diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9910a27..e2005f5 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -121,7 +121,7 @@ During every stage: ## Stage 1 — Decompose The Single-Artifact Transaction -**Status:** Pending +**Status:** Completed ### Goal diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 4a1e7fb..843e140 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -2,8 +2,6 @@ package stage import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "path/filepath" @@ -11,7 +9,6 @@ import ( "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" @@ -491,266 +488,6 @@ func cloneAnalyzeOutputContract(value *artifactmodel.ContractMetadata) *artifact 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 diff --git a/internal/stage/analyze_artifact_execution.go b/internal/stage/analyze_artifact_execution.go new file mode 100644 index 0000000..7d290ef --- /dev/null +++ b/internal/stage/analyze_artifact_execution.go @@ -0,0 +1,385 @@ +package stage + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "path/filepath" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium" + "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +type analyzeArtifactPreparation struct { + name string + promptID string + profileID string + inputPaths map[string]string + vars map[string]string + timeout time.Duration + canonicalOutputPath string + runOutputPath string + stdoutLogPath string + stderrLogPath string + generatedConfigPath string + renderEnabled bool + renderOutputPath string + renderStdoutPath string + renderStderrPath string + renderConfigPath string + metadata map[string]any + reusedArtifacts []map[string]any + logs []string + generatedConfigs []string +} + +type analyzeArtifactInvocation struct { + request scriptorium.RunArtifactRequest + result scriptorium.ArtifactResult +} + +type analyzeArtifactMaterialization struct { + output artifacts.Ref + outputSize int64 + runOutputPath string +} + +func executeAnalyzeArtifact( + ctx context.Context, + execution analyzeExecutionContext, + plan analyzeArtifactExecutionPlan, +) (*analyzeArtifactExecutionResult, error) { + prepared, err := prepareAnalyzeArtifact(execution, plan) + if err != nil { + return nil, err + } + if err := invokeAnalyzeRender(ctx, execution, prepared); err != nil { + return nil, err + } + invocation, err := invokeAnalyzeArtifact(ctx, execution, prepared) + if err != nil { + return nil, err + } + materialized, err := validateAndMaterializeAnalyzeArtifact(execution, plan, prepared, invocation) + if err != nil { + return nil, err + } + return buildAnalyzeArtifactExecutionResult(plan, prepared, invocation, materialized), nil +} + +func prepareAnalyzeArtifact( + execution analyzeExecutionContext, + plan analyzeArtifactExecutionPlan, +) (*analyzeArtifactPreparation, error) { + env := execution.Env + paths := execution.Paths + runLayout := execution.RunLayout + 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, execution.SessionID) + + canonicalOutputPath, err := resolveScriptoriumOutputPath(paths, artifactCfg.OutputPath) + if err != nil { + return nil, fmt.Errorf("analyze: resolve output path for artifact %q: %w", artifactName, err) + } + runOutputPath, 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") + renderOutputPath := filepath.Join(paths.ArtifactsDir, artifactName+".render.json") + renderStdoutPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".render.stdout.log") + renderStderrPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".render.stderr.log") + renderConfigPath := filepath.Join(paths.ConfigDir, "scriptorium."+artifactName+".render.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") + renderOutputPath = filepath.Join(runLayout.ReportsDir, artifactName+".render.json") + renderStdoutPath = filepath.Join(runLayout.LogsDir, "scriptorium."+artifactName+".render.stdout.log") + renderStderrPath = filepath.Join(runLayout.LogsDir, "scriptorium."+artifactName+".render.stderr.log") + renderConfigPath = filepath.Join(runLayout.ConfigDir, "scriptorium."+artifactName+".render.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) + } + renderEnabled := resolveRenderDebugEnabled(env.Config.Pipeline.Scriptorium.RenderDebug, artifactCfg.RenderDebug) + + return &analyzeArtifactPreparation{ + name: artifactName, + promptID: artifactCfg.PromptID, + profileID: artifactCfg.ProfileID, + inputPaths: inputPaths, + vars: vars, + timeout: timeout, + canonicalOutputPath: canonicalOutputPath, + runOutputPath: runOutputPath, + stdoutLogPath: stdoutLogPath, + stderrLogPath: stderrLogPath, + generatedConfigPath: generatedConfigPath, + renderEnabled: renderEnabled, + renderOutputPath: renderOutputPath, + renderStdoutPath: renderStdoutPath, + renderStderrPath: renderStderrPath, + renderConfigPath: renderConfigPath, + reusedArtifacts: reusedArtifacts, + metadata: 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": execution.TranscriptRefs.ProcessedPath, + "processed_transcript_source": execution.TranscriptRefs.ProcessedSource, + "normalized_transcript_path": execution.TranscriptRefs.NormalizedPath, + "normalized_transcript_source": execution.TranscriptRefs.NormalizedSource, + "trimmed_transcript_path": execution.TranscriptRefs.TrimmedPath, + "trimmed_transcript_source": execution.TranscriptRefs.TrimmedSource, + "render_debug_enabled": renderEnabled, + }, + }, nil +} + +func invokeAnalyzeRender( + ctx context.Context, + execution analyzeExecutionContext, + prepared *analyzeArtifactPreparation, +) error { + if !prepared.renderEnabled { + return nil + } + req := scriptorium.RenderArtifactRequest{ + Binary: execution.Env.Config.Pipeline.Scriptorium.Binary, + ConfigPath: execution.Env.Config.Pipeline.Scriptorium.ConfigPath, + PromptID: prepared.promptID, + ProfileID: prepared.profileID, + InputPaths: prepared.inputPaths, + Vars: prepared.vars, + OutputPath: prepared.renderOutputPath, + StdoutLogPath: prepared.renderStdoutPath, + StderrLogPath: prepared.renderStderrPath, + GeneratedConfigPath: prepared.renderConfigPath, + Timeout: prepared.timeout, + } + res, err := execution.Env.Scriptorium.RenderArtifact(ctx, req) + if err != nil { + return fmt.Errorf("analyze: scriptorium render failed for artifact %q: %w", prepared.name, err) + } + if res.ValidationFailed { + return fmt.Errorf("analyze: scriptorium render returned validation_failed=true for artifact %q", prepared.name) + } + outputPath, err := authoritativeOutputPath(req.OutputPath, res.OutputPath) + if err != nil { + return fmt.Errorf("analyze: %w", err) + } + if err := requireNonEmptyFile(outputPath, prepared.name+" render output"); err != nil { + return fmt.Errorf("analyze: %w", err) + } + if err := validateJSONFile(outputPath); err != nil { + return fmt.Errorf("analyze: render diagnostics %q invalid json: %w", outputPath, err) + } + + prepared.logs = append(prepared.logs, prepared.renderStdoutPath, prepared.renderStderrPath) + prepared.generatedConfigs = append(prepared.generatedConfigs, prepared.renderConfigPath) + prepared.metadata["render_output_path"] = outputPath + prepared.metadata["render_stdout_log_path"] = prepared.renderStdoutPath + prepared.metadata["render_stderr_log_path"] = prepared.renderStderrPath + prepared.metadata["render_generated_config_path"] = prepared.renderConfigPath + prepared.metadata["render_adapter_exit_code"] = res.ExitCode + prepared.metadata["render_adapter_duration_ms"] = res.Duration.Milliseconds() + prepared.metadata["render_adapter_command_mode"] = res.CommandMode + prepared.metadata["render_adapter_prompt_id"] = res.PromptID + prepared.metadata["render_adapter_profile_id"] = res.ProfileID + prepared.metadata["render_adapter_output_path"] = res.OutputPath + prepared.metadata["render_adapter_generated_config"] = res.GeneratedConfigPath + prepared.metadata["render_adapter_stdout_log_path"] = res.StdoutLogPath + prepared.metadata["render_adapter_stderr_log_path"] = res.StderrLogPath + if res.Metadata != nil { + prepared.metadata["render_adapter_metadata"] = res.Metadata + } + return nil +} + +func invokeAnalyzeArtifact( + ctx context.Context, + execution analyzeExecutionContext, + prepared *analyzeArtifactPreparation, +) (analyzeArtifactInvocation, error) { + req := scriptorium.RunArtifactRequest{ + Binary: execution.Env.Config.Pipeline.Scriptorium.Binary, + ConfigPath: execution.Env.Config.Pipeline.Scriptorium.ConfigPath, + PromptID: prepared.promptID, + ProfileID: prepared.profileID, + InputPaths: prepared.inputPaths, + Vars: prepared.vars, + OutputPath: prepared.runOutputPath, + StdoutLogPath: prepared.stdoutLogPath, + StderrLogPath: prepared.stderrLogPath, + GeneratedConfigPath: prepared.generatedConfigPath, + Timeout: prepared.timeout, + } + res, err := execution.Env.Scriptorium.RunArtifact(ctx, req) + invocation := analyzeArtifactInvocation{request: req, result: res} + if err != nil { + if res.ValidationFailed { + return invocation, fmt.Errorf( + "analyze: scriptorium validation failed (artifact=%q, prompt_id=%q, output_path=%q, exit_code=%d, stdout_log=%q, stderr_log=%q): %w", + prepared.name, + req.PromptID, + req.OutputPath, + res.ExitCode, + coalesceString(res.StdoutLogPath, req.StdoutLogPath), + coalesceString(res.StderrLogPath, req.StderrLogPath), + err, + ) + } + return invocation, fmt.Errorf("analyze: scriptorium run failed for artifact %q: %w", prepared.name, err) + } + if res.ValidationFailed { + return invocation, fmt.Errorf("analyze: scriptorium run returned validation_failed=true for artifact %q", prepared.name) + } + return invocation, nil +} + +func validateAndMaterializeAnalyzeArtifact( + execution analyzeExecutionContext, + plan analyzeArtifactExecutionPlan, + prepared *analyzeArtifactPreparation, + invocation analyzeArtifactInvocation, +) (analyzeArtifactMaterialization, error) { + outputPath, err := authoritativeOutputPath(invocation.request.OutputPath, invocation.result.OutputPath) + if err != nil { + return analyzeArtifactMaterialization{}, fmt.Errorf("analyze: %w", err) + } + if err := requireNonEmptyFile(outputPath, prepared.name+" output"); err != nil { + return analyzeArtifactMaterialization{}, fmt.Errorf("analyze: %w", err) + } + validatedOutput, err := readExternalResult(outputPath, prepared.name+" output") + if err != nil { + return analyzeArtifactMaterialization{}, fmt.Errorf("analyze: %w", err) + } + contract := &artifactmodel.ContractMetadata{ + MediaType: "text/markdown", SchemaID: "narratio." + prepared.name, SchemaVersion: "1", + } + relativeOutputPath, err := normalizedAnalyzeOutputIdentity(plan.Cfg.OutputPath) + if err != nil { + return analyzeArtifactMaterialization{}, fmt.Errorf("analyze: normalize output identity for artifact %q: %w", prepared.name, err) + } + materialized, err := materializeRunLocalOutput( + execution.Env.ArtifactStore, + outputPath, + prepared.canonicalOutputPath, + artifacts.Ref{ + Kind: prepared.name, + SourceID: artifacts.ConfiguredArtifactSourceID(prepared.name), + Category: "artifacts", + SessionID: execution.SessionID, + RelativePath: relativeOutputPath, + Contract: contract, + }, + ) + if err != nil { + return analyzeArtifactMaterialization{}, fmt.Errorf("analyze: materialize artifact output for %q: %w", prepared.name, err) + } + expectedDigest := sha256.Sum256(validatedOutput) + if materialized.Checksum != hex.EncodeToString(expectedDigest[:]) { + return analyzeArtifactMaterialization{}, fmt.Errorf( + "analyze: materialized artifact output for %q differs from validated run-local bytes", + prepared.name, + ) + } + return analyzeArtifactMaterialization{ + output: materialized, + outputSize: int64(len(validatedOutput)), + runOutputPath: outputPath, + }, nil +} + +func buildAnalyzeArtifactExecutionResult( + plan analyzeArtifactExecutionPlan, + prepared *analyzeArtifactPreparation, + invocation analyzeArtifactInvocation, + materialized analyzeArtifactMaterialization, +) *analyzeArtifactExecutionResult { + res := invocation.result + prepared.logs = append(prepared.logs, prepared.stdoutLogPath, prepared.stderrLogPath) + prepared.generatedConfigs = append(prepared.generatedConfigs, prepared.generatedConfigPath) + prepared.metadata["run_output_path"] = materialized.runOutputPath + prepared.metadata["path"] = prepared.canonicalOutputPath + prepared.metadata["output_path"] = prepared.canonicalOutputPath + prepared.metadata["generated_config_path"] = prepared.generatedConfigPath + prepared.metadata["stdout_log_path"] = prepared.stdoutLogPath + prepared.metadata["stderr_log_path"] = prepared.stderrLogPath + prepared.metadata["adapter_exit_code"] = res.ExitCode + prepared.metadata["adapter_duration_ms"] = res.Duration.Milliseconds() + prepared.metadata["adapter_command_mode"] = res.CommandMode + prepared.metadata["adapter_prompt_id"] = res.PromptID + prepared.metadata["adapter_profile_id"] = res.ProfileID + prepared.metadata["adapter_validation_failed"] = res.ValidationFailed + prepared.metadata["adapter_output_path"] = res.OutputPath + prepared.metadata["adapter_generated_config"] = res.GeneratedConfigPath + prepared.metadata["adapter_stdout_log_path"] = res.StdoutLogPath + prepared.metadata["adapter_stderr_log_path"] = res.StderrLogPath + prepared.metadata["provenance"] = artifacts.ArtifactProvenanceGeneratedCurrentAnalyzeRun + if res.Metadata != nil { + prepared.metadata["adapter_metadata"] = res.Metadata + } + + return &analyzeArtifactExecutionResult{ + Output: materialized.output, + OutputSize: materialized.outputSize, + Scriptorium: manifest.AnalyzeArtifactProvenance{ + PromptID: plan.Cfg.PromptID, ProfileID: plan.Cfg.ProfileID, CommandMode: res.CommandMode, + }, + Logs: prepared.logs, + GeneratedConfigs: prepared.generatedConfigs, + Metadata: prepared.metadata, + ReusedArtifacts: prepared.reusedArtifacts, + } +}