diff --git a/docs/internal/stage-polish.md b/docs/internal/stage-polish.md index a626373..14da9a6 100644 --- a/docs/internal/stage-polish.md +++ b/docs/internal/stage-polish.md @@ -12,21 +12,26 @@ Run Audita polishing on base transcript and produce polished transcript. ## Outputs - `transcripts/polished.json` -- optional `artifacts/audita.report.json` + +The transcript is the only canonical output. Available `errors.yml`, +`recoveries.yml`, and `report.md` remain beside the run-local transcript and +are recorded as diagnostics, not artifact outputs. ## Key Behavior - resolves base transcript only from verified merge manifest output evidence. -- invokes an Audita runner configured with static model/runtime options; the - invocation supplies paths and modules. +- invokes an Audita v2 runner with external configuration selectors and + explicitly forwarded provider credentials; the invocation supplies paths. - validates processed transcript structure (`segments` array required). -- validates optional report JSON. -- materializes canonical outputs; records logs/generated config and adapter metadata. +- validates reported diagnostic paths against the three fixed adjacent names. +- materializes only the canonical transcript; records available reports with + stdout/stderr logs, generated invocation, and safe adapter metadata. ## Invariants - polished transcript schema validation is mandatory. -- report output is config-gated. +- reports are optional diagnostics; Audita's external configuration may disable + them. A failed invocation never promotes an incidental transcript. ## Resume Semantics diff --git a/docs/operations.md b/docs/operations.md index 21ab891..c374c29 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -426,6 +426,9 @@ tree and reports which stream exceeded the limit. Run-local diagnostics are: +- `runs/{run_id}/polish/outputs/transcripts/errors.yml`, + `recoveries.yml`, and `report.md` when Audita writes them beside its + run-local polished transcript - `runs/{run_id}/extract/notarius.receipt.json` - `runs/{run_id}/extract/notarius.stderr.log` - `runs/{run_id}/extract/notarius-output/` before durable promotion @@ -439,6 +442,10 @@ Unlisted files and unsafe entries are never uploaded; a run with no declared current-run diagnostics uploads no run-local files. No distinguished diagnostic file is uploaded. The durable bundle is never scanned for implicit publication; only lanes named by explicit `pipeline.publish.outputs` rules are uploaded. +To inspect a published polish report remotely, read its declared object under +the run archive's `polish/outputs/transcripts/` prefix. Session restore does +not restore run-archive diagnostics as canonical session files; it restores only +the committed manifest and declared transcript/artifact objects. To intentionally replace the current extraction result, run: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 475729a..4894b86 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -316,7 +316,10 @@ semantic integration; all stages are required before release. ## Stage 5: Integrate polish reports with diagnostic lifecycle -Status: Not started. +Status: Complete (2026-09-25). + +Verification: `go test ./internal/stage ./internal/app ./internal/manifest ./internal/artifacts`, +`go test -race ./internal/stage ./internal/app`, and `git diff --check` passed. Preparation: `internal/stage/polish.go`, `polish_test.go`, `run_local.go`, `stage.go`, `publish_sources.go`; result application in `internal/app/runner.go`; diff --git a/internal/adapters/audita/fake.go b/internal/adapters/audita/fake.go index f9d8283..6a057d7 100644 --- a/internal/adapters/audita/fake.go +++ b/internal/adapters/audita/fake.go @@ -17,7 +17,7 @@ func (n *NoopRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, if err := ctx.Err(); err != nil { return PolishResult{}, err } - if err := materializePlaceholders(req); err != nil { + if err := materializePlaceholders(req, false); err != nil { return PolishResult{}, err } return PolishResult{ @@ -45,13 +45,21 @@ func (f *FakeRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, if f.Err != nil { return PolishResult{}, f.Err } - if err := materializePlaceholders(req); err != nil { + if err := materializePlaceholders(req, true); err != nil { return PolishResult{}, err } res := f.Result if res.ProcessedTranscriptPath == "" { res.ProcessedTranscriptPath = req.OutputProcessedPath } + if res.Reports == (ReportBundle{}) { + dir := filepath.Dir(req.OutputProcessedPath) + res.Reports = ReportBundle{ + ErrorsPath: filepath.Join(dir, "errors.yml"), + RecoveriesPath: filepath.Join(dir, "recoveries.yml"), + OverviewPath: filepath.Join(dir, "report.md"), + } + } if res.StdoutLogPath == "" { res.StdoutLogPath = req.StdoutLogPath } @@ -67,7 +75,7 @@ func (f *FakeRunner) Run(ctx context.Context, req PolishRequest) (PolishResult, return res, nil } -func materializePlaceholders(req PolishRequest) error { +func materializePlaceholders(req PolishRequest, reports bool) error { if req.GeneratedConfigPath != "" { payload := map[string]any{ "schema": "audita.generated.v2", @@ -95,6 +103,19 @@ func materializePlaceholders(req PolishRequest) error { }); err != nil { return err } + if !reports { + return nil + } + for _, report := range []struct{ name, contents string }{ + {"errors.yml", "schema: audita-errors\nversion: 1\nstatus: completed\nerrors: []\n"}, + {"recoveries.yml", "schema: audita-recoveries\nversion: 1\nstatus: completed\nrecoveries: []\n"}, + {"report.md", "# Audita report\n"}, + } { + path := filepath.Join(filepath.Dir(req.OutputProcessedPath), report.name) + if err := fileops.WriteFileAtomic(path, []byte(report.contents), fileops.WorkspaceFileMode); err != nil { + return fmt.Errorf("write report %q: %w", path, err) + } + } return nil } diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 07a1836..0c59a3c 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -274,6 +274,28 @@ inputs: if !strings.Contains(stdout.String(), "stage=polish executed=1 skipped=0") { t.Fatalf("stdout = %q, want polish execution", stdout.String()) } + recorded, err := (&manifest.LocalStore{}).Load(context.Background(), filepath.Join(workRoot, "manifest.json")) + if err != nil { + t.Fatalf("load polished manifest: %v", err) + } + polish := recorded.Stages["polish"] + if polish == nil || len(polish.Outputs) != 1 || polish.Outputs[0].Kind != "transcript_polished" { + t.Fatalf("polish outputs = %#v, want only canonical transcript", polish) + } + if len(polish.Logs) != 5 || len(polish.GeneratedConfigs) != 1 { + t.Fatalf("polish diagnostics = %#v, want logs, reports, and invocation", polish) + } + for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} { + found := false + for _, path := range polish.Logs { + if filepath.Base(path) == name { + found = true + } + } + if !found { + t.Fatalf("report %q absent from manifest diagnostics: %#v", name, polish.Logs) + } + } foundReference := false if err := filepath.WalkDir(workRoot, func(path string, entry os.DirEntry, walkErr error) error { if walkErr != nil || entry.IsDir() { diff --git a/internal/app/restore_commit_test.go b/internal/app/restore_commit_test.go index f542f2e..fe6c862 100644 --- a/internal/app/restore_commit_test.go +++ b/internal/app/restore_commit_test.go @@ -44,6 +44,28 @@ func TestCommittedRestorePlanUsesOnlyDeclaredObjects(t *testing.T) { } } +func TestCommittedRestoreDoesNotRequirePolishRunDiagnostics(t *testing.T) { + cfg := restorePlanConfig(t) + fake := &storage.FakeBackend{} + current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ + "transcripts/full.json": []byte(`{"segments":[]}`), + }) + diagnostic := filepath.Join(cfg.Pipeline.Workspace.Root, "runs", current.RunID, "polish", "outputs", "transcripts", "errors.yml") + current.Manifest.Stages["polish"] = &manifest.StageRecord{ + Name: "polish", Status: manifest.StatusSucceeded, + Logs: []string{diagnostic}, + } + plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{}) + if err != nil { + t.Fatalf("buildRestorePlan() error = %v", err) + } + for _, action := range plan.Actions { + if strings.Contains(action.LocalRelativePath, "errors.yml") { + t.Fatalf("diagnostic became a required restore target: %#v", action) + } + } +} + func TestCommittedRestoreDownloadsCarriedOutputFromDeclaredRun(t *testing.T) { cfg := restorePlanConfig(t) fake := &storage.FakeBackend{} diff --git a/internal/stage/polish.go b/internal/stage/polish.go index e365934..99f65d8 100644 --- a/internal/stage/polish.go +++ b/internal/stage/polish.go @@ -87,7 +87,8 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St res, err := env.Audita.Run(ctx, req) if err != nil { - return nil, fmt.Errorf("polish: audita polish failed: %w", err) + return nil, fmt.Errorf("polish: audita polish failed (output_dir=%q, stdout_log=%q, stderr_log=%q): %w", + filepath.Dir(processedPath), stdoutPath, stderrPath, err) } finalProcessedPath, err := authoritativeOutputPath(processedPath, res.ProcessedTranscriptPath) @@ -97,6 +98,27 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St if err := validateProcessedTranscriptOutput(finalProcessedPath); err != nil { return nil, fmt.Errorf("polish: processed transcript %q invalid: %w", finalProcessedPath, err) } + reportPaths := make([]string, 0, 3) + reportMetadata := map[string]string{} + for _, report := range []struct{ name, returned string }{ + {"errors.yml", res.Reports.ErrorsPath}, + {"recoveries.yml", res.Reports.RecoveriesPath}, + {"report.md", res.Reports.OverviewPath}, + } { + if report.returned == "" { + continue + } + expected := filepath.Join(filepath.Dir(processedPath), report.name) + path, err := authoritativeOutputPath(expected, report.returned) + if err != nil { + return nil, fmt.Errorf("polish: report %s: %w", report.name, err) + } + if err := requireRegularReadableFile(path, "audita report"); err != nil { + return nil, fmt.Errorf("polish: %w", err) + } + reportPaths = append(reportPaths, path) + reportMetadata[report.name] = path + } materializedProcessed, err := materializeRunLocalOutput(env.ArtifactStore, finalProcessedPath, canonicalProcessedPath, artifacts.Ref{ Kind: "transcript_polished", @@ -133,6 +155,7 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St "adapter_generated_config_path": res.GeneratedConfigPath, "adapter_stdout_log_path": res.StdoutLogPath, "adapter_stderr_log_path": res.StderrLogPath, + "report_paths": reportMetadata, } if res.Metadata != nil { meta["adapter_metadata"] = res.Metadata @@ -140,7 +163,7 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St return &StageResult{ Outputs: outputs, - Logs: []string{stdoutPath, stderrPath}, + Logs: append([]string{stdoutPath, stderrPath}, reportPaths...), GeneratedConfigs: []string{generatedConfigPath}, Metadata: meta, }, nil diff --git a/internal/stage/polish_test.go b/internal/stage/polish_test.go index b97c62a..46458cd 100644 --- a/internal/stage/polish_test.go +++ b/internal/stage/polish_test.go @@ -3,7 +3,9 @@ package stage import ( "context" "errors" + "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -15,6 +17,12 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) +type polishRunnerFunc func(context.Context, audita.PolishRequest) (audita.PolishResult, error) + +func (run polishRunnerFunc) Run(ctx context.Context, req audita.PolishRequest) (audita.PolishResult, error) { + return run(ctx, req) +} + func TestPolishStagePolishesMergedTranscriptAndRecordsMetadata(t *testing.T) { env, m := setupPolishEnv(t) paths := sessionPathsForEnv(env, m.SessionID) @@ -50,8 +58,14 @@ func TestPolishStagePolishesMergedTranscriptAndRecordsMetadata(t *testing.T) { if result.Outputs[0].Kind != "transcript_polished" { t.Fatalf("output[0] kind = %q, want transcript_polished", result.Outputs[0].Kind) } - if len(result.Logs) != 2 { - t.Fatalf("logs = %#v, want 2", result.Logs) + if len(result.Logs) != 5 { + t.Fatalf("logs = %#v, want stdout, stderr, and three reports", result.Logs) + } + for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} { + path := filepath.Join(filepath.Dir(req.OutputProcessedPath), name) + if !slices.Contains(result.Logs, path) { + t.Fatalf("report %q missing from diagnostics: %#v", path, result.Logs) + } } if len(result.GeneratedConfigs) != 1 { t.Fatalf("generated configs = %#v, want 1", result.GeneratedConfigs) @@ -63,6 +77,9 @@ func TestPolishStagePolishesMergedTranscriptAndRecordsMetadata(t *testing.T) { if result.Metadata["output_schema"] != "audita-v1" { t.Fatalf("metadata output_schema = %#v, want audita-v1", result.Metadata["output_schema"]) } + if _, err := os.Stat(filepath.Join(paths.ArtifactsDir, "audita.report.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("legacy report destination exists: %v", err) + } } func TestPolishStageRejectsIncidentalMergedTranscriptPath(t *testing.T) { @@ -149,6 +166,58 @@ func TestPolishStageFailsWhenAdapterFails(t *testing.T) { } } +func TestPolishStageDoesNotPromoteTranscriptAfterAdapterFailure(t *testing.T) { + env, m := setupPolishEnv(t) + env.Config.Session.Campaign = "sample-campaign" + m.Campaign = "sample-campaign" + m.RunID = "20260518T010203Z-abcdef12" + paths := sessionPathsForEnv(env, m.SessionID) + mergedPath := filepath.Join(paths.TranscriptsDir, "base.json") + writeFile(t, mergedPath, `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, mergedPath) + writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") + failure := errors.New("audita exited after output") + var runOutput string + env.Audita = polishRunnerFunc(func(_ context.Context, req audita.PolishRequest) (audita.PolishResult, error) { + runOutput = req.OutputProcessedPath + writeFile(t, runOutput, `{"segments":[]}`) + writeFile(t, filepath.Join(filepath.Dir(runOutput), "errors.yml"), "status: failed\n") + return audita.PolishResult{ProcessedTranscriptPath: runOutput}, failure + }) + _, err := (polishStage{}).Run(context.Background(), env, m) + if !errors.Is(err, failure) || !strings.Contains(err.Error(), filepath.Dir(runOutput)) { + t.Fatalf("error = %v, want original failure with output directory", err) + } + if _, err := os.Stat(filepath.Join(paths.TranscriptsDir, "polished.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("failed output was promoted: %v", err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(runOutput), "errors.yml")); err != nil { + t.Fatalf("run-local diagnostic missing: %v", err) + } +} + +func TestPolishStageRejectsRedirectedReportPath(t *testing.T) { + env, m := setupPolishEnv(t) + env.Config.Session.Campaign = "sample-campaign" + m.Campaign = "sample-campaign" + m.RunID = "20260518T010203Z-abcdef12" + paths := sessionPathsForEnv(env, m.SessionID) + mergedPath := filepath.Join(paths.TranscriptsDir, "base.json") + writeFile(t, mergedPath, `{"segments":[]}`) + setCurrentBuiltInEvidence(t, m, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, mergedPath) + writeFile(t, filepath.Join(paths.InputsDir, "glossary.yml"), "terms: []\n") + redirected := filepath.Join(t.TempDir(), "errors.yml") + writeFile(t, redirected, "status: completed\n") + env.Audita = &audita.FakeRunner{Result: audita.PolishResult{Reports: audita.ReportBundle{ErrorsPath: redirected}}} + _, err := (polishStage{}).Run(context.Background(), env, m) + if err == nil || !strings.Contains(err.Error(), "did not match requested path") { + t.Fatalf("error = %v, want authoritative report path rejection", err) + } + if _, err := os.Stat(filepath.Join(paths.TranscriptsDir, "polished.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("report-path failure promoted output: %v", err) + } +} + func TestPolishStageFailsWhenProcessedOutputInvalid(t *testing.T) { env, m := setupPolishEnv(t) paths := sessionPathsForEnv(env, m.SessionID) diff --git a/internal/stage/publish_test.go b/internal/stage/publish_test.go index 1dc18db..597617c 100644 --- a/internal/stage/publish_test.go +++ b/internal/stage/publish_test.go @@ -1131,6 +1131,32 @@ func TestPublishFailsWithoutObjectStore(t *testing.T) { } } +func TestPublishArchivesCurrentPolishReportsOnlyAsDiagnostics(t *testing.T) { + env, m, runRoot := publishFixture(t) + polish := m.Stages["polish"] + polish.Outputs = nil + for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} { + path := filepath.Join(runRoot, "polish", "outputs", "transcripts", name) + writeStageTestFile(t, path, "diagnostic\n") + polish.Logs = append(polish.Logs, path) + } + if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil { + t.Fatalf("publish reports: %v", err) + } + fake := env.ObjectStore.(*storage.FakeBackend) + for _, name := range []string{"errors.yml", "recoveries.yml", "report.md"} { + relative := "polish/outputs/transcripts/" + name + if _, ok := fake.Objects[m.S3RunPrefix+relative]; !ok { + t.Fatalf("missing run-archive diagnostic %q", relative) + } + for key := range fake.Objects { + if strings.Contains(key, "published/"+name) { + t.Fatalf("report was published as canonical output: %q", key) + } + } + } +} + func publishFixture(t *testing.T) (*Env, *manifest.Manifest, string) { t.Helper()