Files
narratio/internal/stage/analyze.go

942 lines
30 KiB
Go

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
}
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
}
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,
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{}{}
for _, key := range effective.Keys() {
selectedSet[key] = 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, 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(
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, &copy, 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, &copy, 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,
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 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 {
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
}