2 Commits

5 changed files with 657 additions and 78 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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"`

View File

@@ -28,12 +28,23 @@ func (analyzeStage) Declares() IODecl {
{Kind: "transcript_normalized", Category: "transcripts", RelativePath: "transcripts/normalized.json"},
{Kind: "transcript_trimmed", Category: "transcripts", RelativePath: "transcripts/trimmed.json"},
},
Outputs: []artifacts.Ref{
{Kind: "session_recap", Category: "artifacts", RelativePath: "artifacts/session_recap.md"},
},
Outputs: nil,
}
}
type analyzeArtifactExecutionPlan struct {
Name string
Cfg config.ScriptoriumArtifactConfig
}
type analyzeArtifactExecutionResult struct {
Output artifacts.Ref
Logs []string
GeneratedConfigs []string
Metadata map[string]any
ReusedArtifacts []map[string]any
}
func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
if env == nil || env.Config == nil {
return nil, fmt.Errorf("analyze: stage environment config is required")
@@ -65,27 +76,11 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("analyze: resolve run-stage layout: %w", err)
}
if env.Config.Pipeline.Scriptorium == nil {
return &StageResult{
Metadata: map[string]any{
"stage": "analyze",
"skipped": true,
"reason": "pipeline.scriptorium is not configured",
},
}, nil
}
artifactName, artifactCfg, skipReason, err := selectAnalyzeArtifact(env.Config.Pipeline.Scriptorium)
if err != nil {
return nil, fmt.Errorf("analyze: %w", err)
}
if skipReason != "" {
return &StageResult{
Metadata: map[string]any{
"stage": "analyze",
"skipped": true,
"reason": skipReason,
},
}, nil
return &StageResult{Metadata: map[string]any{
"stage": "analyze",
"skipped": true,
"reason": "pipeline.scriptorium is not configured",
}}, nil
}
runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog(paths, env.Config.Pipeline.Scriptorium, env.SelectedAnalyzeArtifacts)
@@ -93,41 +88,274 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err)
}
plans, skipReason, err := buildAnalyzeExecutionPlans(env.Config.Pipeline.Scriptorium, runtimeCatalog)
if err != nil {
return nil, fmt.Errorf("analyze: %w", err)
}
if skipReason != "" {
return &StageResult{Metadata: map[string]any{
"stage": "analyze",
"skipped": true,
"reason": skipReason,
}}, nil
}
transcriptRefs := discoverAnalyzeTranscriptRefs(m, paths)
sessionDir := filepath.Dir(strings.TrimSpace(env.Config.SessionPath))
outputs := make([]artifacts.Ref, 0, len(plans))
logs := []string{}
generatedConfigs := []string{}
artifactMetadata := make([]map[string]any, 0, len(plans))
reusedArtifacts := []map[string]any{}
reusedSeen := map[string]struct{}{}
for _, plan := range plans {
artifactResult, err := executeAnalyzeArtifact(
ctx,
env,
m,
paths,
runLayout,
sessionID,
sessionDir,
plan,
transcriptRefs,
runtimeCatalog,
)
if err != nil {
return nil, err
}
outputs = append(outputs, artifactResult.Output)
logs = append(logs, artifactResult.Logs...)
generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...)
artifactMetadata = append(artifactMetadata, artifactResult.Metadata)
for _, reused := range artifactResult.ReusedArtifacts {
sourceID, _ := reused["source_id"].(string)
path, _ := reused["path"].(string)
key := sourceID + "|" + path
if _, exists := reusedSeen[key]; exists {
continue
}
reusedSeen[key] = struct{}{}
reusedArtifacts = append(reusedArtifacts, reused)
}
sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name)
if !ok {
return nil, fmt.Errorf("analyze: source id not found for artifact %q", plan.Name)
}
if err := runtimeCatalog.MarkAvailableGenerated(sourceID, artifactResult.Output.AbsolutePath); err != nil {
return nil, fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err)
}
}
metadata := map[string]any{
"stage": "analyze",
"selected_artifacts": extractPlanNames(plans),
"generated_artifacts": artifactMetadata,
"reused_artifacts": reusedArtifacts,
"artifact_count": len(artifactMetadata),
"reused_artifact_count": len(reusedArtifacts),
}
if len(artifactMetadata) == 1 {
for k, v := range artifactMetadata[0] {
metadata[k] = v
}
}
return &StageResult{
Outputs: outputs,
Logs: dedupeAndSortPaths(logs),
GeneratedConfigs: dedupeAndSortPaths(generatedConfigs),
Metadata: metadata,
}, nil
}
func buildAnalyzeExecutionPlans(
scriptoriumCfg *config.ScriptoriumConfig,
catalog *artifacts.ArtifactCatalog,
) ([]analyzeArtifactExecutionPlan, string, error) {
if scriptoriumCfg == nil {
return nil, "pipeline.scriptorium is not configured", nil
}
if len(scriptoriumCfg.Artifacts) == 0 {
return nil, "no scriptorium artifacts configured", nil
}
entries := catalog.ListConfigured()
selected := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.Executable {
selected = append(selected, entry.ConfiguredKey)
}
}
if len(selected) == 0 {
return nil, "no selected scriptorium artifacts to execute", nil
}
ordered, err := orderSelectedScriptoriumArtifacts(scriptoriumCfg.Artifacts, selected, catalog)
if err != nil {
return nil, "", err
}
plans := make([]analyzeArtifactExecutionPlan, 0, len(ordered))
for _, name := range ordered {
artifactCfg, ok := scriptoriumCfg.Artifacts[name]
if !ok {
return nil, "", fmt.Errorf("selected artifact %q is not configured", name)
}
plans = append(plans, analyzeArtifactExecutionPlan{Name: name, Cfg: artifactCfg})
}
return plans, "", nil
}
func orderSelectedScriptoriumArtifacts(
artifactsCfg map[string]config.ScriptoriumArtifactConfig,
selected []string,
catalog *artifacts.ArtifactCatalog,
) ([]string, error) {
selectedSet := map[string]struct{}{}
for _, key := range selected {
trimmed := strings.TrimSpace(key)
if trimmed == "" {
return nil, fmt.Errorf("selected artifact key must be non-empty")
}
selectedSet[trimmed] = struct{}{}
}
for selectedKey := range selectedSet {
cfg, ok := artifactsCfg[selectedKey]
if !ok {
return nil, fmt.Errorf("selected artifact %q is not configured", selectedKey)
}
for _, dep := range cfg.DependsOn {
trimmedDep := strings.TrimSpace(dep)
if trimmedDep == "" {
continue
}
if _, ok := selectedSet[trimmedDep]; ok {
continue
}
sourceID, ok := catalog.SourceIDForConfiguredKey(trimmedDep)
if !ok {
return nil, fmt.Errorf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep)
}
entry, ok := catalog.Lookup(sourceID)
if !ok || !entry.Available {
return nil, fmt.Errorf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID)
}
}
}
indegree := map[string]int{}
edges := map[string][]string{}
for key := range selectedSet {
indegree[key] = 0
}
for key := range selectedSet {
cfg := artifactsCfg[key]
for _, dep := range cfg.DependsOn {
trimmedDep := strings.TrimSpace(dep)
if _, ok := selectedSet[trimmedDep]; !ok {
continue
}
edges[trimmedDep] = append(edges[trimmedDep], key)
indegree[key]++
}
}
for key := range edges {
sort.Strings(edges[key])
}
ready := make([]string, 0, len(indegree))
for key, degree := range indegree {
if degree == 0 {
ready = append(ready, key)
}
}
sort.Strings(ready)
order := make([]string, 0, len(selectedSet))
for len(ready) > 0 {
node := ready[0]
ready = ready[1:]
order = append(order, node)
for _, dep := range edges[node] {
indegree[dep]--
if indegree[dep] == 0 {
ready = append(ready, dep)
sort.Strings(ready)
}
}
}
if len(order) != len(selectedSet) {
return nil, fmt.Errorf("selected scriptorium artifacts contain a dependency cycle")
}
return order, nil
}
func executeAnalyzeArtifact(
ctx context.Context,
env *Env,
m *manifest.Manifest,
paths artifacts.SessionPaths,
runLayout runStageLayout,
sessionID string,
sessionDir string,
plan analyzeArtifactExecutionPlan,
transcriptRefs analyzeTranscriptInputs,
runtimeCatalog *artifacts.ArtifactCatalog,
) (*analyzeArtifactExecutionResult, error) {
artifactName := plan.Name
artifactCfg := plan.Cfg
inputPaths := map[string]string{}
omittedOptionalInputs := []string{}
sessionDir := filepath.Dir(strings.TrimSpace(env.Config.SessionPath))
reusedArtifacts := []map[string]any{}
inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs)
for _, inputName := range inputNames {
inputCfg := artifactCfg.Inputs[inputName]
resolvedPath, resolved, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog)
resolvedPath, resolved, resolvedArtifact, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog)
if resolveErr != nil {
return nil, fmt.Errorf("analyze: resolve input %q: %w", inputName, resolveErr)
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolveErr)
}
if !resolved {
if inputCfg.Required {
return nil, fmt.Errorf("analyze: required input %q could not be resolved", inputName)
return nil, fmt.Errorf("analyze: required input %q for artifact %q could not be resolved", inputName, artifactName)
}
omittedOptionalInputs = append(omittedOptionalInputs, inputName)
continue
}
inputPaths[inputName] = resolvedPath
if resolvedArtifact != nil && resolvedArtifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk {
reusedArtifacts = append(reusedArtifacts, map[string]any{
"name": configuredArtifactNameFromSourceID(resolvedArtifact.ID),
"source_id": resolvedArtifact.ID,
"path": resolvedArtifact.Path,
"provenance": resolvedArtifact.Provenance,
})
}
}
vars, err := buildScriptoriumVars(artifactCfg.Vars, env.Config.Session)
if err != nil {
return nil, fmt.Errorf("analyze: resolve vars: %w", err)
return nil, fmt.Errorf("analyze: resolve vars for artifact %q: %w", artifactName, err)
}
canonicalOutputPath, err := resolveScriptoriumOutputPath(paths, artifactCfg.OutputPath)
if err != nil {
return nil, fmt.Errorf("analyze: resolve output path: %w", err)
return nil, fmt.Errorf("analyze: resolve output path for artifact %q: %w", artifactName, err)
}
outputPath, err := runLocalPathForCanonical(runLayout, paths, canonicalOutputPath)
if err != nil {
return nil, fmt.Errorf("analyze: resolve run-local output path: %w", err)
return nil, fmt.Errorf("analyze: resolve run-local output path for artifact %q: %w", artifactName, err)
}
stdoutLogPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".stdout.log")
stderrLogPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".stderr.log")
generatedConfigPath := filepath.Join(paths.ConfigDir, "scriptorium."+artifactName+".generated.yml")
@@ -139,14 +367,17 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
timeout, err := resolveScriptoriumTimeout(env.Config.Pipeline.Scriptorium.Timeout, artifactCfg.Timeout)
if err != nil {
return nil, fmt.Errorf("analyze: resolve timeout: %w", err)
return nil, fmt.Errorf("analyze: resolve timeout for artifact %q: %w", artifactName, err)
}
logPaths := []string{}
generatedConfigs := []string{}
meta := map[string]any{
"stage": "analyze",
"name": artifactName,
"artifact_name": artifactName,
"source_id": artifacts.ConfiguredArtifactSourceID(artifactName),
"output_kind": "scriptorium_artifact",
"prompt_id": artifactCfg.PromptID,
"profile_id": artifactCfg.ProfileID,
"binary": env.Config.Pipeline.Scriptorium.Binary,
@@ -194,10 +425,10 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}
renderRes, renderErr := env.Scriptorium.RenderArtifact(ctx, renderReq)
if renderErr != nil {
return nil, fmt.Errorf("analyze: scriptorium render failed: %w", renderErr)
return nil, fmt.Errorf("analyze: scriptorium render failed for artifact %q: %w", artifactName, renderErr)
}
if renderRes.ValidationFailed {
return nil, fmt.Errorf("analyze: scriptorium render returned validation_failed=true")
return nil, fmt.Errorf("analyze: scriptorium render returned validation_failed=true for artifact %q", artifactName)
}
finalRenderOutputPath := coalesceString(renderRes.OutputPath, renderReq.OutputPath)
if err := requireNonEmptyFile(finalRenderOutputPath, artifactName+" render output"); err != nil {
@@ -245,7 +476,8 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if runErr != nil {
if res.ValidationFailed {
return nil, fmt.Errorf(
"analyze: scriptorium validation failed (prompt_id=%q, output_path=%q, exit_code=%d, stdout_log=%q, stderr_log=%q): %w",
"analyze: scriptorium validation failed (artifact=%q, prompt_id=%q, output_path=%q, exit_code=%d, stdout_log=%q, stderr_log=%q): %w",
artifactName,
req.PromptID,
coalesceString(res.OutputPath, req.OutputPath),
res.ExitCode,
@@ -254,10 +486,10 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
runErr,
)
}
return nil, fmt.Errorf("analyze: scriptorium run failed: %w", runErr)
return nil, fmt.Errorf("analyze: scriptorium run failed for artifact %q: %w", artifactName, runErr)
}
if res.ValidationFailed {
return nil, fmt.Errorf("analyze: scriptorium run returned validation_failed=true")
return nil, fmt.Errorf("analyze: scriptorium run returned validation_failed=true for artifact %q", artifactName)
}
finalOutputPath := coalesceString(res.OutputPath, req.OutputPath)
@@ -270,12 +502,13 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
SessionID: sessionID,
})
if err != nil {
return nil, fmt.Errorf("analyze: promote artifact output: %w", err)
return nil, fmt.Errorf("analyze: promote artifact output for %q: %w", artifactName, err)
}
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
@@ -290,39 +523,38 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
meta["adapter_generated_config"] = res.GeneratedConfigPath
meta["adapter_stdout_log_path"] = res.StdoutLogPath
meta["adapter_stderr_log_path"] = res.StderrLogPath
meta["provenance"] = artifacts.ArtifactProvenanceGeneratedCurrentAnalyzeRun
if res.Metadata != nil {
meta["adapter_metadata"] = res.Metadata
}
return &StageResult{
Outputs: []artifacts.Ref{promotedArtifact},
return &analyzeArtifactExecutionResult{
Output: promotedArtifact,
Logs: logPaths,
GeneratedConfigs: generatedConfigs,
Metadata: meta,
ReusedArtifacts: reusedArtifacts,
}, nil
}
func selectAnalyzeArtifact(cfg *config.ScriptoriumConfig) (string, config.ScriptoriumArtifactConfig, string, error) {
if cfg == nil {
return "", config.ScriptoriumArtifactConfig{}, "pipeline.scriptorium is not configured", nil
func extractPlanNames(plans []analyzeArtifactExecutionPlan) []string {
if len(plans) == 0 {
return nil
}
out := make([]string, 0, len(plans))
for _, plan := range plans {
out = append(out, plan.Name)
}
return out
}
enabled := []string{}
for name, artifact := range cfg.Artifacts {
if artifact.Enabled {
enabled = append(enabled, name)
}
func configuredArtifactNameFromSourceID(sourceID string) string {
trimmed := strings.TrimSpace(sourceID)
const prefix = "narratio.artifact."
if !strings.HasPrefix(trimmed, prefix) {
return ""
}
sort.Strings(enabled)
if len(enabled) == 0 {
return "", config.ScriptoriumArtifactConfig{}, "no enabled scriptorium artifacts configured", nil
}
sessionRecapCfg, ok := cfg.Artifacts["session_recap"]
if !ok || !sessionRecapCfg.Enabled {
return "", config.ScriptoriumArtifactConfig{}, "", fmt.Errorf("only artifacts.session_recap is supported in this analyze implementation; enabled=%s", strings.Join(enabled, ","))
}
return "session_recap", sessionRecapCfg, "", nil
return strings.TrimPrefix(trimmed, prefix)
}
func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) {
@@ -397,45 +629,47 @@ func resolveScriptoriumInput(
paths artifacts.SessionPaths,
sessionDir string,
runtimeCatalog *artifacts.ArtifactCatalog,
) (string, bool, error) {
switch strings.TrimSpace(inputCfg.Source) {
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
source := strings.TrimSpace(inputCfg.Source)
switch source {
case "previous_session_artifact":
if strings.TrimSpace(inputCfg.Path) == "" {
return "", false, nil
return "", false, nil, nil
}
resolved := resolveInputPathForRead(paths, sessionDir, inputCfg.Path)
if err := requireFile(resolved, "scriptorium input "+inputName); err != nil {
return "", false, nil
return "", false, nil, nil
}
return resolved, true, nil
return resolved, true, nil, nil
default:
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, inputCfg.Source, runtimeCatalog)
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
if err == nil {
return resolved.Path, true, nil
copy := resolved
return resolved.Path, true, &copy, nil
}
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
if artifacts.IsConfiguredArtifactSource(inputCfg.Source) {
if artifacts.IsConfiguredArtifactSource(source) {
if inputCfg.Required {
return "", false, fmt.Errorf("configured artifact source %q is unavailable", inputCfg.Source)
return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source)
}
return "", false, nil
return "", false, nil, nil
}
normalized, normalizeErr := artifacts.NormalizeSessionArtifactSource(inputCfg.Source)
normalized, normalizeErr := artifacts.NormalizeSessionArtifactSource(source)
if normalizeErr != nil {
return "", false, normalizeErr
return "", false, nil, normalizeErr
}
switch normalized {
case artifacts.ArtifactTranscriptPolished:
return "", false, nil
return "", false, nil, nil
case artifacts.ArtifactTranscriptFull:
return "", false, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
case artifacts.ArtifactTranscriptTrimmed:
return "", false, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
default:
return "", false, nil
return "", false, nil, nil
}
}
return "", false, err
return "", false, nil, err
}
}

View File

@@ -444,6 +444,226 @@ 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)
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)
}
if len(fake.RunRequests) != 2 {
t.Fatalf("run requests = %d, want 2", len(fake.RunRequests))
}
if fake.RunRequests[0].PromptID != "dnd.player_handout" {
t.Fatalf("first prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID)
}
if fake.RunRequests[1].PromptID != "dnd.session_recap" {
t.Fatalf("second prompt id = %q, want dnd.session_recap", fake.RunRequests[1].PromptID)
}
selected, ok := result.Metadata["selected_artifacts"].([]string)
if !ok {
t.Fatalf("selected_artifacts = %#v, want []string", result.Metadata["selected_artifacts"])
}
if len(selected) != 2 || selected[0] != "player_handout" || selected[1] != "session_recap" {
t.Fatalf("selected_artifacts = %#v, want [player_handout session_recap]", selected)
}
}
func TestAnalyzeRunsDependenciesBeforeDependents(t *testing.T) {
env, m, fake := 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,
DependsOn: []string{"session_recap"},
PromptID: "dnd.player_handout",
ProfileID: "local-quality",
OutputPath: "artifacts/player_handout.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"recap": {
Source: "narratio.artifact.session_recap",
Required: true,
},
"transcript": {
Source: "narratio.transcript.trimmed",
Required: true,
},
},
}
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.RunRequests) != 2 {
t.Fatalf("run requests = %d, want 2", len(fake.RunRequests))
}
if fake.RunRequests[0].PromptID != "dnd.session_recap" {
t.Fatalf("first prompt id = %q, want dnd.session_recap", fake.RunRequests[0].PromptID)
}
if fake.RunRequests[1].PromptID != "dnd.player_handout" {
t.Fatalf("second prompt id = %q, want dnd.player_handout", fake.RunRequests[1].PromptID)
}
if got := fake.RunRequests[1].InputPaths["recap"]; got != filepath.Join(paths.ArtifactsDir, "session_recap.md") {
t.Fatalf("dependent recap path = %q, want %q", got, filepath.Join(paths.ArtifactsDir, "session_recap.md"))
}
}
func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) {
env, m, fake := 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,
},
},
}
env.SelectedAnalyzeArtifacts = []string{"player_handout"}
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.RunRequests) != 1 {
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
}
if fake.RunRequests[0].PromptID != "dnd.player_handout" {
t.Fatalf("prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID)
}
if len(result.Outputs) != 1 || result.Outputs[0].Kind != "player_handout" {
t.Fatalf("outputs = %#v, want only player_handout", result.Outputs)
}
}
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)
@@ -798,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()
@@ -885,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
}