package stage import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "path/filepath" "sort" "strings" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "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 OutputSize int64 Scriptorium manifest.AnalyzeArtifactProvenance 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 } 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") } 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 } if len(env.Config.Pipeline.Scriptorium.Artifacts) == 0 { return &StageResult{Metadata: map[string]any{ "stage": "analyze", "skipped": true, "reason": "no scriptorium artifacts 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) } if len(effective.Keys()) == 0 { return &StageResult{Metadata: map[string]any{ "stage": "analyze", "skipped": true, "reason": "no selected scriptorium artifacts to execute", }}, nil } execution := analyzeExecutionContext{ Env: env, Manifest: m, Paths: paths, RunLayout: runLayout, SessionID: sessionID, TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths), Catalog: runtimeCatalog, } reconciliation, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, execution) if err != nil { return nil, fmt.Errorf("analyze: reconcile configured artifacts: %w", err) } workPlan, err := planAnalyzeWork( env.Config.Pipeline.Scriptorium, env.SelectedArtifactKeys, env.Force, reconciliation, ) if err != nil { return nil, fmt.Errorf("analyze: plan configured artifacts: %w", err) } if len(workPlan.ExecutionOrder) > 0 && env.Scriptorium == nil { return nil, fmt.Errorf("analyze: scriptorium adapter is required") } logs := []string{} generatedConfigs := []string{} artifactMetadata := make([]map[string]any, 0, len(workPlan.ExecutionOrder)) reusedArtifacts := []map[string]any{} reusedSeen := map[string]struct{}{} sessionRecords := manifest.CloneAnalyzeArtifactCollection(workPlan.ProjectedRecords) invocationRecords := make(map[string]manifest.AnalyzeArtifactRecord) priorCurrentRecords := make(map[string]manifest.AnalyzeArtifactRecord) for _, item := range reconciliation.Ordered { if item.Stored != nil && item.Stored.Status == manifest.AnalyzeArtifactCurrent { priorCurrentRecords[item.Key] = *item.Stored } } invocationKeys := make(map[string]struct{}, len(workPlan.ExecutionOrder)+len(workPlan.ReusedCurrent)) for _, item := range workPlan.ExecutionOrder { invocationKeys[item.Key] = struct{}{} } for _, item := range workPlan.ReusedCurrent { invocationKeys[item.Key] = struct{}{} if record, ok := sessionRecords[item.Key]; ok { invocationRecords[item.Key] = record } } for _, item := range workPlan.ExecutionOrder { artifactCfg := env.Config.Pipeline.Scriptorium.Artifacts[item.Key] fingerprint, _, _, err := computeAnalyzeArtifactFingerprint( item.Key, env.Config.Pipeline.Scriptorium, execution, ) if err != nil { executionErr := fmt.Errorf("analyze: compute execution fingerprint for artifact %q: %w", item.Key, err) return failedAnalyzeResult( execution, item, artifactCfg, "", executionErr, sessionRecords, invocationRecords, ), executionErr } priorRecord, hadPriorRecord := priorCurrentRecords[item.Key] plan := analyzeArtifactExecutionPlan{Name: item.Key, Cfg: artifactCfg} artifactResult, err := executeAnalyzeArtifact(ctx, execution, plan) if err != nil { return failedAnalyzeResult( execution, item, artifactCfg, fingerprint, err, sessionRecords, invocationRecords, ), err } 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) } record, err := currentAnalyzeArtifactRecord( execution, plan, fingerprint, artifactResult, ) if err != nil { recordErr := fmt.Errorf("analyze: record artifact %q: %w", plan.Name, err) return failedAnalyzeResult( execution, item, artifactCfg, fingerprint, recordErr, sessionRecords, invocationRecords, ), recordErr } sessionRecords[plan.Name] = record invocationRecords[plan.Name] = record sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name) if !ok { catalogErr := fmt.Errorf("analyze: source id not found for artifact %q", plan.Name) return failedAnalyzeResult( execution, item, artifactCfg, fingerprint, catalogErr, sessionRecords, invocationRecords, ), catalogErr } if err := runtimeCatalog.MarkAvailableGeneratedEvidence( sourceID, artifactResult.Output.AbsolutePath, record.ProducerRunID, record.Output.Checksum, record.OutputSize, record.Output.Contract, ); err != nil { catalogErr := fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err) return failedAnalyzeResult( execution, item, artifactCfg, fingerprint, catalogErr, sessionRecords, invocationRecords, ), catalogErr } if !hadPriorRecord || !sameAnalyzeOutputIdentity(priorRecord, record) { staleUnscheduledAnalyzeDependents( env.Config.Pipeline.Scriptorium.Artifacts, plan.Name, invocationKeys, sessionRecords, ) } } metadata := map[string]any{ "stage": "analyze", "selected_artifacts": append([]string(nil), workPlan.ExplicitTargets...), "executed_artifacts": analyzePlanKeysForMetadata(workPlan.ExecutionOrder), "reused_current": analyzePlanKeysForMetadata(workPlan.ReusedCurrent), "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{ Logs: dedupeAndSortPaths(logs), GeneratedConfigs: dedupeAndSortPaths(generatedConfigs), Metadata: metadata, AnalyzeState: &AnalyzeStateProjection{ Session: sessionRecords, Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords), }, }, nil } func failedAnalyzeResult( execution analyzeExecutionContext, item analyzePlanItem, artifactCfg config.ScriptoriumArtifactConfig, fingerprint string, cause error, sessionRecords map[string]manifest.AnalyzeArtifactRecord, invocationRecords map[string]manifest.AnalyzeArtifactRecord, ) *StageResult { record := manifest.AnalyzeArtifactRecord{ Key: item.Key, Status: manifest.AnalyzeArtifactFailed, Dependencies: normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn), ProducerRunID: analyzeProducerRunID(execution), UpdatedAt: time.Now().UTC(), Error: NonResumable(cause.Error()).Reason, } if fingerprint != "" { record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion record.Fingerprint = fingerprint } if artifactCfg.PromptID != "" || artifactCfg.ProfileID != "" { record.Scriptorium = &manifest.AnalyzeArtifactProvenance{ PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, } } sessionRecords[item.Key] = record invocationRecords[item.Key] = record staleAnalyzeDependents(artifactCfgMap(execution), item.Key, sessionRecords) return &StageResult{AnalyzeState: &AnalyzeStateProjection{ Session: manifest.CloneAnalyzeArtifactCollection(sessionRecords), Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords), }} } func artifactCfgMap(execution analyzeExecutionContext) map[string]config.ScriptoriumArtifactConfig { if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil || execution.Env.Config.Pipeline.Scriptorium == nil { return nil } return execution.Env.Config.Pipeline.Scriptorium.Artifacts } func staleAnalyzeDependents( configured map[string]config.ScriptoriumArtifactConfig, changed string, records map[string]manifest.AnalyzeArtifactRecord, ) { staleAnalyzeDependentClosure(configured, changed, records, nil) } func currentAnalyzeArtifactRecord( execution analyzeExecutionContext, plan analyzeArtifactExecutionPlan, fingerprint string, result *analyzeArtifactExecutionResult, ) (manifest.AnalyzeArtifactRecord, error) { if result == nil { return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("execution result is required") } producerRunID := analyzeProducerRunID(execution) relativePath, err := normalizedAnalyzeOutputIdentity(plan.Cfg.OutputPath) if err != nil { return manifest.AnalyzeArtifactRecord{}, err } if relativePath == "" { return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("configured output path is required") } contract := result.Output.Contract if contract == nil { return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("validated output contract is required") } record := manifest.AnalyzeArtifactRecord{ Key: plan.Name, Status: manifest.AnalyzeArtifactCurrent, FingerprintVersion: manifest.AnalyzeFingerprintContractVersion, Fingerprint: fingerprint, Dependencies: normalizedAnalyzeDependencyKeys(plan.Cfg.DependsOn), Output: &manifest.ArtifactRecord{ Kind: "scriptorium_artifact", SourceID: artifacts.ConfiguredArtifactSourceID(plan.Name), LocalPath: relativePath, Contract: cloneAnalyzeOutputContract(contract), ProducerRunID: producerRunID, Checksum: result.Output.Checksum, }, OutputSize: result.OutputSize, ProducerRunID: producerRunID, UpdatedAt: time.Now().UTC(), Scriptorium: &result.Scriptorium, Logs: dedupeAndSortPaths(result.Logs), GeneratedConfigs: dedupeAndSortPaths(result.GeneratedConfigs), } if err := manifest.ValidateAnalyzeArtifactCollection( manifest.AnalyzeStateContractVersion, map[string]manifest.AnalyzeArtifactRecord{plan.Name: record}, ); err != nil { return manifest.AnalyzeArtifactRecord{}, err } return record, nil } func analyzeProducerRunID(execution analyzeExecutionContext) string { if execution.Manifest != nil { if runID := strings.TrimSpace(execution.Manifest.RunID); runID != "" { return runID } } // Direct stage callers predate invocation manifests. Application-owned // execution always supplies the actual run identity. return "direct-analyze" } func sameAnalyzeOutputIdentity(left, right manifest.AnalyzeArtifactRecord) bool { if left.Status != manifest.AnalyzeArtifactCurrent || right.Status != manifest.AnalyzeArtifactCurrent || left.Output == nil || right.Output == nil || left.Output.Contract == nil || right.Output.Contract == nil { return false } return left.OutputSize == right.OutputSize && left.Output.Checksum == right.Output.Checksum && *left.Output.Contract == *right.Output.Contract } func staleUnscheduledAnalyzeDependents( configured map[string]config.ScriptoriumArtifactConfig, changed string, invocationKeys map[string]struct{}, records map[string]manifest.AnalyzeArtifactRecord, ) { staleAnalyzeDependentClosure(configured, changed, records, func(key string) bool { _, evaluated := invocationKeys[key] return !evaluated }) } func staleAnalyzeDependentClosure( configured map[string]config.ScriptoriumArtifactConfig, changed string, records map[string]manifest.AnalyzeArtifactRecord, eligible func(string) bool, ) { reverse := make(map[string][]string, len(configured)) for key, artifactCfg := range configured { for _, dependency := range normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) { reverse[dependency] = append(reverse[dependency], key) } } for key := range reverse { sort.Strings(reverse[key]) } queue := append([]string(nil), reverse[changed]...) seen := make(map[string]struct{}, len(queue)) for len(queue) > 0 { key := queue[0] queue = queue[1:] if _, visited := seen[key]; visited { continue } seen[key] = struct{}{} if eligible != nil && !eligible(key) { continue } staleProjectedAnalyzeRecord(records, key) queue = append(queue, reverse[key]...) } } func analyzePlanKeysForMetadata(items []analyzePlanItem) []string { if len(items) == 0 { return nil } keys := make([]string, 0, len(items)) for _, item := range items { keys = append(keys, item.Key) } return keys } func cloneAnalyzeOutputContract(value *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata { if value == nil { return nil } cloned := *value return &cloned } 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 resolvedInputs, err := resolveAnalyzeInputIdentities(artifactCfg.Inputs, execution) if err != nil { return nil, fmt.Errorf("analyze: resolve inputs for artifact %q: %w", artifactName, err) } inputPaths := resolvedInputs.Paths() omittedOptionalInputs := []string{} reusedArtifacts := []map[string]any{} for _, identity := range resolvedInputs.Ordered { if !identity.Present { omittedOptionalInputs = append(omittedOptionalInputs, identity.Name) continue } resolvedArtifact := resolvedInputs.Artifact(identity.Name) if resolvedArtifact != nil && resolvedArtifact.Provenance == artifacts.ArtifactProvenanceCurrentAnalyzeManifest { 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, 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) } validatedOutput, err := readExternalResult(finalOutputPath, artifactName+" output") if err != nil { return nil, fmt.Errorf("analyze: %w", err) } contract := &artifactmodel.ContractMetadata{ MediaType: "text/markdown", SchemaID: "narratio." + artifactName, SchemaVersion: "1", } relativeOutputPath, err := normalizedAnalyzeOutputIdentity(artifactCfg.OutputPath) if err != nil { return nil, fmt.Errorf("analyze: normalize output identity for artifact %q: %w", artifactName, err) } materializedArtifact, err := materializeRunLocalOutput(env.ArtifactStore, finalOutputPath, canonicalOutputPath, artifacts.Ref{ Kind: artifactName, SourceID: artifacts.ConfiguredArtifactSourceID(artifactName), Category: "artifacts", SessionID: sessionID, RelativePath: relativeOutputPath, Contract: contract, }) if err != nil { return nil, fmt.Errorf("analyze: materialize artifact output for %q: %w", artifactName, err) } expectedDigest := sha256.Sum256(validatedOutput) if materializedArtifact.Checksum != hex.EncodeToString(expectedDigest[:]) { return nil, fmt.Errorf("analyze: materialized artifact output for %q differs from validated run-local bytes", artifactName) } 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, OutputSize: int64(len(validatedOutput)), Scriptorium: manifest.AnalyzeArtifactProvenance{ PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, CommandMode: res.CommandMode, }, Logs: logPaths, GeneratedConfigs: generatedConfigs, Metadata: meta, ReusedArtifacts: reusedArtifacts, }, nil } 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 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 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) } catalog.HydrateAnalyzeArtifacts(paths, m, configured) 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 }