package api import ( "context" "encoding/json" "fmt" "os" "path/filepath" "bat-api/internal/backendrpc" ) // Backend is the subset of daemon RPC used by bat-api. // // Call order for discovery (per plan review): // 1. daemon.status // 2. daemon.doctor // 3. catalog.status / resource.manifest (and resource.state as needed) type Backend interface { DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) CatalogStatus(ctx context.Context) (json.RawMessage, error) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) } // ControlBackend is the explicitly allowlisted mutation subset exposed through // the authenticated bat-api admin control surface. // // It intentionally does not include daemon.stop, cleanup, or generic RPC calls. // Restart is forwarded only to Rust's lifecycle RPC; Go never execs bat itself. type ControlBackend interface { DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) } // DaemonLogsBackend exposes the Rust daemon log tail for an authenticated // dashboard. It remains read-only and never opens log files from Go. type DaemonLogsBackend interface { DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) } // TaskBackend exposes Rust-owned async task state to the dashboard. Go only // forwards read/cancel requests and does not create generic tasks. type TaskBackend interface { TaskList(ctx context.Context) (*backendrpc.TaskList, error) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) } // ParseBackend exposes existing Rust TextUnit index queries to the dashboard. // It is read-only and does not expand parser coverage. type ParseBackend interface { ParseStatus(ctx context.Context) (json.RawMessage, error) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) } // ScheduleBackend exposes the Rust-owned schedule store to an authenticated // dashboard. The JSON result remains Rust's report shape so the API does not // duplicate schedule state or invent a second schema. type ScheduleBackend interface { ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) } // TranslationBackend exposes the narrow provider-worker state mutation used // by the dashboard. It does not create arbitrary translation jobs. type TranslationBackend interface { TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) TranslationTasks(ctx context.Context, params backendrpc.TranslationTaskListParams) (json.RawMessage, error) TranslationHandoff(ctx context.Context) (json.RawMessage, error) TranslationWorkerRun(ctx context.Context, params backendrpc.TranslationWorkerRunParams) (*backendrpc.TranslationWorkerRunResult, error) TranslationProofread(ctx context.Context) (json.RawMessage, error) } // TranslationMemoryBackend exposes the Rust-owned Translation Memory query and // explicit confirmation operations. Go forwards typed requests and responses // but never opens or mutates the TM database itself. type TranslationMemoryBackend interface { TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error) TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error) } // LocalizedBackend exposes localized release status and the explicit // publish/rollback controls used by the authenticated dashboard. type LocalizedBackend interface { LocalizedStatus(ctx context.Context) (json.RawMessage, error) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) } // RPCClient adapts *backendrpc.Client to Backend. type RPCClient struct { Client *backendrpc.Client } func (r RPCClient) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) { return r.Client.DaemonStatus(ctx) } func (r RPCClient) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) { return r.Client.DaemonDoctor(ctx) } func (r RPCClient) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) { return r.Client.ResourceState(ctx) } func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.CatalogStatus(ctx) } func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) { return r.Client.ResourceManifest(ctx, offset, limit) } func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) { return r.Client.DaemonRestart(ctx) } func (r RPCClient) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) { return r.Client.DaemonReload(ctx) } func (r RPCClient) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) { return r.Client.DaemonRefresh(ctx, force) } func (r RPCClient) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) { return r.Client.DaemonLogs(ctx, tail) } func (r RPCClient) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { return r.Client.ResourceSync(ctx, force) } func (r RPCClient) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) { return r.Client.ResourceVerify(ctx) } func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) { return r.Client.ResourceRepair(ctx) } func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) { return r.Client.CatalogRefresh(ctx, force) } func (r RPCClient) TaskList(ctx context.Context) (*backendrpc.TaskList, error) { return r.Client.TaskList(ctx) } func (r RPCClient) TaskStatus(ctx context.Context, taskID string) (*backendrpc.TaskRecord, error) { return r.Client.TaskStatus(ctx, taskID) } func (r RPCClient) TaskLogs(ctx context.Context, taskID string) (*backendrpc.TaskLogs, error) { return r.Client.TaskLogs(ctx, taskID) } func (r RPCClient) TaskCancel(ctx context.Context, taskID string) (*backendrpc.TaskCancelResult, error) { return r.Client.TaskCancel(ctx, taskID) } func (r RPCClient) ScheduleList(ctx context.Context, params backendrpc.ScheduleListParams) (json.RawMessage, error) { return r.Client.ScheduleListFiltered(ctx, params) } func (r RPCClient) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { return r.Client.ScheduleAdd(ctx, params) } func (r RPCClient) ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { return r.Client.ScheduleUpdate(ctx, params) } func (r RPCClient) ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) { return r.Client.ScheduleRemove(ctx, params) } func (r RPCClient) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) { return r.Client.ScheduleRun(ctx, params) } func (r RPCClient) TranslationTaskUpdate(ctx context.Context, params backendrpc.TranslationTaskUpdateParams) (json.RawMessage, error) { return r.Client.TranslationTaskUpdate(ctx, params) } func (r RPCClient) TranslationTasks(ctx context.Context, params backendrpc.TranslationTaskListParams) (json.RawMessage, error) { return r.Client.TranslationTasks(ctx, params) } func (r RPCClient) TranslationHandoff(ctx context.Context) (json.RawMessage, error) { return r.Client.TranslationHandoff(ctx) } func (r RPCClient) TranslationWorkerRun(ctx context.Context, params backendrpc.TranslationWorkerRunParams) (*backendrpc.TranslationWorkerRunResult, error) { return r.Client.TranslationWorkerRun(ctx, params) } func (r RPCClient) TranslationProofread(ctx context.Context) (json.RawMessage, error) { return r.Client.TranslationProofread(ctx) } func (r RPCClient) TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) { return r.Client.TranslationMemorySummary(ctx, params) } func (r RPCClient) TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error) { return r.Client.TranslationMemoryQuery(ctx, params) } func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error) { return r.Client.TranslationMemoryConfirm(ctx, params) } func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.LocalizedStatus(ctx) } func (r RPCClient) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) { return r.Client.LocalizedPublish(ctx, params) } func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) { return r.Client.LocalizedRollback(ctx, params) } func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) { return r.Client.ParseStatus(ctx) } func (r RPCClient) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) { return r.Client.ParseTextUnits(ctx, query) } func (r RPCClient) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) { return r.Client.ParseErrors(ctx, query) } func (r RPCClient) UnityFSPatchTextAsset(ctx context.Context, params backendrpc.UnityFSTextAssetPatchParams) (json.RawMessage, error) { return r.Client.UnityFSPatchTextAsset(ctx, params) } func (r RPCClient) UnityFSPatchStringField(ctx context.Context, params backendrpc.UnityFSStringFieldPatchParams) (json.RawMessage, error) { return r.Client.UnityFSPatchStringField(ctx, params) } func (r RPCClient) UnityFSPatchField(ctx context.Context, params backendrpc.UnityFSFieldPatchParams) (json.RawMessage, error) { return r.Client.UnityFSPatchField(ctx, params) } // DiscoverResult is the outcome of talking to the bat daemon. type DiscoverResult struct { RPCAvailable bool DoctorHealthy *bool Status *backendrpc.DaemonStatusReport Doctor *backendrpc.DoctorReport Snapshot *SnapshotSummary ResourceRoot string Index *ReleaseIndex Warnings []string } // DiscoverAndIndex contacts the daemon (status first, then doctor) and builds // a release index from paginated resource.manifest plus on-disk checks. // // If resourceRootOverride is non-empty, it wins over RPC-reported roots after // RPC health probes (still preferred for production to call status/doctor). func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride string) (*DiscoverResult, error) { out := &DiscoverResult{} if backend == nil { if resourceRootOverride == "" { out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}} return out, nil } idx, err := LoadIndexFromResourceRoot(resourceRootOverride) if err != nil { return nil, err } out.ResourceRoot = idx.ResourceRoot out.Index = idx return out, nil } // 1) daemon.status first status, err := backend.DaemonStatus(ctx) if err != nil { out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.status: %v", err)) if resourceRootOverride != "" { idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride) if loadErr != nil { return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr) } out.ResourceRoot = idx.ResourceRoot out.Index = idx return out, nil } out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}} return out, nil } out.RPCAvailable = true out.Status = status // 2) daemon.doctor second doctor, err := backend.DaemonDoctor(ctx) if err != nil { out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.doctor: %v", err)) } else { out.Doctor = doctor h := doctor.Healthy out.DoctorHealthy = &h } // Catalog / resource discovery var snapshot *SnapshotSummary var resourceRoot string catalogAvailabilityKnown := false catalogAvailable := false if raw, err := backend.CatalogStatus(ctx); err != nil { out.Warnings = append(out.Warnings, fmt.Sprintf("catalog.status: %v", err)) } else { catalogAvailable, catalogAvailabilityKnown = parseCatalogAvailability(raw) snap, root, ok := parseCatalogStatus(raw) if ok { snapshot = snap resourceRoot = root } } if catalogAvailabilityKnown && !catalogAvailable { return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil } if resourceRoot == "" { if state, err := backend.ResourceState(ctx); err != nil { out.Warnings = append(out.Warnings, fmt.Sprintf("resource.state: %v", err)) } else if state.ResourceOutputRoot != nil && *state.ResourceOutputRoot != "" { // Prefer published current under output root when catalog root missing. candidate := filepath.Join(*state.ResourceOutputRoot, "current") resourceRoot = candidate } } if resourceRootOverride != "" { resourceRoot = resourceRootOverride } if resourceRoot == "" { out.Snapshot = snapshot out.Index = &ReleaseIndex{ Source: "rpc", RPCAvailable: true, DoctorHealthy: out.DoctorHealthy, Snapshot: snapshot, byRel: map[string]int{}, } out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version") return out, nil } if resourceRootOverride == "" { info, statErr := os.Stat(resourceRoot) if statErr != nil || !info.IsDir() { warning := fmt.Sprintf("published resource root is unavailable: %s", resourceRoot) if statErr != nil { warning = fmt.Sprintf("%s (%v)", warning, statErr) } else { warning = fmt.Sprintf("%s (not a directory)", warning) } return emptyRPCResult(out, snapshot, warning), nil } } entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend) if err != nil { out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err)) // Fallback: load local manifest file under root. idx, loadErr := LoadIndexFromResourceRoot(resourceRoot) if loadErr != nil { if resourceRootOverride == "" { return emptyRPCResult( out, snapshot, fmt.Sprintf("published release cannot be indexed: %v", loadErr), ), nil } return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr) } idx.Source = "rpc+local_manifest" idx.RPCAvailable = true idx.DoctorHealthy = out.DoctorHealthy if snapshot != nil { idx.Snapshot = snapshot } out.ResourceRoot = idx.ResourceRoot out.Snapshot = idx.Snapshot out.Index = idx return out, nil } if rootFromManifest != "" { resourceRoot = rootFromManifest } if resourceRootOverride != "" { resourceRoot = resourceRootOverride } idx, err := BuildIndexFromManifestEntries( resourceRoot, "rpc", true, out.DoctorHealthy, snapshot, manifestVersion, entries, ) if err != nil { if resourceRootOverride == "" { return emptyRPCResult( out, snapshot, fmt.Sprintf("published release failed local validation: %v", err), ), nil } return out, err } out.ResourceRoot = idx.ResourceRoot out.Snapshot = snapshot out.Index = idx return out, nil } func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult { out.ResourceRoot = "" out.Snapshot = snapshot out.Index = &ReleaseIndex{ Source: "rpc", RPCAvailable: true, DoctorHealthy: out.DoctorHealthy, Snapshot: snapshot, byRel: map[string]int{}, } if warning != "" { out.Warnings = append(out.Warnings, warning) } return out } func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) { if len(raw) == 0 || string(raw) == "null" { return nil, "", false } var payload struct { Available bool `json:"available"` Status string `json:"status"` StatusCode string `json:"status_code"` DistributionStatus string `json:"distribution_status"` DistributionStatusCode string `json:"distribution_status_code"` AppVersion string `json:"app_version"` BundleVersion string `json:"bundle_version"` ConnectionGroupName string `json:"connection_group_name"` AddressablesRoot string `json:"addressables_root"` LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"` GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap"` Version *struct { ID string `json:"id"` CompletedUnixSeconds *uint64 `json:"completed_unix_seconds"` ResourceRoot string `json:"resource_root"` } `json:"version"` } if err := json.Unmarshal(raw, &payload); err != nil || !payload.Available { return nil, "", false } snap := &SnapshotSummary{ Status: payload.Status, StatusCode: payload.StatusCode, DistributionStatus: payload.DistributionStatus, DistributionStatusCode: payload.DistributionStatusCode, AppVersion: payload.AppVersion, BundleVersion: payload.BundleVersion, ConnectionGroupName: payload.ConnectionGroupName, AddressablesRoot: payload.AddressablesRoot, LauncherMetadata: payload.LauncherMetadata, GameMainConfig: payload.GameMainConfigBootstrap, } root := "" if payload.Version != nil { snap.VersionID = payload.Version.ID snap.CompletedUnixSeconds = payload.Version.CompletedUnixSeconds root = payload.Version.ResourceRoot } return snap, root, true } func parseCatalogAvailability(raw json.RawMessage) (bool, bool) { if len(raw) == 0 || string(raw) == "null" { return false, false } var payload struct { Available *bool `json:"available"` } if err := json.Unmarshal(raw, &payload); err != nil || payload.Available == nil { return false, false } return *payload.Available, true } func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) { const pageSize = 500 offset := 0 var all []manifestEntry var version int var root string for { page, err := backend.ResourceManifest(ctx, offset, pageSize) if err != nil { return nil, 0, "", err } if !page.Available { return nil, 0, "", fmt.Errorf("resource.manifest available=false") } if root == "" { root = page.ResourceRoot } if version == 0 { version = page.ManifestVersion } for _, e := range page.Entries { var bytes uint64 if e.Bytes != nil { bytes = *e.Bytes } all = append(all, manifestEntry{ URL: e.URL, Destination: e.Destination, Bytes: bytes, BLAKE3: e.BLAKE3, }) } offset += len(page.Entries) if len(page.Entries) == 0 || offset >= page.TotalEntries { break } } return all, version, root, nil }