package app import ( "context" "errors" "flag" "fmt" "io" "log/slog" "os" "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/logging" ) var newObjectStoreFromConfigFn = storage.NewObjectStoreFromConfig var discoverRemoteCurrentStateFn = discoverRemoteCurrentState var buildRestorePlanFn = buildRestorePlan var executeRestorePlanFn = executeRestorePlan // Restore validates restore CLI/config inputs and storage preflight for future restore phases. func Restore(ctx context.Context, args []string, out io.Writer) (resultErr error) { positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("restore", flag.ContinueOnError) fs.SetOutput(out) var flags commonConfigFlags var dryRun bool var force bool var includeAudio bool addCommonConfigFlags(fs, &flags) fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files") fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") fs.BoolVar(&includeAudio, "include-audio", false, "include remote session-level audio objects") fs.Usage = func() { _, _ = fmt.Fprintln(out, "Usage: narratio session restore [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--dry-run] [--force] [--include-audio]") _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out, "Flags:") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return fmt.Errorf("restore: invalid flags: %w", err) } if err := resolveParsedSessionID("restore", positionalSessionID, fs, &flags.sessionID); err != nil { return err } if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("restore: session_id is required") } cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("restore: %w", err) } if err := config.Validate(cfg); err != nil { return fmt.Errorf("restore: %w", err) } objectStore, err := newCommandObjectStore(ctx, cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)) if err != nil { return fmt.Errorf("restore: %w", err) } current, err := discoverRemoteCurrentStateFn(ctx, cfg, objectStore) if err != nil { return fmt.Errorf("restore: %w", err) } if dryRun { plan, err := buildRestorePlanFn(ctx, cfg, current, objectStore, RestorePlanOptions{ IncludeAudio: includeAudio, Force: force, DryRun: true, }) if err != nil { return fmt.Errorf("restore: %w", err) } report, err := newRestoreReport(current, plan, RestorePlanOptions{ IncludeAudio: includeAudio, Force: force, DryRun: true, }) if err != nil { return fmt.Errorf("restore: %w", err) } if err := writeRestoreDryRunSummary(out, report); err != nil { return fmt.Errorf("restore: write plan output: %w", err) } return nil } artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) if _, err := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID); err != nil { return fmt.Errorf("restore: prepare workdir: %w", err) } lock, err := artifactStore.AcquireSessionLockForContext(ctx, cfg.Session.Campaign, cfg.Session.SessionID) if err != nil { return fmt.Errorf("restore: acquire session lock: %w", err) } defer func() { if releaseErr := artifactStore.ReleaseSessionLock(lock); releaseErr != nil { if resultErr == nil { resultErr = fmt.Errorf("restore: release session lock: %w", releaseErr) } else { resultErr = errors.Join(resultErr, fmt.Errorf("restore: release session lock: %w", releaseErr)) } } }() // Classification and installation share the same transition lock as a // runner. This prevents a runner from making reuse decisions against state // that restore is about to replace. plan, err := buildRestorePlanFn(ctx, cfg, current, objectStore, RestorePlanOptions{ IncludeAudio: includeAudio, Force: force, }) if err != nil { return fmt.Errorf("restore: %w", err) } report, err := newRestoreReport(current, plan, RestorePlanOptions{ IncludeAudio: includeAudio, Force: force, }) if err != nil { return fmt.Errorf("restore: %w", err) } if plan.ConflictCount > 0 { report.setFailed(fmt.Errorf("conflict: %d conflicting path(s)", plan.ConflictCount)) if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil { return fmt.Errorf("restore: report failure: %w", reportErr) } return fmt.Errorf( "restore conflict: %d conflicting path(s); --force can replace eligible regular files but not unresolved conflicts (download=%d skip_same=%d conflicts=%d)", plan.ConflictCount, plan.DownloadCount, plan.SkipSameCount, plan.ConflictCount, ) } if err := writeRestoreMarker(artifactStore.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)); err != nil { return fmt.Errorf("restore: mark incomplete restore: %w", err) } result, err := executeRestorePlanFn(ctx, cfg, current, plan, report, objectStore) if err != nil { report.setFailed(err) if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil { return fmt.Errorf("restore: execute plan failed (%v) and report write failed (%v)", err, reportErr) } return fmt.Errorf("restore: execute plan: %w", err) } report.Execution.Downloaded = result.DownloadedCount report.setSucceeded() if err := clearRestoreMarker(artifactStore.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)); err != nil { return fmt.Errorf("restore: clear incomplete restore marker: %w", err) } if _, err := persistRestoreReport(artifactStore, cfg, report); err != nil { return fmt.Errorf("restore: write report: %w", err) } if err := writeRestoreSuccessSummary(out, report); err != nil { return fmt.Errorf("restore: write summary: %w", err) } return nil }