package stage import ( "context" "encoding/json" "errors" "fmt" "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" } 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 } type analyzeExecutionContext struct { Env *Env Manifest *manifest.Manifest Paths artifacts.SessionPaths RunLayout runStageLayout SessionID string TranscriptRefs analyzeTranscriptInputs Catalog *artifacts.ArtifactCatalog } type analyzeInputResolutionState uint8 const ( analyzeInputPresent analyzeInputResolutionState = iota analyzeInputAbsent analyzeInputError ) type analyzeInputResolution struct { State analyzeInputResolutionState Path string Artifact *artifacts.ResolvedSessionArtifact Err error } 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 } effective := env.EffectiveArtifacts if !effective.Resolved() { effective, err = artifacts.ResolveEffectiveArtifactSet( artifacts.ConfiguredArtifactDefinitions(env.Config.Pipeline.Scriptorium.Artifacts), env.SelectedArtifactKeys, ) if err != nil { return nil, fmt.Errorf("analyze: resolve effective artifacts: %w", err) } } runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog( paths, m, env.Config.Pipeline.Scriptorium, env.Config.Pipeline.Notarius, effective, ) if err != nil { return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err) } plans, skipReason, err := buildAnalyzeExecutionPlans(env.Config.Pipeline.Scriptorium, effective, 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 } execution := analyzeExecutionContext{ Env: env, Manifest: m, Paths: paths, RunLayout: runLayout, SessionID: sessionID, TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths), Catalog: runtimeCatalog, } 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, execution, plan) 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, effective artifacts.EffectiveArtifactSet, 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 } if len(effective.Keys()) == 0 { return nil, "no selected scriptorium artifacts to execute", nil } ordered, err := orderSelectedScriptoriumArtifacts(scriptoriumCfg.Artifacts, effective, 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, effective artifacts.EffectiveArtifactSet, catalog *artifacts.ArtifactCatalog, ) ([]string, error) { selectedSet := map[string]struct{}{} selected := effective.Keys() for _, key := range selected { selectedSet[key] = struct{}{} } dependencyErrors := []string{} for _, selectedKey := range selected { cfg, ok := artifactsCfg[selectedKey] if !ok { dependencyErrors = append(dependencyErrors, fmt.Sprintf("selected artifact %q is not configured", selectedKey)) continue } 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 { dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep)) continue } entry, ok := catalog.Lookup(sourceID) if !ok || !entry.Available { dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID)) } } } if len(dependencyErrors) > 0 { return nil, errors.New(strings.Join(dependencyErrors, "; ")) } indegree := map[string]int{} edges := map[string][]string{} for _, key := range selected { indegree[key] = 0 } for _, key := range selected { 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, execution analyzeExecutionContext, plan analyzeArtifactExecutionPlan, ) (*analyzeArtifactExecutionResult, error) { env := execution.Env paths := execution.Paths runLayout := execution.RunLayout sessionID := execution.SessionID transcriptRefs := execution.TranscriptRefs 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] resolution := resolveScriptoriumInput(inputCfg, execution) switch resolution.State { case analyzeInputError: return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolution.Err) case analyzeInputAbsent: 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 case analyzeInputPresent: inputPaths[inputName] = resolution.Path default: return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: invalid resolution state", inputName, artifactName) } if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk { reusedArtifacts = append(reusedArtifacts, map[string]any{ "name": configuredArtifactNameFromSourceID(resolution.Artifact.ID), "source_id": resolution.Artifact.ID, "path": resolution.Artifact.Path, "provenance": resolution.Artifact.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, err := authoritativeOutputPath(renderReq.OutputPath, renderRes.OutputPath) if err != nil { return nil, fmt.Errorf("analyze: %w", err) } 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, 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, err := authoritativeOutputPath(req.OutputPath, res.OutputPath) if err != nil { return nil, fmt.Errorf("analyze: %w", err) } 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) { return resolveSingletonTranscript(m, paths, artifacts.ArtifactTranscriptPolished) } 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(inputCfg config.ScriptoriumInputConfig, execution analyzeExecutionContext) analyzeInputResolution { source := strings.TrimSpace(inputCfg.Source) descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source) if describeErr != nil { return analyzeInputFailure(describeErr) } if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput { resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths) if err != nil { if inputCfg.Required { return analyzeInputFailure(err) } return analyzeInputMissing() } if !ok { return analyzeInputMissing() } return analyzeInputFound(resolvedPath, nil) } if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact { resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog) if err == nil { copy := resolved return analyzeInputFound(resolved.Path, ©) } if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { if inputCfg.Required { return analyzeInputFailure(fmt.Errorf( "required previous-session input source %q is unavailable; run narratio run-stage prepare %s --force", source, execution.SessionID, )) } return analyzeInputMissing() } return analyzeInputFailure(err) } resolved, err := artifacts.ResolveSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog) if err == nil { copy := resolved return analyzeInputFound(resolved.Path, ©) } if !errors.Is(err, artifacts.ErrSessionArtifactNotFound) { return analyzeInputFailure(err) } if !inputCfg.Required { return analyzeInputMissing() } switch descriptor.Source.Kind { case artifactpolicy.SourceKindExtraction: return analyzeInputFailure(fmt.Errorf( "required extraction source %q is unavailable; enable and configure pipeline.notarius output %q, then run narratio run-stage extract %s --force", source, descriptor.Source.ConfiguredKey, execution.SessionID, )) case artifactpolicy.SourceKindConfiguredArtifact: return analyzeInputFailure(fmt.Errorf("configured artifact source %q is unavailable", source)) default: return analyzeInputFailure(requiredBuiltInInputError(descriptor.Source.ID, execution)) } } func analyzeInputFound(path string, artifact *artifacts.ResolvedSessionArtifact) analyzeInputResolution { return analyzeInputResolution{State: analyzeInputPresent, Path: path, Artifact: artifact} } func analyzeInputMissing() analyzeInputResolution { return analyzeInputResolution{State: analyzeInputAbsent} } func analyzeInputFailure(err error) analyzeInputResolution { return analyzeInputResolution{State: analyzeInputError, Err: err} } func requiredBuiltInInputError(source string, execution analyzeExecutionContext) error { entry, ok := execution.Catalog.Lookup(source) if !ok || strings.TrimSpace(entry.ProducerStage) == "" { return fmt.Errorf("required built-in source %q is unavailable", source) } return fmt.Errorf( "required built-in source %q is unavailable; run narratio run-stage %s %s --force", source, entry.ProducerStage, execution.SessionID, ) } 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, effective artifacts.EffectiveArtifactSet, ) (*artifacts.ArtifactCatalog, error) { configured := artifacts.ConfiguredArtifactDefinitions(nil) if scriptoriumCfg != nil { configured = artifacts.ConfiguredArtifactDefinitions(scriptoriumCfg.Artifacts) } extractionDefinitions := artifacts.ExtractionDefinitionsFromConfig(notariusCfg) catalog, err := artifacts.BootstrapRuntimeCatalog(configured, effective, extractionDefinitions) if err != nil { return nil, err } if notariusCfg != nil && notariusCfg.Enabled { catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions) } 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 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 { data, err := readExternalResult(path, label) if err != nil { return err } if len(data) == 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 := readExternalResult(path, "scriptorium artifact result") if err != nil { return err } var payload any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("decode json: %w", err) } return nil }