997 lines
34 KiB
Go
997 lines
34 KiB
Go
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...)
|
|
if origin, ok := analyzeArtifactOrigin(execution, plan.Name); ok {
|
|
artifactResult.Metadata["family"] = origin.Family
|
|
artifactResult.Metadata["character_id"] = origin.CharacterID
|
|
}
|
|
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 origin, ok := analyzeArtifactOrigin(execution, item.Key); ok {
|
|
record.Family = origin.Family
|
|
record.CharacterID = origin.CharacterID
|
|
}
|
|
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 analyzeArtifactOrigin(execution analyzeExecutionContext, key string) (config.ArtifactFamilyMemberOrigin, bool) {
|
|
if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil {
|
|
return config.ArtifactFamilyMemberOrigin{}, false
|
|
}
|
|
origin, ok := config.ArtifactFamilies(execution.Env.Config.Pipeline).Members[key]
|
|
return origin, ok
|
|
}
|
|
|
|
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
|
|
}
|