package stage import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) type analyzeStage struct{} func (analyzeStage) Name() string { return "analyze" } func (analyzeStage) Declares() IODecl { return IODecl{ Inputs: []artifacts.Ref{ {Kind: "transcript_polished", Category: "transcripts", RelativePath: "transcripts/polished.json"}, {Kind: "transcript_final", Category: "transcripts", RelativePath: "transcripts/final.json"}, {Kind: "transcript_final_trimmed", Category: "transcripts", RelativePath: "transcripts/final.trimmed.json"}, }, 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") } if env.ArtifactStore == nil { return nil, fmt.Errorf("analyze: artifact store is required") } if env.Config.Pipeline == nil || env.Config.Session == nil { return nil, fmt.Errorf("analyze: resolved config must include pipeline and session") } if env.Scriptorium == nil { return nil, fmt.Errorf("analyze: scriptorium 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("analyze: session id is required") } paths := sessionPathsForEnv(env, sessionID) runLayout, err := resolveRunStageLayout(env, m, paths, sessionID, "analyze") if err != nil { 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 } runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog( paths, m, env.Config.Pipeline.Scriptorium, env.Config.Pipeline.Notarius, env.SelectedArtifactKeys, ) if err != nil { 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{} reusedArtifacts := []map[string]any{} inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs) for _, inputName := range inputNames { inputCfg := artifactCfg.Inputs[inputName] resolvedPath, resolved, resolvedArtifact, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog) if resolveErr != nil { 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 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 for artifact %q: %w", artifactName, err) } vars = withScriptoriumStickySessionVar(vars, sessionID) canonicalOutputPath, err := resolveScriptoriumOutputPath(paths, artifactCfg.OutputPath) if err != nil { 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 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") if runLayout.Enabled { stdoutLogPath = filepath.Join(runLayout.LogsDir, "scriptorium."+artifactName+".stdout.log") stderrLogPath = filepath.Join(runLayout.LogsDir, "scriptorium."+artifactName+".stderr.log") generatedConfigPath = filepath.Join(runLayout.ConfigDir, "scriptorium."+artifactName+".generated.yml") } timeout, err := resolveScriptoriumTimeout(env.Config.Pipeline.Scriptorium.Timeout, artifactCfg.Timeout) if err != nil { 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, "config_path": env.Config.Pipeline.Scriptorium.ConfigPath, "input_paths": inputPaths, "input_names": sortedMapKeys(inputPaths), "omitted_optional_inputs": omittedOptionalInputs, "vars": vars, "timeout": timeout.String(), "processed_transcript_path": transcriptRefs.ProcessedPath, "processed_transcript_source": transcriptRefs.ProcessedSource, "normalized_transcript_path": transcriptRefs.NormalizedPath, "normalized_transcript_source": transcriptRefs.NormalizedSource, "trimmed_transcript_path": transcriptRefs.TrimmedPath, "trimmed_transcript_source": transcriptRefs.TrimmedSource, "render_debug_enabled": resolveRenderDebugEnabled(env.Config.Pipeline.Scriptorium.RenderDebug, artifactCfg.RenderDebug), } if meta["render_debug_enabled"] == true { renderOutputPath := filepath.Join(paths.ArtifactsDir, artifactName+".render.json") if runLayout.Enabled { renderOutputPath = filepath.Join(runLayout.ReportsDir, artifactName+".render.json") } renderStdoutPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".render.stdout.log") renderStderrPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".render.stderr.log") renderGeneratedConfigPath := filepath.Join(paths.ConfigDir, "scriptorium."+artifactName+".render.generated.yml") if runLayout.Enabled { renderStdoutPath = filepath.Join(runLayout.LogsDir, "scriptorium."+artifactName+".render.stdout.log") renderStderrPath = filepath.Join(runLayout.LogsDir, "scriptorium."+artifactName+".render.stderr.log") renderGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "scriptorium."+artifactName+".render.generated.yml") } renderReq := scriptorium.RenderArtifactRequest{ Binary: env.Config.Pipeline.Scriptorium.Binary, ConfigPath: env.Config.Pipeline.Scriptorium.ConfigPath, PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, InputPaths: inputPaths, Vars: vars, OutputPath: renderOutputPath, StdoutLogPath: renderStdoutPath, StderrLogPath: renderStderrPath, GeneratedConfigPath: renderGeneratedConfigPath, Timeout: timeout, } renderRes, renderErr := env.Scriptorium.RenderArtifact(ctx, renderReq) if renderErr != nil { 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 for artifact %q", artifactName) } finalRenderOutputPath := coalesceString(renderRes.OutputPath, renderReq.OutputPath) if err := requireNonEmptyFile(finalRenderOutputPath, artifactName+" render output"); err != nil { return nil, fmt.Errorf("analyze: %w", err) } if err := validateJSONFile(finalRenderOutputPath); err != nil { return nil, fmt.Errorf("analyze: render diagnostics %q invalid json: %w", finalRenderOutputPath, err) } logPaths = append(logPaths, renderStdoutPath, renderStderrPath) generatedConfigs = append(generatedConfigs, renderGeneratedConfigPath) meta["render_output_path"] = finalRenderOutputPath meta["render_stdout_log_path"] = renderStdoutPath meta["render_stderr_log_path"] = renderStderrPath meta["render_generated_config_path"] = renderGeneratedConfigPath meta["render_adapter_exit_code"] = renderRes.ExitCode meta["render_adapter_duration_ms"] = renderRes.Duration.Milliseconds() meta["render_adapter_command_mode"] = renderRes.CommandMode meta["render_adapter_prompt_id"] = renderRes.PromptID meta["render_adapter_profile_id"] = renderRes.ProfileID meta["render_adapter_output_path"] = renderRes.OutputPath meta["render_adapter_generated_config"] = renderRes.GeneratedConfigPath meta["render_adapter_stdout_log_path"] = renderRes.StdoutLogPath meta["render_adapter_stderr_log_path"] = renderRes.StderrLogPath if renderRes.Metadata != nil { meta["render_adapter_metadata"] = renderRes.Metadata } } req := scriptorium.RunArtifactRequest{ Binary: env.Config.Pipeline.Scriptorium.Binary, ConfigPath: env.Config.Pipeline.Scriptorium.ConfigPath, PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, InputPaths: inputPaths, Vars: vars, OutputPath: outputPath, StdoutLogPath: stdoutLogPath, StderrLogPath: stderrLogPath, GeneratedConfigPath: generatedConfigPath, Timeout: timeout, } res, runErr := env.Scriptorium.RunArtifact(ctx, req) if runErr != nil { if res.ValidationFailed { return nil, fmt.Errorf( "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, coalesceString(res.StdoutLogPath, req.StdoutLogPath), coalesceString(res.StderrLogPath, req.StderrLogPath), 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 for artifact %q", artifactName) } finalOutputPath := coalesceString(res.OutputPath, req.OutputPath) if err := requireNonEmptyFile(finalOutputPath, artifactName+" output"); err != nil { return nil, fmt.Errorf("analyze: %w", err) } materializedArtifact, err := materializeRunLocalOutput(env.ArtifactStore, finalOutputPath, canonicalOutputPath, artifacts.Ref{ Kind: artifactName, Category: "artifacts", SessionID: sessionID, }) if err != nil { return nil, fmt.Errorf("analyze: materialize 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 meta["stderr_log_path"] = stderrLogPath meta["adapter_exit_code"] = res.ExitCode meta["adapter_duration_ms"] = res.Duration.Milliseconds() meta["adapter_command_mode"] = res.CommandMode meta["adapter_prompt_id"] = res.PromptID meta["adapter_profile_id"] = res.ProfileID meta["adapter_validation_failed"] = res.ValidationFailed meta["adapter_output_path"] = res.OutputPath 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 &analyzeArtifactExecutionResult{ Output: materializedArtifact, Logs: logPaths, GeneratedConfigs: generatedConfigs, Metadata: meta, ReusedArtifacts: reusedArtifacts, }, 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 } func configuredArtifactNameFromSourceID(sourceID string) string { name, _ := artifactpolicy.ParseConfiguredSource(sourceID) return name } func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) { candidates := []string{} if m != nil && m.Stages != nil { if sr := m.Stages["polish"]; sr != nil { for _, out := range sr.Outputs { if out.Kind != "transcript_polished" { continue } p := strings.TrimSpace(out.LocalPath) if p == "" { continue } resolved := artifacts.ResolveSessionLocalPathForRead(paths, p) candidates = append(candidates, filepath.Clean(resolved)) } } } deduped := dedupeAndSortPaths(candidates) for _, p := range deduped { if info, err := os.Stat(p); err == nil && !info.IsDir() { return p, "manifest.polish.outputs", nil } } fallback := filepath.Join(paths.TranscriptsDir, "polished.json") if info, err := os.Stat(fallback); err == nil && !info.IsDir() { return filepath.Clean(fallback), "fallback.transcripts_dir", nil } if len(deduped) > 0 { return deduped[0], "manifest.polish.outputs", nil } return "", "", nil } type analyzeTranscriptInputs struct { ProcessedPath string ProcessedSource string NormalizedPath string NormalizedSource string TrimmedPath string TrimmedSource string } func discoverAnalyzeTranscriptRefs(m *manifest.Manifest, paths artifacts.SessionPaths) analyzeTranscriptInputs { processedPath, processedSource := discoverAnalyzeArtifactRef(m, paths, artifacts.ArtifactTranscriptPolished) normalizedPath, normalizedSource := discoverAnalyzeArtifactRef(m, paths, artifacts.ArtifactTranscriptFinal) trimmedPath, trimmedSource := discoverAnalyzeArtifactRef(m, paths, artifacts.ArtifactTranscriptFinalTrimmed) return analyzeTranscriptInputs{ ProcessedPath: processedPath, ProcessedSource: processedSource, NormalizedPath: normalizedPath, NormalizedSource: normalizedSource, TrimmedPath: trimmedPath, TrimmedSource: trimmedSource, } } func discoverAnalyzeArtifactRef(m *manifest.Manifest, paths artifacts.SessionPaths, source string) (string, string) { resolved, err := artifacts.ResolveSessionArtifact(paths, m, source) if err != nil { return "", "" } return resolved.Path, resolved.Provenance } func resolveScriptoriumInput( inputName string, inputCfg config.ScriptoriumInputConfig, m *manifest.Manifest, paths artifacts.SessionPaths, sessionDir string, runtimeCatalog *artifacts.ArtifactCatalog, ) (string, bool, *artifacts.ResolvedSessionArtifact, error) { source := strings.TrimSpace(inputCfg.Source) descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source) if describeErr != nil { return "", false, nil, describeErr } if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput { resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, paths) if err != nil { if inputCfg.Required { return "", false, nil, err } return "", false, nil, nil } return resolvedPath, ok, nil, nil } if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact { resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) if err == nil { copy := resolved return resolved.Path, true, ©, nil } if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { if inputCfg.Required { return "", false, nil, fmt.Errorf( "required previous-session input source %q is unavailable; run narratio run-stage --force prepare", source, ) } return "", false, nil, nil } return "", false, nil, err } switch source { default: resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) if err == nil { copy := resolved return resolved.Path, true, ©, nil } if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { if descriptor.Source.Kind == artifactpolicy.SourceKindExtraction { if inputCfg.Required { return "", false, nil, fmt.Errorf( "required extraction source %q is unavailable; enable and configure pipeline.notarius output %q, then rerun extract with --force", source, descriptor.Source.ConfiguredKey, ) } return "", false, nil, nil } if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact { if inputCfg.Required { return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source) } return "", false, nil, nil } switch descriptor.Source.ID { case artifacts.ArtifactTranscriptPolished: return "", false, nil, nil case artifacts.ArtifactTranscriptFinal: return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first") case artifacts.ArtifactTranscriptFinalTrimmed: return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first") case artifacts.ArtifactTranscriptFinalMarkdown, artifacts.ArtifactTranscriptFinalTrimmedMarkdown: return "", false, nil, fmt.Errorf( "rendered markdown transcript input is unavailable for source %q; run narratio run-stage render %s --force", descriptor.Source.ID, paths.SessionID, ) default: return "", false, nil, nil } } return "", false, nil, err } } func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) { filename, ok := preparedStableInputFilename(sourceID) if !ok { return "", false, fmt.Errorf("unsupported prepared input source %q", sourceID) } path := filepath.Join(paths.InputsDir, filename) if err := requireNonEmptyFile(path, "prepared input "+sourceID); err != nil { return "", false, fmt.Errorf( "prepared input source %q is unavailable; run narratio run-stage prepare %s --force: %w", sourceID, paths.SessionID, err, ) } return path, true, nil } func preparedStableInputFilename(sourceID string) (string, bool) { switch strings.TrimSpace(sourceID) { case artifactpolicy.SourceInputPlayers: return "players.yml", true case artifactpolicy.SourceInputParty: return "party.yml", true case artifactpolicy.SourceInputGlossary: return "glossary.yml", true default: return "", false } } func buildAnalyzeRuntimeArtifactCatalog( paths artifacts.SessionPaths, m *manifest.Manifest, scriptoriumCfg *config.ScriptoriumConfig, notariusCfg *config.NotariusConfig, selectedArtifacts []string, ) (*artifacts.ArtifactCatalog, error) { catalog := artifacts.NewArtifactCatalog() if err := catalog.RegisterBuiltIns(); err != nil { return nil, err } extractionDefinitions := artifacts.ExtractionDefinitionsFromConfig(notariusCfg) if err := catalog.RegisterExtractionArtifacts(extractionDefinitions); err != nil { return nil, err } if notariusCfg != nil && notariusCfg.Enabled { catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions) } if scriptoriumCfg == nil { return catalog, nil } configured := map[string]artifacts.ConfiguredArtifactDefinition{} for key, artifactCfg := range scriptoriumCfg.Artifacts { configured[key] = artifacts.ConfiguredArtifactDefinition{ Enabled: artifactCfg.Enabled, OutputPath: artifactCfg.OutputPath, } } if err := catalog.RegisterConfiguredArtifacts(configured, selectedArtifacts); err != nil { return nil, err } for _, entry := range catalog.ListConfigured() { if entry.Executable { continue } if strings.TrimSpace(entry.CanonicalRelPath) == "" { continue } resolvedPath, err := resolveScriptoriumOutputPath(paths, entry.CanonicalRelPath) if err != nil { continue } if err := requireNonEmptyFile(resolvedPath, "configured artifact "+entry.SourceID); err != nil { continue } if err := catalog.MarkAvailableFromDisk(entry.SourceID, resolvedPath); err != nil { return nil, err } } return catalog, nil } func resolveInputPathForRead(paths artifacts.SessionPaths, sessionDir, pathValue string) string { trimmed := strings.TrimSpace(pathValue) if trimmed == "" { return "" } if filepath.IsAbs(trimmed) { return filepath.Clean(trimmed) } candidates := []string{} if strings.TrimSpace(sessionDir) != "" { candidates = append(candidates, filepath.Clean(filepath.Join(sessionDir, trimmed))) } candidates = append(candidates, filepath.Clean(artifacts.ResolveSessionLocalPathForRead(paths, trimmed))) for _, c := range candidates { if info, err := os.Stat(c); err == nil && !info.IsDir() { return c } } return candidates[0] } func resolveScriptoriumOutputPath(paths artifacts.SessionPaths, configured string) (string, error) { outputPath := strings.TrimSpace(configured) if outputPath == "" { return "", fmt.Errorf("scriptorium artifact output path is required") } if filepath.IsAbs(outputPath) { return filepath.Clean(outputPath), nil } rel := filepath.Clean(outputPath) if rel == "." || rel == "" { return "", fmt.Errorf("relative output path is required") } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", fmt.Errorf("relative output path escapes session root: %q", outputPath) } return filepath.Join(paths.Root, rel), nil } func resolveScriptoriumTimeout(topLevel, artifact string) (time.Duration, error) { raw := strings.TrimSpace(artifact) if raw == "" { raw = strings.TrimSpace(topLevel) } if raw == "" { raw = "10m" } d, err := time.ParseDuration(raw) if err != nil { return 0, fmt.Errorf("parse duration %q: %w", raw, err) } if d <= 0 { return 0, fmt.Errorf("duration must be > 0") } return d, nil } func buildScriptoriumVars(varsCfg map[string]any, session *config.SessionConfig) (map[string]string, error) { if len(varsCfg) == 0 { return nil, nil } vars := map[string]string{} keys := make([]string, 0, len(varsCfg)) for key := range varsCfg { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { value := varsCfg[key] switch typed := value.(type) { case bool: if !typed { continue } derived, ok, err := deriveSessionVarValue(key, session) if err != nil { return nil, err } if ok { vars[key] = derived } case string: vars[key] = typed default: return nil, fmt.Errorf("var %q has unsupported type %T", key, value) } } if len(vars) == 0 { return nil, nil } return vars, nil } func deriveSessionVarValue(name string, session *config.SessionConfig) (string, bool, error) { switch name { case "session_id": if session == nil || strings.TrimSpace(session.SessionID) == "" { return "", false, nil } return strings.TrimSpace(session.SessionID), true, nil case "session_date": if session == nil || strings.TrimSpace(session.Date) == "" { return "", false, nil } return strings.TrimSpace(session.Date), true, nil case "campaign_name": if session == nil || strings.TrimSpace(session.Campaign) == "" { return "", false, nil } return strings.TrimSpace(session.Campaign), true, nil case "previous_session_id": return "", false, nil default: return "", false, fmt.Errorf("unsupported boolean var %q", name) } } func requireNonEmptyFile(path string, label string) error { if err := requireFile(path, label); err != nil { return err } info, err := os.Stat(path) if err != nil { return fmt.Errorf("%s %q stat failed: %w", label, path, err) } if info.Size() <= 0 { return fmt.Errorf("%s %q is empty", label, path) } return nil } func sortedScriptoriumInputNames(inputs map[string]config.ScriptoriumInputConfig) []string { if len(inputs) == 0 { return nil } names := make([]string, 0, len(inputs)) for name := range inputs { names = append(names, name) } sort.Strings(names) return names } func sortedMapKeys(values map[string]string) []string { if len(values) == 0 { return nil } keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) return keys } func coalesceString(primary, fallback string) string { if strings.TrimSpace(primary) != "" { return primary } return fallback } func resolveRenderDebugEnabled(global bool, perArtifact *bool) bool { if perArtifact == nil { return global } return *perArtifact } func validateJSONFile(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read file: %w", err) } var payload any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("decode json: %w", err) } return nil }