From 86caf4b22210ccfd7989709e5f361d6942e81ff1 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 19 May 2026 19:07:44 -0500 Subject: [PATCH] Update analyze-stage metadata and manifest output --- internal/app/runner.go | 13 ++- internal/app/runner_test.go | 77 ++++++++++++++++++ internal/manifest/manifest.go | 1 + internal/stage/analyze.go | 2 + internal/stage/analyze_test.go | 144 +++++++++++++++++++++++++++++++++ 5 files changed, 234 insertions(+), 3 deletions(-) diff --git a/internal/app/runner.go b/internal/app/runner.go index 1b3ca32..d12d7d5 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -204,7 +204,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage return nil, fmt.Errorf("stage %q failed: %w", s.Name(), err) } - outputs := mapResultOutputs(result, runID) + outputs := mapResultOutputs(s.Name(), result, runID) succeededAt := nowUTC() m.MarkStageSucceeded(s.Name(), succeededAt, outputs) applyStageResultToManifest(m, s.Name(), result) @@ -388,7 +388,7 @@ func fileExists(path string) (bool, error) { return false, err } -func mapResultOutputs(result *stage.StageResult, runID string) []manifest.ArtifactRecord { +func mapResultOutputs(stageName string, result *stage.StageResult, runID string) []manifest.ArtifactRecord { if result == nil || len(result.Outputs) == 0 { return nil } @@ -400,8 +400,15 @@ func mapResultOutputs(result *stage.StageResult, runID string) []manifest.Artifa if localPath == "" { localPath = ref.RelativePath } + kind := ref.Kind + sourceID := "" + if stageName == "analyze" { + sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind) + kind = "scriptorium_artifact" + } out = append(out, manifest.ArtifactRecord{ - Kind: ref.Kind, + Kind: kind, + SourceID: sourceID, LocalPath: localPath, ProducerRunID: runID, RemoteKey: ref.RemoteKey, diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 6bba179..33d8074 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -58,6 +58,18 @@ func (s captureSelectedArtifactsStage) Run(_ context.Context, env *stage.Env, _ return &stage.StageResult{Metadata: map[string]any{"captured": true}}, nil } +type analyzeOutputStage struct { + output artifacts.Ref +} + +func (s analyzeOutputStage) Name() string { return "analyze" } +func (s analyzeOutputStage) Declares() stage.IODecl { return stage.IODecl{} } +func (s analyzeOutputStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) { + return &stage.StageResult{ + Outputs: []artifacts.Ref{s.output}, + }, nil +} + func TestExecuteStagesPropagatesSelectedArtifactsToEnv(t *testing.T) { cfg := testConfig(t) @@ -78,6 +90,71 @@ func TestExecuteStagesPropagatesSelectedArtifactsToEnv(t *testing.T) { } } +func TestExecuteStagesAnalyzeOutputsPersistAsScriptoriumArtifacts(t *testing.T) { + cfg := testConfig(t) + storeForPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) + sessionPaths := storeForPaths.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) + outputPath := filepath.Join(sessionPaths.ArtifactsDir, "session_recap.md") + + stageToRun := analyzeOutputStage{ + output: artifacts.Ref{ + Kind: "session_recap", + Category: "artifacts", + RelativePath: "artifacts/session_recap.md", + AbsolutePath: outputPath, + }, + } + summary, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{}) + if err != nil { + t.Fatalf("executeStages() error = %v", err) + } + + store := &manifest.LocalStore{} + sessionManifest, err := store.Load(context.Background(), summary.ManifestPath) + if err != nil { + t.Fatalf("load session manifest: %v", err) + } + sessionStage := sessionManifest.Stages["analyze"] + if sessionStage == nil { + t.Fatal("session manifest analyze stage missing") + } + if len(sessionStage.Outputs) != 1 { + t.Fatalf("session analyze outputs len = %d, want 1", len(sessionStage.Outputs)) + } + sessionOutput := sessionStage.Outputs[0] + if sessionOutput.Kind != "scriptorium_artifact" { + t.Fatalf("session output kind = %q, want scriptorium_artifact", sessionOutput.Kind) + } + if sessionOutput.SourceID != "narratio.artifact.session_recap" { + t.Fatalf("session output source_id = %q, want narratio.artifact.session_recap", sessionOutput.SourceID) + } + if sessionOutput.LocalPath != outputPath { + t.Fatalf("session output local_path = %q, want %q", sessionOutput.LocalPath, outputPath) + } + + runManifest, err := store.LoadRun(context.Background(), summary.RunManifestPath) + if err != nil { + t.Fatalf("load run manifest: %v", err) + } + runStage := runManifest.Stages["analyze"] + if runStage == nil { + t.Fatal("run manifest analyze stage missing") + } + if len(runStage.Outputs) != 1 { + t.Fatalf("run analyze outputs len = %d, want 1", len(runStage.Outputs)) + } + runOutput := runStage.Outputs[0] + if runOutput.Kind != "scriptorium_artifact" { + t.Fatalf("run output kind = %q, want scriptorium_artifact", runOutput.Kind) + } + if runOutput.SourceID != "narratio.artifact.session_recap" { + t.Fatalf("run output source_id = %q, want narratio.artifact.session_recap", runOutput.SourceID) + } + if runOutput.LocalPath != outputPath { + t.Fatalf("run output local_path = %q, want %q", runOutput.LocalPath, outputPath) + } +} + func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { cfg := testConfig(t) diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index fae84f9..15326e8 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -28,6 +28,7 @@ type InputRecord struct { // ArtifactRecord captures one produced artifact and optional remote metadata. type ArtifactRecord struct { Kind string `json:"kind"` + SourceID string `json:"source_id,omitempty"` LocalPath string `json:"local_path"` // ProducerRunID identifies the run that produced this durable artifact. ProducerRunID string `json:"producer_run_id,omitempty"` diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 9fbdacd..19e0fac 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -374,6 +374,7 @@ func executeAnalyzeArtifact( generatedConfigs := []string{} meta := map[string]any{ "stage": "analyze", + "name": artifactName, "artifact_name": artifactName, "source_id": artifacts.ConfiguredArtifactSourceID(artifactName), "output_kind": "scriptorium_artifact", @@ -507,6 +508,7 @@ func executeAnalyzeArtifact( logPaths = append(logPaths, stdoutLogPath, stderrLogPath) generatedConfigs = append(generatedConfigs, generatedConfigPath) meta["run_output_path"] = finalOutputPath + meta["path"] = canonicalOutputPath meta["output_path"] = canonicalOutputPath meta["generated_config_path"] = generatedConfigPath meta["stdout_log_path"] = stdoutLogPath diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index 36f4547..fea4ea6 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -444,6 +444,75 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes } } +func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) { + env, m, _ := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) + playerHandoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md") + writeAnalyzeFile(t, playerHandoutPath, "handout\n") + + sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{ + Source: "narratio.artifact.player_handout", + Required: true, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap + env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ + Enabled: false, + OutputPath: "artifacts/player_handout.md", + } + + result, err := (analyzeStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + + generated := mustArtifactEntryList(t, result.Metadata, "generated_artifacts") + if len(generated) != 1 { + t.Fatalf("generated_artifacts len = %d, want 1 (%#v)", len(generated), generated) + } + g0 := generated[0] + if g0["name"] != "session_recap" { + t.Fatalf("generated[0].name = %#v, want session_recap", g0["name"]) + } + if g0["source_id"] != "narratio.artifact.session_recap" { + t.Fatalf("generated[0].source_id = %#v, want narratio.artifact.session_recap", g0["source_id"]) + } + if g0["output_kind"] != "scriptorium_artifact" { + t.Fatalf("generated[0].output_kind = %#v, want scriptorium_artifact", g0["output_kind"]) + } + if g0["path"] != filepath.Join(paths.ArtifactsDir, "session_recap.md") { + t.Fatalf("generated[0].path = %#v, want session recap path", g0["path"]) + } + if g0["prompt_id"] != "dnd.session_recap" { + t.Fatalf("generated[0].prompt_id = %#v, want dnd.session_recap", g0["prompt_id"]) + } + if g0["profile_id"] != "local-quality" { + t.Fatalf("generated[0].profile_id = %#v, want local-quality", g0["profile_id"]) + } + if g0["provenance"] != artifacts.ArtifactProvenanceGeneratedCurrentAnalyzeRun { + t.Fatalf("generated[0].provenance = %#v, want %q", g0["provenance"], artifacts.ArtifactProvenanceGeneratedCurrentAnalyzeRun) + } + + reused := mustArtifactEntryList(t, result.Metadata, "reused_artifacts") + if len(reused) != 1 { + t.Fatalf("reused_artifacts len = %d, want 1 (%#v)", len(reused), reused) + } + r0 := reused[0] + if r0["name"] != "player_handout" { + t.Fatalf("reused[0].name = %#v, want player_handout", r0["name"]) + } + if r0["source_id"] != "narratio.artifact.player_handout" { + t.Fatalf("reused[0].source_id = %#v, want narratio.artifact.player_handout", r0["source_id"]) + } + if r0["path"] != playerHandoutPath { + t.Fatalf("reused[0].path = %#v, want %q", r0["path"], playerHandoutPath) + } + if r0["provenance"] != artifacts.ArtifactProvenanceDisabledFromDisk { + t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceDisabledFromDisk) + } +} + func TestAnalyzeRunsMultipleIndependentArtifactsInDeterministicOrder(t *testing.T) { env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) @@ -560,6 +629,41 @@ func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) { } } +func TestAnalyzeMetadataIncludesMultipleGeneratedArtifacts(t *testing.T) { + env, m, _ := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) + + env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ + Enabled: true, + PromptID: "dnd.player_handout", + ProfileID: "local-quality", + OutputPath: "artifacts/player_handout.md", + Inputs: map[string]config.ScriptoriumInputConfig{ + "transcript": { + Source: "narratio.transcript.trimmed", + Required: true, + }, + }, + } + + result, err := (analyzeStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + generated := mustArtifactEntryList(t, result.Metadata, "generated_artifacts") + if len(generated) != 2 { + t.Fatalf("generated_artifacts len = %d, want 2 (%#v)", len(generated), generated) + } + for i, entry := range generated { + for _, field := range []string{"name", "source_id", "output_kind", "path", "prompt_id", "profile_id", "provenance"} { + if _, ok := entry[field]; !ok { + t.Fatalf("generated[%d] missing field %q: %#v", i, field, entry) + } + } + } +} + func TestAnalyzeFailsWhenRequiredConfiguredArtifactMissing(t *testing.T) { env, m, _ := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) @@ -914,6 +1018,22 @@ func TestAnalyzeSkipsWhenNoEnabledScriptoriumArtifactsConfigured(t *testing.T) { } } +func TestAnalyzeSkipsWhenArtifactMapEmpty(t *testing.T) { + env, m, _ := setupAnalyzeEnv(t) + env.Config.Pipeline.Scriptorium.Artifacts = map[string]config.ScriptoriumArtifactConfig{} + + result, err := (analyzeStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if result.Metadata["skipped"] != true { + t.Fatalf("metadata = %#v, want skipped=true", result.Metadata) + } + if result.Metadata["reason"] != "no scriptorium artifacts configured" { + t.Fatalf("reason = %#v, want no scriptorium artifacts configured", result.Metadata["reason"]) + } +} + func setupAnalyzeEnv(t *testing.T) (*Env, *manifest.Manifest, *scriptorium.FakeRunner) { t.Helper() workspace := t.TempDir() @@ -1001,3 +1121,27 @@ func writeAnalyzeFileNoTest(path, contents string) { _ = os.MkdirAll(filepath.Dir(path), 0o755) _ = os.WriteFile(path, []byte(contents), 0o644) } + +func mustArtifactEntryList(t *testing.T, metadata map[string]any, key string) []map[string]any { + t.Helper() + raw, ok := metadata[key] + if !ok { + t.Fatalf("metadata missing key %q: %#v", key, metadata) + } + if typed, ok := raw.([]map[string]any); ok { + return typed + } + asList, ok := raw.([]any) + if !ok { + t.Fatalf("metadata[%q] = %#v, want []map[string]any", key, raw) + } + out := make([]map[string]any, 0, len(asList)) + for _, item := range asList { + m, ok := item.(map[string]any) + if !ok { + t.Fatalf("metadata[%q] entry = %#v, want map[string]any", key, item) + } + out = append(out, m) + } + return out +}