diff --git a/internal/app/analyze_artifacts_commands_test.go b/internal/app/analyze_artifacts_commands_test.go index 7a8fc81..27c2393 100644 --- a/internal/app/analyze_artifacts_commands_test.go +++ b/internal/app/analyze_artifacts_commands_test.go @@ -127,7 +127,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { store := &manifest.LocalStore{} seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) - for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} { + for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} { seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) } if err := store.Save(context.Background(), manifestPath, seed); err != nil { @@ -143,7 +143,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - if !strings.Contains(out.String(), "executed=0 skipped=9") { + if !strings.Contains(out.String(), "executed=0 skipped=10") { t.Fatalf("output = %q, want all stages skipped", out.String()) } } diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index a8933ec..55f7e63 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -31,8 +31,8 @@ func TestExecuteValidCommands(t *testing.T) { args []string wantOut string }{ - {name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="}, - {name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\npublish: skip\nnotify: skip"}, + {name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=10 skipped=0; manifest="}, + {name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nrender: skip\nanalyze: skip\npublish: skip\nnotify: skip"}, {name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"}, {name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, } @@ -331,7 +331,7 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } - if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=9 skipped=0; manifest=") { + if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=10 skipped=0; manifest=") { t.Fatalf("stdout = %q, want successful run output", stdout.String()) } } diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index 5f313a6..91d2bd0 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -997,7 +997,7 @@ func TestExecutePublishLoadsRemoteLocks(t *testing.T) { restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") - for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} { + for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} { // The publish stage only checks the manifest statuses and source files. _ = stageName } @@ -1065,7 +1065,7 @@ func writeValidPublishRunConfigFiles(t *testing.T, workspaceRoot string) (string m := manifest.New("2026-05-03", nowUTC()) m.Campaign = "sample-campaign" m.RunID = "20260521T160000Z-test" - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} { m.MarkStageSucceeded(name, nowUTC(), nil) } path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index 52ed6ca..42fa214 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -27,12 +27,12 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) { if !strings.Contains(got, "narratio session plan: workdir prepared at") { t.Fatalf("first output = %q, want workdir prepared", got) } - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} { if !strings.Contains(got, name+": run") { t.Fatalf("first output = %q, missing stage %q", got, name) } } - if !strings.Contains(got, "totals: run=9 skip=0") { + if !strings.Contains(got, "totals: run=10 skip=0") { t.Fatalf("first output = %q, want totals", got) } @@ -84,8 +84,8 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) { if !strings.Contains(got, "trim: run") { t.Fatalf("output = %q, want trim run", got) } - if !strings.Contains(got, "totals: run=7 skip=2") { - t.Fatalf("output = %q, want totals run=7 skip=2", got) + if !strings.Contains(got, "totals: run=8 skip=2") { + t.Fatalf("output = %q, want totals run=8 skip=2", got) } } diff --git a/internal/app/planner_test.go b/internal/app/planner_test.go index b2ee8b5..a68b4da 100644 --- a/internal/app/planner_test.go +++ b/internal/app/planner_test.go @@ -4,7 +4,7 @@ import "testing" func TestBuildFullPlanOrder(t *testing.T) { got := BuildFullPlan() - want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} + want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} if len(got) != len(want) { t.Fatalf("len(plan) = %d, want %d", len(got), len(want)) } diff --git a/internal/app/post_publish_cleanup_test.go b/internal/app/post_publish_cleanup_test.go index a974929..9f54e7a 100644 --- a/internal/app/post_publish_cleanup_test.go +++ b/internal/app/post_publish_cleanup_test.go @@ -355,7 +355,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri if err != nil { t.Fatalf("Load() error = %v", err) } - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} { seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil) } seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) diff --git a/internal/app/run_control_test.go b/internal/app/run_control_test.go index 6953e01..2927e55 100644 --- a/internal/app/run_control_test.go +++ b/internal/app/run_control_test.go @@ -32,7 +32,7 @@ func TestDecideStageActions(t *testing.T) { func TestDownstreamStageNames(t *testing.T) { got := downstreamStageNames("polish") - want := []string{"normalize", "trim", "analyze", "publish", "notify"} + want := []string{"normalize", "trim", "render", "analyze", "publish", "notify"} if !reflect.DeepEqual(got, want) { t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want) } @@ -52,12 +52,13 @@ func TestInvalidateDownstreamSucceededStages(t *testing.T) { m.MarkStageSucceeded("polish", now, nil) m.MarkStageSucceeded("normalize", now, nil) m.MarkStageSucceeded("trim", now, nil) + m.MarkStageSucceeded("render", now, nil) m.MarkStageFailed("analyze", now, "analysis failed") m.MarkStageSucceeded("publish", now, nil) m.MarkStageSucceeded("notify", now, nil) got := invalidateDownstreamSucceededStages(m, "polish", now.Add(1*time.Second)) - want := []string{"normalize", "trim", "publish", "notify"} + want := []string{"normalize", "trim", "render", "publish", "notify"} if !reflect.DeepEqual(got, want) { t.Fatalf("invalidateDownstreamSucceededStages() = %#v, want %#v", got, want) } diff --git a/internal/app/run_stage_test.go b/internal/app/run_stage_test.go index a8228bd..d5b1c3e 100644 --- a/internal/app/run_stage_test.go +++ b/internal/app/run_stage_test.go @@ -36,8 +36,8 @@ func TestRunContinuesAfterCompletedStages(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - if !strings.Contains(out.String(), "executed=7 skipped=2") { - t.Fatalf("output = %q, want executed=7 skipped=2", out.String()) + if !strings.Contains(out.String(), "executed=8 skipped=2") { + t.Fatalf("output = %q, want executed=8 skipped=2", out.String()) } loaded, err := store.Load(context.Background(), manifestPath) @@ -56,7 +56,7 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) { store := &manifest.LocalStore{} m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} { m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) } if err := store.Save(context.Background(), manifestPath, m); err != nil { @@ -68,8 +68,8 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - if !strings.Contains(out.String(), "executed=0 skipped=9") { - t.Fatalf("output = %q, want executed=0 skipped=9", out.String()) + if !strings.Contains(out.String(), "executed=0 skipped=10") { + t.Fatalf("output = %q, want executed=0 skipped=10", out.String()) } } @@ -85,7 +85,7 @@ func TestRunForceRerunsSucceeded(t *testing.T) { store := &manifest.LocalStore{} m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} { m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) } if err := store.Save(context.Background(), manifestPath, m); err != nil { @@ -97,7 +97,7 @@ func TestRunForceRerunsSucceeded(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - if !strings.Contains(out.String(), "executed=9 skipped=0") { + if !strings.Contains(out.String(), "executed=10 skipped=0") { t.Fatalf("output = %q, want forced full rerun", out.String()) } } @@ -176,7 +176,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) store := &manifest.LocalStore{} seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} { seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) } if err := store.Save(context.Background(), manifestPath, seed); err != nil { @@ -196,7 +196,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) if err != nil { t.Fatalf("load manifest after force: %v", err) } - for _, name := range []string{"normalize", "trim", "analyze", "publish", "notify"} { + for _, name := range []string{"normalize", "trim", "render", "analyze", "publish", "notify"} { if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale { t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name]) } @@ -207,7 +207,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) if err != nil { t.Fatalf("Run() error = %v", err) } - if !strings.Contains(out.String(), "executed=5 skipped=4") { + if !strings.Contains(out.String(), "executed=6 skipped=4") { t.Fatalf("output = %q, want run to execute stale downstream stages", out.String()) } } @@ -266,3 +266,30 @@ func TestRunStageNormalizeExecutes(t *testing.T) { t.Fatalf("normalize stage = %#v, want succeeded", m.Stages["normalize"]) } } + +func TestRunStageRenderExecutes(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") + workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") + mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1}]}`) + mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[{"id":2}]}`) + + var out bytes.Buffer + err := RunStage(context.Background(), []string{"render", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) + if err != nil { + t.Fatalf("RunStage(render) error = %v", err) + } + if !strings.Contains(out.String(), "stage=render executed=1 skipped=0") { + t.Fatalf("output = %q, want stage=render executed", out.String()) + } + + store := &manifest.LocalStore{} + m, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("load manifest: %v", err) + } + if m.Stages["render"] == nil || m.Stages["render"].Status != manifest.StatusSucceeded { + t.Fatalf("render stage = %#v, want succeeded", m.Stages["render"]) + } +} diff --git a/internal/app/runner.go b/internal/app/runner.go index 6add9fe..bfe7bba 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -12,6 +12,7 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" "gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" + "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/logging" @@ -410,6 +411,8 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string) if stageName == "analyze" { sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind) kind = "scriptorium_artifact" + } else { + sourceID = sourceIDForOutputKind(kind) } out = append(out, manifest.ArtifactRecord{ Kind: kind, @@ -424,6 +427,22 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string) return out } +func sourceIDForOutputKind(kind string) string { + trimmed := strings.TrimSpace(kind) + if trimmed == "" { + return "" + } + if trimmed == "session_bounds" { + return artifacts.ArtifactBoundsSession + } + for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() { + if spec.OutputKind == trimmed { + return spec.SourceID + } + } + return "" +} + func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) { if m == nil || result == nil { return diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 3085b03..71fc7ce 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -332,8 +332,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { if err != nil { t.Fatalf("executeStages() error = %v", err) } - if len(summary.StageNames) != 9 || len(summary.Executed) != 9 || len(summary.Skipped) != 0 { - t.Fatalf("summary = %#v, want all 9 executed", summary) + if len(summary.StageNames) != 10 || len(summary.Executed) != 10 || len(summary.Skipped) != 0 { + t.Fatalf("summary = %#v, want all 10 executed", summary) } store := &manifest.LocalStore{} @@ -342,7 +342,7 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { t.Fatalf("Load manifest error = %v", err) } - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} { sr := m.Stages[name] if sr == nil { t.Fatalf("missing stage record %q", name) @@ -425,6 +425,15 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { } continue } + if name == "render" { + if sr.Metadata == nil || sr.Metadata["stage"] != "render" { + t.Fatalf("render metadata missing stage=render: %#v", sr.Metadata) + } + if len(sr.Outputs) == 0 { + t.Fatalf("render outputs missing") + } + continue + } if name == "publish" { if sr.Metadata == nil || sr.Metadata["stage"] != "publish" { t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata) @@ -522,7 +531,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin store := &manifest.LocalStore{} existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC)) - for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "publish", "notify"} { + for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "publish", "notify"} { existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil) } existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure") @@ -553,7 +562,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded { t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"]) } - for _, stageName := range []string{"normalize", "trim", "publish", "notify"} { + for _, stageName := range []string{"normalize", "trim", "render", "publish", "notify"} { if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale { t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName]) } @@ -977,7 +986,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) { seed.S3Bucket = "my-dnd-archive" seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/" seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/" - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} { seed.MarkStageSucceeded(name, time.Now().UTC(), nil) } if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil { diff --git a/internal/stage/placeholders.go b/internal/stage/placeholders.go index d2ac0be..9d39d5a 100644 --- a/internal/stage/placeholders.go +++ b/internal/stage/placeholders.go @@ -74,6 +74,7 @@ func All() []Stage { polishStage{}, normalizeStage{}, trimStage{}, + renderStage{}, analyzeStage{}, publishStage{}, placeholderStage{name: "notify"}, diff --git a/internal/stage/placeholders_test.go b/internal/stage/placeholders_test.go index db4938f..cc69a09 100644 --- a/internal/stage/placeholders_test.go +++ b/internal/stage/placeholders_test.go @@ -101,7 +101,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { } m := manifest.New("2026-05-03", time.Now().UTC()) - for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} { + for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} { m.MarkStageSucceeded(name, time.Now().UTC(), nil) } m.RunID = "20260516T000000Z-abcdef12" @@ -194,6 +194,15 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { } continue } + if s.Name() == "render" { + if result.Metadata["stage"] != "render" { + t.Fatalf("render metadata = %#v, want stage=render", result.Metadata) + } + if len(result.Outputs) != 2 { + t.Fatalf("render outputs = %#v, want 2 markdown outputs", result.Outputs) + } + continue + } if s.Name() == "publish" { if result.Metadata["stage"] != "publish" { t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata) diff --git a/internal/stage/render.go b/internal/stage/render.go new file mode 100644 index 0000000..d61262b --- /dev/null +++ b/internal/stage/render.go @@ -0,0 +1,288 @@ +package stage + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +type renderStage struct{} + +func (renderStage) Name() string { return "render" } + +func (renderStage) Declares() IODecl { + return IODecl{ + Inputs: []artifacts.Ref{ + {Kind: "transcript_final", Category: "transcripts", RelativePath: "transcripts/final.json"}, + {Kind: "transcript_final_trimmed", Category: "transcripts", RelativePath: "transcripts/final.trimmed.json"}, + }, + Outputs: []artifacts.Ref{ + {Kind: artifacts.TranscriptOutputKindFinalMarkdown, Category: "transcripts", RelativePath: artifacts.TranscriptPathFinalMarkdown}, + {Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown, Category: "transcripts", RelativePath: artifacts.TranscriptPathFinalTrimmedMarkdown}, + }, + } +} + +func (renderStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) { + if env == nil || env.Config == nil { + return nil, fmt.Errorf("render: stage environment config is required") + } + if env.ArtifactStore == nil { + return nil, fmt.Errorf("render: artifact store is required") + } + if env.Config.Pipeline == nil || env.Config.Session == nil { + return nil, fmt.Errorf("render: resolved config must include pipeline and session") + } + if env.Seriatim == nil { + return nil, fmt.Errorf("render: seriatim adapter is required") + } + + var sessionID string + if m != nil { + sessionID = strings.TrimSpace(m.SessionID) + } + if sessionID == "" { + sessionID = strings.TrimSpace(env.Config.Session.SessionID) + } + if sessionID == "" { + return nil, fmt.Errorf("render: session id is required") + } + + paths := sessionPathsForEnv(env, sessionID) + runLayout, err := resolveRunStageLayout(env, m, paths, sessionID, "render") + if err != nil { + return nil, fmt.Errorf("render: resolve run-stage layout: %w", err) + } + + renderCfg := renderConfigOrDefault(env.Config.Pipeline.Render) + enabled := renderCfg.Enabled == nil || *renderCfg.Enabled + format := strings.TrimSpace(renderCfg.Format) + if format == "" { + format = config.DefaultRenderFormat + } + title := resolveRenderTitle(renderCfg, env.Config.Session) + includeTimestamps := renderCfg.IncludeTimestamps == nil || *renderCfg.IncludeTimestamps + includeSegmentIDs := renderCfg.IncludeSegmentIDs + includeMetadata := renderCfg.IncludeMetadata + + meta := map[string]any{ + "stage": "render", + "render_enabled": enabled, + "format": format, + "title": title, + "include_timestamps": includeTimestamps, + "include_segment_ids": includeSegmentIDs, + "include_metadata": includeMetadata, + "binary": env.Config.Pipeline.Seriatim.Binary, + "timeout": env.Config.Pipeline.Seriatim.Timeout, + } + if !enabled { + meta["skipped"] = true + meta["reason"] = "pipeline.render.enabled is false" + return &StageResult{Metadata: meta}, nil + } + + finalInput, err := artifacts.ResolveSessionArtifact(paths, m, artifacts.ArtifactTranscriptFinal) + if err != nil { + return nil, wrapRenderInputResolveError(err, sessionID, artifacts.ArtifactTranscriptFinal, "normalize") + } + finalTrimmedInput, err := artifacts.ResolveSessionArtifact(paths, m, artifacts.ArtifactTranscriptFinalTrimmed) + if err != nil { + return nil, wrapRenderInputResolveError(err, sessionID, artifacts.ArtifactTranscriptFinalTrimmed, "trim") + } + + canonicalFinalMarkdownPath, err := resolveScriptoriumOutputPath(paths, artifacts.TranscriptPathFinalMarkdown) + if err != nil { + return nil, fmt.Errorf("render: resolve canonical final markdown output path: %w", err) + } + canonicalFinalTrimmedMarkdownPath, err := resolveScriptoriumOutputPath(paths, artifacts.TranscriptPathFinalTrimmedMarkdown) + if err != nil { + return nil, fmt.Errorf("render: resolve canonical final trimmed markdown output path: %w", err) + } + + runFinalMarkdownPath, err := runLocalPathForCanonical(runLayout, paths, canonicalFinalMarkdownPath) + if err != nil { + return nil, fmt.Errorf("render: resolve run-local final markdown output path: %w", err) + } + runFinalTrimmedMarkdownPath, err := runLocalPathForCanonical(runLayout, paths, canonicalFinalTrimmedMarkdownPath) + if err != nil { + return nil, fmt.Errorf("render: resolve run-local final trimmed markdown output path: %w", err) + } + + finalStdoutLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final.stdout.log") + finalStderrLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final.stderr.log") + finalGeneratedConfigPath := filepath.Join(paths.ConfigDir, "seriatim.render.final.generated.yml") + finalTrimmedStdoutLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final_trimmed.stdout.log") + finalTrimmedStderrLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final_trimmed.stderr.log") + finalTrimmedGeneratedConfigPath := filepath.Join(paths.ConfigDir, "seriatim.render.final_trimmed.generated.yml") + if runLayout.Enabled { + finalStdoutLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final.stdout.log") + finalStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final.stderr.log") + finalGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.render.final.generated.yml") + finalTrimmedStdoutLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final_trimmed.stdout.log") + finalTrimmedStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final_trimmed.stderr.log") + finalTrimmedGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.render.final_trimmed.generated.yml") + } + + timeout, err := resolveTrimSeriatimTimeout(env.Config.Pipeline.Seriatim.Timeout) + if err != nil { + return nil, fmt.Errorf("render: resolve seriatim timeout: %w", err) + } + + finalReq := seriatim.RenderRequest{ + Binary: env.Config.Pipeline.Seriatim.Binary, + InputTranscriptPath: finalInput.Path, + OutputRenderedPath: runFinalMarkdownPath, + Format: format, + Title: title, + IncludeTimestamps: includeTimestamps, + IncludeSegmentIDs: includeSegmentIDs, + IncludeMetadata: includeMetadata, + StdoutLogPath: finalStdoutLogPath, + StderrLogPath: finalStderrLogPath, + GeneratedConfigPath: finalGeneratedConfigPath, + Timeout: timeout, + } + finalRes, err := env.Seriatim.Render(ctx, finalReq) + if err != nil { + return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinal, err) + } + finalRenderedPath := coalesceString(finalRes.OutputRenderedPath, finalReq.OutputRenderedPath) + if err := requireNonEmptyFile(finalRenderedPath, "final transcript markdown output"); err != nil { + return nil, fmt.Errorf("render: %w", err) + } + + finalTrimmedReq := seriatim.RenderRequest{ + Binary: env.Config.Pipeline.Seriatim.Binary, + InputTranscriptPath: finalTrimmedInput.Path, + OutputRenderedPath: runFinalTrimmedMarkdownPath, + Format: format, + Title: title, + IncludeTimestamps: includeTimestamps, + IncludeSegmentIDs: includeSegmentIDs, + IncludeMetadata: includeMetadata, + StdoutLogPath: finalTrimmedStdoutLogPath, + StderrLogPath: finalTrimmedStderrLogPath, + GeneratedConfigPath: finalTrimmedGeneratedConfigPath, + Timeout: timeout, + } + finalTrimmedRes, err := env.Seriatim.Render(ctx, finalTrimmedReq) + if err != nil { + return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinalTrimmed, err) + } + finalTrimmedRenderedPath := coalesceString(finalTrimmedRes.OutputRenderedPath, finalTrimmedReq.OutputRenderedPath) + if err := requireNonEmptyFile(finalTrimmedRenderedPath, "final trimmed transcript markdown output"); err != nil { + return nil, fmt.Errorf("render: %w", err) + } + + materializedFinalMarkdown, err := materializeRunLocalOutput(env.ArtifactStore, finalRenderedPath, canonicalFinalMarkdownPath, artifacts.Ref{ + Kind: artifacts.TranscriptOutputKindFinalMarkdown, + Category: "transcripts", + SessionID: sessionID, + }) + if err != nil { + return nil, fmt.Errorf("render: materialize canonical final markdown output: %w", err) + } + materializedFinalTrimmedMarkdown, err := materializeRunLocalOutput(env.ArtifactStore, finalTrimmedRenderedPath, canonicalFinalTrimmedMarkdownPath, artifacts.Ref{ + Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown, + Category: "transcripts", + SessionID: sessionID, + }) + if err != nil { + return nil, fmt.Errorf("render: materialize canonical final trimmed markdown output: %w", err) + } + + meta["final_input_path"] = finalInput.Path + meta["final_input_provenance"] = finalInput.Provenance + meta["final_input_run_id"] = finalInput.ProducerRunID + meta["final_trimmed_input_path"] = finalTrimmedInput.Path + meta["final_trimmed_input_provenance"] = finalTrimmedInput.Provenance + meta["final_trimmed_input_run_id"] = finalTrimmedInput.ProducerRunID + meta["run_final_markdown_path"] = finalRenderedPath + meta["final_markdown_path"] = canonicalFinalMarkdownPath + meta["run_final_trimmed_markdown_path"] = finalTrimmedRenderedPath + meta["final_trimmed_markdown_path"] = canonicalFinalTrimmedMarkdownPath + populateRenderAdapterMetadata(meta, "final_adapter_", finalRes) + populateRenderAdapterMetadata(meta, "final_trimmed_adapter_", finalTrimmedRes) + + return &StageResult{ + Outputs: []artifacts.Ref{ + materializedFinalMarkdown, + materializedFinalTrimmedMarkdown, + }, + Logs: dedupeAndSortPaths([]string{ + finalStdoutLogPath, + finalStderrLogPath, + finalTrimmedStdoutLogPath, + finalTrimmedStderrLogPath, + }), + GeneratedConfigs: dedupeAndSortPaths([]string{ + finalGeneratedConfigPath, + finalTrimmedGeneratedConfigPath, + }), + Metadata: meta, + }, nil +} + +func renderConfigOrDefault(cfg *config.RenderConfig) *config.RenderConfig { + if cfg != nil { + return cfg + } + enabled := true + includeTimestamps := true + return &config.RenderConfig{ + Enabled: &enabled, + Format: config.DefaultRenderFormat, + IncludeTimestamps: &includeTimestamps, + IncludeSegmentIDs: config.DefaultRenderSegmentIDs, + IncludeMetadata: config.DefaultRenderMetadata, + } +} + +func resolveRenderTitle(renderCfg *config.RenderConfig, sessionCfg *config.SessionConfig) string { + if renderCfg != nil && strings.TrimSpace(renderCfg.Title) != "" { + return strings.TrimSpace(renderCfg.Title) + } + if sessionCfg != nil && strings.TrimSpace(sessionCfg.Title) != "" { + return strings.TrimSpace(sessionCfg.Title) + } + return "" +} + +func wrapRenderInputResolveError(err error, sessionID, sourceID, guidanceStage string) error { + var notFound *artifacts.SessionArtifactNotFoundError + if errors.As(err, ¬Found) { + return fmt.Errorf( + "render: required input %q is unavailable; run narratio run-stage --force %s %s", + sourceID, + guidanceStage, + sessionID, + ) + } + return fmt.Errorf("render: resolve %q input: %w", sourceID, err) +} + +func populateRenderAdapterMetadata(meta map[string]any, prefix string, result seriatim.RenderResult) { + if meta == nil { + return + } + meta[prefix+"duration_ms"] = result.Duration.Milliseconds() + meta[prefix+"exit_code"] = result.ExitCode + meta[prefix+"invoked_binary"] = result.InvokedBinary + meta[prefix+"format"] = result.Format + meta[prefix+"title"] = result.Title + meta[prefix+"output_path"] = result.OutputRenderedPath + meta[prefix+"generated_config"] = result.GeneratedConfigPath + meta[prefix+"stdout_log_path"] = result.StdoutLogPath + meta[prefix+"stderr_log_path"] = result.StderrLogPath + if result.Metadata != nil { + meta[prefix+"metadata"] = result.Metadata + } +} diff --git a/internal/stage/render_test.go b/internal/stage/render_test.go new file mode 100644 index 0000000..6637796 --- /dev/null +++ b/internal/stage/render_test.go @@ -0,0 +1,207 @@ +package stage + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestRenderStageDisabledSucceedsWithoutOutputs(t *testing.T) { + env, m, ser := setupRenderEnv(t) + enabled := false + env.Config.Pipeline.Render.Enabled = &enabled + + result, err := (renderStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("render.Run() error = %v", err) + } + if len(result.Outputs) != 0 { + t.Fatalf("outputs = %#v, want empty when disabled", result.Outputs) + } + if len(ser.RenderRequests) != 0 { + t.Fatalf("render requests = %d, want 0 when disabled", len(ser.RenderRequests)) + } + if result.Metadata["render_enabled"] != false { + t.Fatalf("metadata render_enabled = %#v, want false", result.Metadata["render_enabled"]) + } +} + +func TestRenderStageResolvesManifestInputsFirst(t *testing.T) { + env, m, ser := setupRenderEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + + manifestFinal := filepath.Join(paths.ArtifactsDir, "final.from-manifest.json") + manifestTrimmed := filepath.Join(paths.ArtifactsDir, "final.trimmed.from-manifest.json") + writeFile(t, manifestFinal, `{"segments":[{"id":101}]}`) + writeFile(t, manifestTrimmed, `{"segments":[{"id":202}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`) + + m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final", LocalPath: manifestFinal}}) + m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final_trimmed", LocalPath: manifestTrimmed}}) + + result, err := (renderStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("render.Run() error = %v", err) + } + if len(ser.RenderRequests) != 2 { + t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests)) + } + if ser.RenderRequests[0].InputTranscriptPath != manifestFinal { + t.Fatalf("first input path = %q, want %q", ser.RenderRequests[0].InputTranscriptPath, manifestFinal) + } + if ser.RenderRequests[1].InputTranscriptPath != manifestTrimmed { + t.Fatalf("second input path = %q, want %q", ser.RenderRequests[1].InputTranscriptPath, manifestTrimmed) + } + if result.Metadata["final_input_provenance"] != "manifest.normalize.outputs" { + t.Fatalf("final_input_provenance = %#v, want manifest.normalize.outputs", result.Metadata["final_input_provenance"]) + } + if result.Metadata["final_trimmed_input_provenance"] != "manifest.trim.outputs" { + t.Fatalf("final_trimmed_input_provenance = %#v, want manifest.trim.outputs", result.Metadata["final_trimmed_input_provenance"]) + } + if len(result.Outputs) != 2 { + t.Fatalf("outputs = %#v, want 2", result.Outputs) + } + if result.Outputs[0].Kind != artifacts.TranscriptOutputKindFinalMarkdown { + t.Fatalf("outputs[0].kind = %q, want %q", result.Outputs[0].Kind, artifacts.TranscriptOutputKindFinalMarkdown) + } + if result.Outputs[1].Kind != artifacts.TranscriptOutputKindFinalTrimmedMarkdown { + t.Fatalf("outputs[1].kind = %q, want %q", result.Outputs[1].Kind, artifacts.TranscriptOutputKindFinalTrimmedMarkdown) + } +} + +func TestRenderStageTitleFallsBackToSessionTitle(t *testing.T) { + env, m, ser := setupRenderEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`) + env.Config.Pipeline.Render.Title = "" + env.Config.Session.Title = "Session Title" + + _, err := (renderStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("render.Run() error = %v", err) + } + if len(ser.RenderRequests) != 2 { + t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests)) + } + if ser.RenderRequests[0].Title != "Session Title" || ser.RenderRequests[1].Title != "Session Title" { + t.Fatalf("render titles = %q/%q, want Session Title", ser.RenderRequests[0].Title, ser.RenderRequests[1].Title) + } +} + +func TestRenderStageFailsWhenFinalInputMissing(t *testing.T) { + env, m, _ := setupRenderEnv(t) + _, err := (renderStage{}).Run(context.Background(), env, m) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "run narratio run-stage --force normalize") { + t.Fatalf("error = %q, want normalize guidance", err.Error()) + } +} + +func TestRenderStageFailsWhenFinalTrimmedInputMissing(t *testing.T) { + env, m, _ := setupRenderEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) + + _, err := (renderStage{}).Run(context.Background(), env, m) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "run narratio run-stage --force trim") { + t.Fatalf("error = %q, want trim guidance", err.Error()) + } +} + +func TestRenderStageUsesRunLocalOutputsAndMaterializesCanonical(t *testing.T) { + env, m, ser := setupRenderEnv(t) + env.Config.Session.Campaign = "sample-campaign" + m.Campaign = "sample-campaign" + m.RunID = "20260518T010203Z-abcdef12" + paths := sessionPathsForEnv(env, m.SessionID) + writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`) + writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`) + + result, err := (renderStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("render.Run() error = %v", err) + } + if len(ser.RenderRequests) != 2 { + t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests)) + } + if !strings.Contains(ser.RenderRequests[0].OutputRenderedPath, filepath.Join("runs", m.RunID, "render", "outputs")) { + t.Fatalf("run output path = %q, want run-local output path", ser.RenderRequests[0].OutputRenderedPath) + } + for _, out := range result.Outputs { + if strings.Contains(out.AbsolutePath, string(filepath.Separator)+"runs"+string(filepath.Separator)) { + t.Fatalf("materialized output path = %q, want canonical session path", out.AbsolutePath) + } + } + if len(result.GeneratedConfigs) != 2 { + t.Fatalf("generated configs = %#v, want two generated config paths", result.GeneratedConfigs) + } + if len(result.Logs) != 4 { + t.Fatalf("logs = %#v, want four log paths", result.Logs) + } +} + +func setupRenderEnv(t *testing.T) (*Env, *manifest.Manifest, *seriatim.FakeRunner) { + t.Helper() + workspace := t.TempDir() + cfgDir := t.TempDir() + sessionPath := filepath.Join(cfgDir, "session.yml") + pipelinePath := filepath.Join(cfgDir, "pipeline.yml") + writeFile(t, sessionPath, "session_id: 2026-05-03\n") + writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") + + enabled := true + includeTimestamps := true + seriatimReport := false + cfg := &config.Config{ + PipelinePath: pipelinePath, + SessionPath: sessionPath, + Pipeline: &config.PipelineConfig{ + Workspace: config.WorkspaceConfig{Root: workspace}, + Seriatim: config.SeriatimConfig{ + Binary: "seriatim", + Timeout: "10m", + OutputSchema: "seriatim-intermediate", + CoalesceGap: func() *float64 { v := 3.0; return &v }(), + Report: &seriatimReport, + }, + Render: &config.RenderConfig{ + Enabled: &enabled, + Format: "markdown", + Title: "Pipeline Title", + IncludeTimestamps: &includeTimestamps, + IncludeSegmentIDs: false, + IncludeMetadata: false, + }, + }, + Session: &config.SessionConfig{ + SessionID: "2026-05-03", + Campaign: "sample-campaign", + }, + } + + store := artifacts.NewLocalStore(workspace) + if _, err := store.EnsureLayoutFor("sample-campaign", "2026-05-03"); err != nil { + t.Fatalf("EnsureLayout() error = %v", err) + } + + ser := &seriatim.FakeRunner{} + return &Env{ + Config: cfg, + ArtifactStore: store, + Seriatim: ser, + }, manifest.New("2026-05-03", time.Now().UTC()), ser +}