diff --git a/internal/app/restore.go b/internal/app/restore.go index 9040127..52ae98e 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -85,14 +85,18 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { if err != nil { return fmt.Errorf("restore: %w", err) } - if err := writeRestorePlan(out, current, plan, RestorePlanOptions{ + report, err := newRestoreReport(current, plan, RestorePlanOptions{ IncludeAudio: includeAudio, Force: force, DryRun: dryRun, - }); err != nil { - return fmt.Errorf("restore: write plan output: %w", err) + }) + if err != nil { + return fmt.Errorf("restore: %w", err) } if dryRun { + if err := writeRestoreDryRunSummary(out, report); err != nil { + return fmt.Errorf("restore: write plan output: %w", err) + } return nil } @@ -109,28 +113,33 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { }() if plan.ConflictCount > 0 && !force { + 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: plan has %d conflicting path(s); rerun with --force or resolve local conflicts", + "restore conflict: %d conflicting path(s); rerun with --force to overwrite (download=%d skip_same=%d conflicts=%d)", + plan.ConflictCount, + plan.DownloadCount, + plan.SkipSameCount, plan.ConflictCount, ) } - result, err := executeRestorePlanFn(ctx, cfg, current, plan, objectStore) + 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) } - - _, err = fmt.Fprintf( - out, - "restore complete: session %s/%s run=%s downloaded=%d skipped_same=%d conflicts=%d\n", - current.Campaign, - current.SessionID, - current.RunID, - result.DownloadedCount, - plan.SkipSameCount, - plan.ConflictCount, - ) - if err != nil { + report.Execution.Downloaded = result.DownloadedCount + report.setSucceeded() + 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 diff --git a/internal/app/restore_execute.go b/internal/app/restore_execute.go index 6ffb7d1..d0744ff 100644 --- a/internal/app/restore_execute.go +++ b/internal/app/restore_execute.go @@ -23,6 +23,7 @@ func executeRestorePlan( cfg *config.Config, current *RemoteCurrentState, plan *RestorePlan, + report *RestoreReport, store storage.ObjectStore, ) (*RestoreExecutionResult, error) { if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil { @@ -61,8 +62,14 @@ func executeRestorePlan( result := &RestoreExecutionResult{} for _, action := range actions { if err := executeRestoreDownloadAction(ctx, cfg, sessionRoot, current, action, store); err != nil { + if report != nil { + report.markFailed(action, err) + } return nil, fmt.Errorf("install %q from %q: %w", action.LocalRelativePath, action.RemoteKey, err) } + if report != nil { + report.markDownloaded(action) + } result.DownloadedCount++ } diff --git a/internal/app/restore_execution_test.go b/internal/app/restore_execution_test.go index d7c76fb..a761adc 100644 --- a/internal/app/restore_execution_test.go +++ b/internal/app/restore_execution_test.go @@ -3,6 +3,7 @@ package app import ( "bytes" "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -38,13 +39,24 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) { if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } - if !strings.Contains(stdout.String(), "restore complete:") { + if !strings.Contains(stdout.String(), "Restored session archive for sample-campaign/2026-05-03") { t.Fatalf("stdout = %q, want completion summary", stdout.String()) } sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1,2,3]}`) mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n") + reportPath := filepath.Join(sessionRoot, "reports", "restore-latest.json") + report := mustReadRestoreReport(t, reportPath) + if report.Status != "succeeded" { + t.Fatalf("report status = %q, want succeeded", report.Status) + } + if report.Execution.Downloaded != 3 { + t.Fatalf("report execution.downloaded = %d, want 3", report.Execution.Downloaded) + } + if len(report.Actions) == 0 { + t.Fatal("report actions is empty") + } if _, err := os.Stat(filepath.Join(sessionRoot, "audio", "alice.flac")); !os.IsNotExist(err) { t.Fatalf("audio should not be restored by default; stat err=%v", err) } @@ -69,6 +81,10 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) { sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio") + report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json")) + if !report.IncludeAudio { + t.Fatalf("report include_audio = %v, want true", report.IncludeAudio) + } } func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) { @@ -94,6 +110,13 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) { t.Fatalf("stderr = %q, want conflict failure", stderr.String()) } mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript") + report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json")) + if report.Status != "failed" { + t.Fatalf("report status = %q, want failed", report.Status) + } + if report.Plan.Conflicts != 1 { + t.Fatalf("report plan.conflicts = %d, want 1", report.Plan.Conflicts) + } } func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) { @@ -116,6 +139,10 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript") + report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json")) + if !report.Force { + t.Fatalf("report force = %v, want true", report.Force) + } } func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) { @@ -192,6 +219,13 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing. t.Fatalf("stderr = %q, want manifest validation failure", stderr.String()) } mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript") + report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json")) + if report.Status != "failed" { + t.Fatalf("report status = %q, want failed", report.Status) + } + if strings.TrimSpace(report.Error) == "" { + t.Fatal("report error is empty, want failure context") + } afterData, err := os.ReadFile(existingPath) if err != nil { t.Fatalf("read local manifest after failure: %v", err) @@ -214,7 +248,11 @@ func TestExecuteRestorePlanPathMismatchFails(t *testing.T) { LocalPath: "/tmp/escape.txt", }}} - _, err := executeRestorePlan(context.Background(), cfg, current, plan, store) + report, err := newRestoreReport(current, plan, RestorePlanOptions{}) + if err != nil { + t.Fatalf("newRestoreReport() error = %v", err) + } + _, err = executeRestorePlan(context.Background(), cfg, current, plan, report, store) if err == nil { t.Fatal("expected error, got nil") } @@ -223,6 +261,19 @@ func TestExecuteRestorePlanPathMismatchFails(t *testing.T) { } } +func mustReadRestoreReport(t *testing.T, path string) *RestoreReport { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + var report RestoreReport + if err := json.Unmarshal(data, &report); err != nil { + t.Fatalf("Unmarshal restore report %q: %v", path, err) + } + return &report +} + func restoreWithStoreAndRealPhases(t *testing.T, objectStore storage.ObjectStore) { t.Helper() origStoreFn := newObjectStoreFromConfigFn diff --git a/internal/app/restore_report.go b/internal/app/restore_report.go new file mode 100644 index 0000000..590bcdf --- /dev/null +++ b/internal/app/restore_report.go @@ -0,0 +1,243 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +// RestoreReport is the durable restore diagnostic model. +type RestoreReport struct { + GeneratedAt string `json:"generated_at"` + SessionID string `json:"session_id"` + Campaign string `json:"campaign"` + RunID string `json:"run_id"` + DryRun bool `json:"dry_run"` + Force bool `json:"force"` + IncludeAudio bool `json:"include_audio"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Plan RestorePlanSummary `json:"plan"` + Execution RestoreExecutionStats `json:"execution"` + Actions []RestoreReportAction `json:"actions"` + reportPathRel string +} + +type RestorePlanSummary struct { + Actions int `json:"actions"` + Download int `json:"download"` + SkipSame int `json:"skip_same"` + Conflicts int `json:"conflicts"` +} + +type RestoreExecutionStats struct { + Downloaded int `json:"downloaded"` + Failed int `json:"failed"` +} + +type RestoreReportAction struct { + Kind string `json:"kind"` + LocalRelativePath string `json:"local_relative_path"` + RemoteKey string `json:"remote_key"` + Reason string `json:"reason,omitempty"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +func newRestoreReport(current *RemoteCurrentState, plan *RestorePlan, opts RestorePlanOptions) (*RestoreReport, error) { + if current == nil { + return nil, fmt.Errorf("remote current state is required") + } + if plan == nil { + return nil, fmt.Errorf("restore plan is required") + } + r := &RestoreReport{ + GeneratedAt: nowUTC().Format("2006-01-02T15:04:05.999999999Z07:00"), + SessionID: current.SessionID, + Campaign: current.Campaign, + RunID: current.RunID, + DryRun: opts.DryRun, + Force: opts.Force, + IncludeAudio: opts.IncludeAudio, + Status: "planned", + Plan: RestorePlanSummary{ + Actions: len(plan.Actions), + Download: plan.DownloadCount, + SkipSame: plan.SkipSameCount, + Conflicts: plan.ConflictCount, + }, + Actions: make([]RestoreReportAction, 0, len(plan.Actions)), + reportPathRel: filepath.ToSlash(filepath.Join(config.PathReportsDirSegment, "restore-latest.json")), + } + for _, action := range plan.Actions { + r.Actions = append(r.Actions, RestoreReportAction{ + Kind: string(action.Kind), + LocalRelativePath: action.LocalRelativePath, + RemoteKey: action.RemoteKey, + Reason: action.Reason, + Status: initialRestoreActionStatus(action.Kind), + }) + } + return r, nil +} + +func initialRestoreActionStatus(kind RestoreActionKind) string { + switch kind { + case RestoreActionDownload: + return "planned_download" + case RestoreActionSkipSame: + return "skipped_same" + case RestoreActionConflict: + return "conflict" + default: + return "planned" + } +} + +func (r *RestoreReport) markDownloaded(action RestoreAction) { + if r == nil { + return + } + if idx := r.findAction(action); idx >= 0 { + r.Actions[idx].Status = "downloaded" + r.Actions[idx].Error = "" + } + r.Execution.Downloaded++ +} + +func (r *RestoreReport) markFailed(action RestoreAction, err error) { + if r == nil { + return + } + if idx := r.findAction(action); idx >= 0 { + r.Actions[idx].Status = "failed" + if err != nil { + r.Actions[idx].Error = err.Error() + } + } + r.Execution.Failed++ +} + +func (r *RestoreReport) setFailed(err error) { + if r == nil { + return + } + r.Status = "failed" + if err != nil { + r.Error = err.Error() + } +} + +func (r *RestoreReport) setSucceeded() { + if r == nil { + return + } + r.Status = "succeeded" + r.Error = "" +} + +func (r *RestoreReport) findAction(action RestoreAction) int { + if r == nil { + return -1 + } + for i := range r.Actions { + if r.Actions[i].LocalRelativePath == action.LocalRelativePath && r.Actions[i].RemoteKey == action.RemoteKey { + return i + } + } + return -1 +} + +func writeRestoreDryRunSummary(out io.Writer, report *RestoreReport) error { + if out == nil { + return fmt.Errorf("output writer is required") + } + if report == nil { + return fmt.Errorf("restore report is required") + } + if _, err := fmt.Fprintf(out, "Restore plan for %s/%s\n", report.Campaign, report.SessionID); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Remote run: %s\n", report.RunID); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Would download: %d\n", report.Plan.Download); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Would skip unchanged: %d\n", report.Plan.SkipSame); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Conflicts: %d\n", report.Plan.Conflicts); err != nil { + return err + } + for _, action := range report.Actions { + line := "" + switch action.Status { + case "planned_download": + line = "Would download: " + action.LocalRelativePath + case "skipped_same": + line = "Would skip unchanged: " + action.LocalRelativePath + case "conflict": + line = "Conflict: " + action.LocalRelativePath + default: + line = strings.TrimSpace(action.Kind) + ": " + action.LocalRelativePath + } + if _, err := fmt.Fprintln(out, line); err != nil { + return err + } + } + return nil +} + +func writeRestoreSuccessSummary(out io.Writer, report *RestoreReport) error { + if out == nil { + return fmt.Errorf("output writer is required") + } + if report == nil { + return fmt.Errorf("restore report is required") + } + if _, err := fmt.Fprintf(out, "Restored session archive for %s/%s\n", report.Campaign, report.SessionID); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Remote run: %s\n", report.RunID); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Downloaded: %d\n", report.Execution.Downloaded); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Skipped unchanged: %d\n", report.Plan.SkipSame); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "Conflicts: %d\n", report.Plan.Conflicts); err != nil { + return err + } + return nil +} + +func persistRestoreReport(store artifacts.Store, cfg *config.Config, report *RestoreReport) (string, error) { + if store == nil { + return "", fmt.Errorf("artifact store is required") + } + if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil { + return "", fmt.Errorf("resolved config with pipeline/session is required") + } + if report == nil { + return "", fmt.Errorf("restore report is required") + } + sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) + reportPath := filepath.Join(sessionRoot, filepath.FromSlash(report.reportPathRel)) + payload, err := json.MarshalIndent(report, "", " ") + if err != nil { + return "", fmt.Errorf("marshal restore report: %w", err) + } + payload = append(payload, '\n') + if err := store.WriteFileAtomic(reportPath, payload, 0o644); err != nil { + return "", fmt.Errorf("write restore report %q: %w", reportPath, err) + } + return reportPath, nil +} diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go index 20e86f1..f503d6a 100644 --- a/internal/app/restore_test.go +++ b/internal/app/restore_test.go @@ -94,10 +94,13 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { t.Fatalf("stderr = %q, want empty", stderr.String()) } outText := stdout.String() - if !strings.Contains(outText, "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") { + if !strings.Contains(outText, "Restore plan for sample-campaign/2026-05-03") { t.Fatalf("stdout = %q, want restore plan summary", outText) } - if !strings.Contains(outText, "download manifest.json <- dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json") { + if !strings.Contains(outText, "Would download: 1") { + t.Fatalf("stdout = %q, want plan count output", outText) + } + if !strings.Contains(outText, "Would download: manifest.json") { t.Fatalf("stdout = %q, want action output", outText) } @@ -105,6 +108,10 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { t.Fatalf("manifest should not be created during phase-4 restore planning; stat err=%v", err) } + reportPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "reports", "restore-latest.json") + if _, err := os.Stat(reportPath); !os.IsNotExist(err) { + t.Fatalf("restore report should not be written during dry-run; stat err=%v", err) + } } func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) { @@ -210,10 +217,10 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) { if code == 0 { t.Fatal("exit code = 0, want non-zero") } - if !strings.Contains(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") { - t.Fatalf("stdout = %q, want plan output", stdout.String()) + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty on conflict failure", stdout.String()) } - if !strings.Contains(stderr.String(), "plan has 1 conflicting path(s)") { + if !strings.Contains(stderr.String(), "restore conflict: 1 conflicting path(s); rerun with --force to overwrite") { t.Fatalf("stderr = %q, want conflict failure", stderr.String()) } if strings.Contains(stderr.String(), "phase 4: restore execution") { @@ -250,7 +257,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) { DownloadCount: 1, }, nil } - executeRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, storage.ObjectStore) (*RestoreExecutionResult, error) { + executeRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, *RestoreReport, storage.ObjectStore) (*RestoreExecutionResult, error) { return &RestoreExecutionResult{DownloadedCount: 1}, nil } @@ -262,10 +269,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) { if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } - if !strings.Contains(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") { - t.Fatalf("stdout = %q, want plan output", stdout.String()) - } - if !strings.Contains(stdout.String(), "restore complete: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4 downloaded=1 skipped_same=0 conflicts=0") { + if !strings.Contains(stdout.String(), "Restored session archive for sample-campaign/2026-05-03") { t.Fatalf("stdout = %q, want completion summary", stdout.String()) } if stderr.Len() != 0 {