2026-07-03 06:01:34 +00:00
package cli
import (
2026-07-04 00:59:32 +00:00
"context"
2026-07-08 02:46:38 +00:00
"crypto/sha256"
"encoding/hex"
2026-07-03 17:01:24 +00:00
"encoding/json"
2026-07-26 17:14:16 +00:00
"errors"
2026-07-03 17:01:24 +00:00
"flag"
2026-07-03 06:01:34 +00:00
"fmt"
"io"
2026-07-03 17:01:24 +00:00
"os"
2026-07-04 01:11:57 +00:00
"path"
"path/filepath"
2026-07-03 17:01:24 +00:00
"sort"
"strings"
2026-07-04 00:59:32 +00:00
"time"
2026-07-03 17:01:24 +00:00
2026-08-25 01:55:09 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/buildinfo"
2026-07-04 00:59:32 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
2026-07-03 17:01:24 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
2026-07-18 12:42:10 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
2026-08-09 01:41:37 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/core/fileio"
2026-07-08 02:46:38 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/checkpoint"
2026-07-18 00:31:53 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
2026-07-04 00:59:32 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
2026-07-08 03:14:59 +00:00
frameworkdebug "gitea.maximumdirect.net/eric/notarius/internal/framework/debug"
2026-07-28 13:36:01 -05:00
frameworkllm "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
2026-07-03 17:01:24 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
2026-07-03 06:01:34 +00:00
)
2026-07-03 17:01:24 +00:00
const defaultConfigPath = "/usr/local/etc/notarius/config.yml"
const usage = ` Usage :
notarius help
2026-08-25 01:55:09 +00:00
notarius -- version
2026-07-30 02:58:25 +00:00
notarius run < pipeline - id > -- input path / to / source . json [ -- json ] [ flags ]
2026-07-03 17:01:24 +00:00
notarius config validate -- config path / to / config . yml [ -- pipeline pipeline - id ] [ -- only lane - a , lane - b ]
notarius pipelines list -- config path / to / config . yml [ -- json ]
`
type Options struct {
2026-07-18 00:31:53 +00:00
Catalog pipeline . ModuleCatalog
Registries pipeline . Registries
LLMClientFactory LLMClientFactory
2026-07-18 14:21:26 +00:00
RunIDGenerator RunIDGenerator
2026-07-18 00:31:53 +00:00
LookupEnv func ( string ) ( string , bool )
Now func ( ) time . Time
UserCacheDir func ( ) ( string , error )
ChunkPlanStoreFactory pipeline . ChunkPlanStoreFactory
2026-07-18 13:00:04 +00:00
DebugRecorderFactory func ( string ) ( pipeline . DebugRecorder , error )
2026-07-18 14:31:46 +00:00
DebugTerminalFactory func ( * debugbundle . SummaryWriter ) DebugTerminalWriter
2026-08-03 16:34:48 +00:00
promptKitAssets * frameworkllm . AssetRegistry
2026-07-03 17:01:24 +00:00
}
2026-07-03 06:01:34 +00:00
2026-07-30 02:03:04 +00:00
type LLMRuntimeOverrides struct {
ReasoningEffort * string
}
type LLMClientFactory func ( ctx context . Context , cfg config . Config , profileID string , overrides LLMRuntimeOverrides ) ( contracts . StructuredLLMClient , [ ] artifacts . LLMProfileManifest , error )
2026-07-04 00:59:32 +00:00
2026-07-03 06:01:34 +00:00
// Run executes the command-line interface and returns a process exit code.
func Run ( args [ ] string , stdout , stderr io . Writer ) int {
2026-07-03 17:01:24 +00:00
return RunWithOptions ( args , stdout , stderr , Options { } )
}
func RunWithOptions ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
2026-08-25 01:55:09 +00:00
if len ( args ) > 0 && args [ 0 ] == "--version" {
if len ( args ) != 1 {
fmt . Fprintln ( stderr , "notarius: --version does not accept arguments" )
return 2
}
version , err := buildinfo . Version ( )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
fmt . Fprintf ( stdout , "notarius %s\n" , version )
return 0
}
2026-07-17 04:53:09 +00:00
var err error
opts , err = normalizeOptions ( opts )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
2026-07-03 06:01:34 +00:00
if len ( args ) == 0 {
writeUsage ( stdout )
return 0
}
switch args [ 0 ] {
case "help" , "--help" , "-h" :
writeUsage ( stdout )
return 0
2026-07-03 17:01:24 +00:00
case "config" :
return runConfig ( args [ 1 : ] , stdout , stderr , opts )
case "pipelines" :
return runPipelines ( args [ 1 : ] , stdout , stderr , opts )
2026-07-04 01:06:04 +00:00
case "run" :
return runPipelineCommand ( args [ 1 : ] , stdout , stderr , opts )
2026-07-03 06:01:34 +00:00
default :
fmt . Fprintf ( stderr , "notarius: unknown command %q\n" , args [ 0 ] )
writeUsage ( stderr )
return 2
}
}
func writeUsage ( w io . Writer ) {
fmt . Fprint ( w , usage )
}
2026-07-03 17:01:24 +00:00
2026-07-17 04:53:09 +00:00
func normalizeOptions ( opts Options ) ( Options , error ) {
2026-07-03 17:01:24 +00:00
if opts . LookupEnv == nil {
opts . LookupEnv = os . LookupEnv
}
2026-07-04 00:59:32 +00:00
if opts . Now == nil {
opts . Now = time . Now
}
2026-07-18 14:21:26 +00:00
if opts . RunIDGenerator == nil {
opts . RunIDGenerator = defaultRunIDGenerator
}
2026-07-18 00:31:53 +00:00
if opts . UserCacheDir == nil {
opts . UserCacheDir = os . UserCacheDir
}
if opts . ChunkPlanStoreFactory == nil {
opts . ChunkPlanStoreFactory = chunkplan . NewFilesystemStore
}
2026-07-18 13:00:04 +00:00
if opts . DebugRecorderFactory == nil {
opts . DebugRecorderFactory = frameworkdebug . NewFilesystemRecorder
}
2026-07-18 14:31:46 +00:00
if opts . DebugTerminalFactory == nil {
opts . DebugTerminalFactory = func ( writer * debugbundle . SummaryWriter ) DebugTerminalWriter { return writer }
}
2026-07-17 04:53:09 +00:00
if isEmptyCatalog ( opts . Catalog ) && isEmptyRegistries ( opts . Registries ) {
components , err := newProductionComponents ( )
if err != nil {
return Options { } , err
}
opts . Registries = components . registries
opts . Catalog = catalogFromRegistries ( components . registries )
2026-08-03 16:34:48 +00:00
opts . promptKitAssets = components . assets
2026-07-17 04:53:09 +00:00
}
2026-07-04 00:59:32 +00:00
if opts . LLMClientFactory == nil {
2026-08-03 18:35:40 +00:00
if opts . promptKitAssets == nil {
assets , err := productionPromptAssets ( )
if err != nil {
return Options { } , err
}
opts . promptKitAssets = assets
}
opts . LLMClientFactory = productionLLMClientFactoryWithAssets ( opts . promptKitAssets )
2026-07-04 00:59:32 +00:00
}
2026-07-17 04:53:09 +00:00
return opts , nil
2026-07-03 17:01:24 +00:00
}
2026-07-04 01:06:04 +00:00
func runPipelineCommand ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
fs := flag . NewFlagSet ( "run" , flag . ContinueOnError )
fs . SetOutput ( io . Discard )
configPath := fs . String ( "config" , "" , "config file path" )
inputPath := fs . String ( "input" , "" , "source input file path" )
onlyRaw := fs . String ( "only" , "" , "comma-separated artifact lanes" )
outputDir := fs . String ( "output-dir" , "" , "output directory" )
2026-07-26 17:14:16 +00:00
machineOutput := fs . Bool ( "json" , false , "write the successful run result as JSON" )
2026-07-18 12:42:10 +00:00
debug := fs . Bool ( "debug" , false , "write a debug bundle" )
debugDir := fs . String ( "debug-dir" , "" , "debug bundle directory" )
2026-08-09 00:29:04 +00:00
llmProfile := singleValueFlag { name : "--llm-profile" }
2026-07-30 02:11:35 +00:00
reasoningEffort := singleValueFlag { name : "--reasoning-effort" }
clearReasoningEffort := fs . Bool ( "clear-reasoning-effort" , false , "clear the LLM profile reasoning effort" )
2026-07-19 10:15:28 -05:00
resume := fs . Bool ( "resume" , false , "reuse compatible recorded checkpoints" )
2026-07-30 02:11:35 +00:00
recomputeStep := singleValueFlag { name : "--recompute-step" }
2026-07-18 00:31:53 +00:00
chunkCache := chunkCacheFlag { }
2026-08-03 19:05:49 +00:00
requestedSessionID := sessionIDFlag { }
2026-07-05 14:27:23 +00:00
referenceFlags := stringListFlag { }
withoutReferenceFlags := stringListFlag { }
2026-08-03 19:05:49 +00:00
fs . Var ( & requestedSessionID , "session-id" , "prompt session identifier" )
2026-08-09 00:29:04 +00:00
fs . Var ( & llmProfile , "llm-profile" , "LLM profile override" )
2026-07-30 02:11:35 +00:00
fs . Var ( & reasoningEffort , "reasoning-effort" , "reasoning effort override" )
2026-07-18 00:31:53 +00:00
fs . Var ( & chunkCache , "chunk_cache" , "chunk plan cache mode: auto, bypass, or refresh" )
2026-08-29 12:34:31 +00:00
fs . Var ( & referenceFlags , "reference" , "reference binding, as slot=path, chunk.slot=path, lane.slot=path, lane.extract.slot=path, lane.merge.slot=path, or lane.normalize.slot=path" )
2026-07-05 16:31:23 +00:00
fs . Var ( & withoutReferenceFlags , "without-reference" , "unbind a reference, using the same selector forms as --reference" )
2026-07-21 21:59:48 +00:00
fs . Var ( & recomputeStep , "recompute-step" , "recompute one ordered pipeline step and dependent lanes" )
2026-07-05 17:51:36 +00:00
if err := validateRunFlagValues ( args ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
2026-07-04 01:06:04 +00:00
if err := fs . Parse ( reorderRunArgs ( args ) ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
if fs . NArg ( ) == 0 {
fmt . Fprintln ( stderr , "notarius: run requires a pipeline ID" )
return 2
}
if fs . NArg ( ) > 1 {
fmt . Fprintf ( stderr , "notarius: unexpected argument %q\n" , fs . Arg ( 1 ) )
return 2
}
pipelineID := strings . TrimSpace ( fs . Arg ( 0 ) )
if pipelineID == "" {
fmt . Fprintln ( stderr , "notarius: run requires a pipeline ID" )
return 2
}
if strings . TrimSpace ( * inputPath ) == "" {
fmt . Fprintln ( stderr , "notarius: run requires --input" )
return 2
}
2026-07-18 12:42:10 +00:00
if strings . TrimSpace ( * debugDir ) != "" && ! * debug {
fmt . Fprintln ( stderr , "notarius: --debug-dir requires --debug" )
return 2
}
if strings . TrimSpace ( * outputDir ) == "" && flagWasProvided ( args , "--output-dir" ) {
fmt . Fprintln ( stderr , "notarius: --output-dir must not be empty" )
return 2
}
if strings . TrimSpace ( * debugDir ) == "" && flagWasProvided ( args , "--debug-dir" ) {
fmt . Fprintln ( stderr , "notarius: --debug-dir must not be empty" )
return 2
}
2026-08-03 19:05:49 +00:00
if requestedSessionID . set && strings . TrimSpace ( requestedSessionID . value ) == "" {
2026-07-05 17:51:36 +00:00
fmt . Fprintln ( stderr , "notarius: --session-id must not be empty" )
return 2
}
2026-08-09 00:29:04 +00:00
if llmProfile . set && strings . TrimSpace ( llmProfile . value ) == "" {
fmt . Fprintln ( stderr , "notarius: --llm-profile must not be empty" )
return 2
}
2026-07-30 02:11:35 +00:00
if reasoningEffort . set && * clearReasoningEffort {
fmt . Fprintln ( stderr , "notarius: --reasoning-effort cannot be combined with --clear-reasoning-effort" )
return 2
}
if reasoningEffort . set && strings . TrimSpace ( reasoningEffort . value ) == "" {
fmt . Fprintln ( stderr , "notarius: --reasoning-effort must not be empty" )
return 2
}
runtimeOverrides := LLMRuntimeOverrides { }
if reasoningEffort . set {
value := strings . TrimSpace ( reasoningEffort . value )
runtimeOverrides . ReasoningEffort = & value
} else if * clearReasoningEffort {
value := ""
runtimeOverrides . ReasoningEffort = & value
}
2026-07-04 01:06:04 +00:00
only , err := parseOnly ( * onlyRaw )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
2026-07-21 21:59:48 +00:00
if recomputeStep . set && strings . TrimSpace ( recomputeStep . value ) == "" {
fmt . Fprintln ( stderr , "notarius: --recompute-step must not be empty" )
return 2
}
if recomputeStep . set && len ( only ) > 0 {
fmt . Fprintln ( stderr , "notarius: --recompute-step cannot be combined with --only" )
return 2
}
2026-07-05 14:27:23 +00:00
referenceRequests , err := parseReferenceFlags ( referenceFlags )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
referenceUnbindRequests , err := parseReferenceUnbindFlags ( withoutReferenceFlags )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
2026-07-04 01:06:04 +00:00
2026-07-04 01:11:57 +00:00
cfg , loadedConfigPath , err := loadConfig ( * configPath , opts )
2026-07-04 01:06:04 +00:00
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
2026-07-18 00:31:53 +00:00
if chunkCache . set {
2026-07-18 12:42:10 +00:00
cfg . Cache . ChunkPlans . Mode = chunkCache . value
}
if dir := strings . TrimSpace ( * outputDir ) ; dir != "" {
cfg . Output . Directory = dir
2026-07-18 00:31:53 +00:00
}
2026-07-18 12:42:10 +00:00
if dir := strings . TrimSpace ( * debugDir ) ; dir != "" {
cfg . Debug . Directory = dir
}
if err := cfg . Validate ( ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
2026-07-04 01:06:04 +00:00
}
2026-07-19 10:15:28 -05:00
if * resume && ! cfg . Cache . Checkpoints . Enabled {
fmt . Fprintln ( stderr , "notarius: --resume requires cache.checkpoints.enabled: true" )
return 1
}
2026-07-21 21:59:48 +00:00
if recomputeStep . set && ! * resume {
fmt . Fprintln ( stderr , "notarius: --recompute-step requires --resume" )
return 2
}
if recomputeStep . set && ! cfg . Cache . Checkpoints . Enabled {
fmt . Fprintln ( stderr , "notarius: --recompute-step requires cache.checkpoints.enabled: true" )
return 1
}
2026-07-04 01:06:04 +00:00
2026-07-04 01:11:57 +00:00
startedAt := opts . Now ( ) . UTC ( )
2026-07-18 14:21:26 +00:00
runID , err := opts . RunIDGenerator ( startedAt )
if err != nil {
fmt . Fprintf ( stderr , "notarius: generate run ID: %v\n" , err )
return 1
}
if err := validateRunID ( runID ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: invalid generated run ID: %v\n" , err )
return 1
}
2026-07-18 14:31:46 +00:00
runOutputDir := filepath . Join ( cfg . Output . Directory , runID )
commandState := newPipelineCommandState ( runID , pipelineID , runOutputDir )
2026-07-18 12:42:10 +00:00
var summary * debugbundle . SummaryWriter
2026-07-18 14:31:46 +00:00
var terminalWriter DebugTerminalWriter
2026-07-18 12:42:10 +00:00
debugPath := ""
debugRecorder := pipeline . NoopDebugRecorder ( )
if * debug {
2026-07-18 14:21:26 +00:00
bundle , err := debugbundle . Allocate ( cfg . Debug . Directory , runID , startedAt )
2026-07-08 02:18:59 +00:00
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
2026-07-18 14:21:26 +00:00
debugPath , summary = bundle . Path ( ) , bundle . Summary ( )
2026-07-18 14:31:46 +00:00
commandState . setDebugPath ( debugPath )
terminalWriter = opts . DebugTerminalFactory ( summary )
if terminalWriter == nil {
terminalWriter = summary
}
2026-07-18 13:00:04 +00:00
debugRecorder , err = opts . DebugRecorderFactory ( bundle . TraceRoot ( ) )
2026-07-18 12:42:10 +00:00
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "create debug recorder: %w" , err ) )
2026-07-18 12:42:10 +00:00
}
debugRecorder = pipeline . SynchronizedDebugRecorder ( debugRecorder )
2026-07-04 01:06:04 +00:00
}
2026-07-18 12:42:10 +00:00
invocation := debugbundle . Invocation {
2026-07-30 02:11:35 +00:00
Operation : "run" ,
PipelineID : pipelineID ,
InputPath : strings . TrimSpace ( * inputPath ) ,
ConfigPath : loadedConfigPath ,
ConfigSource : configSource ( * configPath ) ,
OnlyLanes : append ( [ ] string ( nil ) , only ... ) ,
ChunkCacheOverride : chunkCache . explicitValue ( ) ,
ReasoningEffortOverride : runtimeOverrides . ReasoningEffort ,
Resume : * resume ,
RecomputeStep : strings . TrimSpace ( recomputeStep . value ) ,
RunID : runID ,
StartedAt : startedAt ,
2026-07-04 01:11:57 +00:00
}
2026-07-18 12:42:10 +00:00
if err := writeSummary ( summary , func ( ) error { return summary . WriteInvocation ( invocation ) } ) ; err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "write debug invocation metadata: %w" , err ) )
2026-07-08 03:02:50 +00:00
}
2026-07-04 01:11:57 +00:00
catalog , err := effectiveCatalog ( opts )
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
2026-07-04 01:11:57 +00:00
}
2026-07-05 14:27:23 +00:00
referenceOverrides , referenceUnbinds , err := resolveCLIReferenceRequests ( cfg , pipelineID , only , catalog , referenceRequests , referenceUnbindRequests )
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
2026-07-05 14:27:23 +00:00
}
2026-07-04 01:06:04 +00:00
effective , err := cfg . Resolve ( config . ResolveInput {
PipelineID : pipelineID ,
Only : only ,
Catalog : catalog ,
2026-08-09 00:29:04 +00:00
LLMProfileOverride : strings . TrimSpace ( llmProfile . value ) ,
2026-07-05 14:27:23 +00:00
ReferenceOverrides : referenceOverrides ,
ReferenceUnbinds : referenceUnbinds ,
2026-07-04 01:06:04 +00:00
} )
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
2026-07-04 01:11:57 +00:00
}
2026-07-05 18:03:23 +00:00
profileIDs := effectiveLLMProfileIDs ( effective . ResolvedPipeline )
2026-08-03 16:34:48 +00:00
if err := validateExplicitPromptKitProfiles ( context . Background ( ) , effective . Config , profileIDs , opts . promptKitAssets ) ; err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
2026-07-05 18:03:23 +00:00
}
2026-07-05 14:32:35 +00:00
workingDir , err := os . Getwd ( )
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "resolve working directory: %w" , err ) )
2026-07-05 14:32:35 +00:00
}
2026-08-27 16:40:17 +00:00
materialized , referenceDiagnostics , err := pipeline . MaterializeReferences ( effective . ResolvedPipeline , catalog , pipeline . ReferenceMaterializationOptions {
2026-07-05 14:32:35 +00:00
ConfigPath : loadedConfigPath ,
WorkingDir : workingDir ,
} )
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
2026-07-05 14:32:35 +00:00
}
effective . ResolvedPipeline = materialized
2026-07-21 21:59:48 +00:00
checkpointPolicy := pipeline . CheckpointExecutionPolicy { }
if recomputeStep . set {
checkpointPolicy , err = recomputePolicy ( effective . ResolvedPipeline , recomputeStep . value )
if err != nil {
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
}
}
2026-07-04 01:11:57 +00:00
invocation . PipelineDigest = effective . ResolvedPipeline . Digest
2026-07-18 12:42:10 +00:00
if err := writeSummary ( summary , func ( ) error { return summary . WriteInvocation ( invocation ) } ) ; err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "write debug invocation metadata: %w" , err ) )
2026-07-04 01:11:57 +00:00
}
2026-07-18 12:42:10 +00:00
if err := writeSummary ( summary , func ( ) error { return summary . WriteRedactedEffectiveConfig ( effective ) } ) ; err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "write debug effective config: %w" , err ) )
2026-07-04 01:11:57 +00:00
}
2026-07-18 14:13:27 +00:00
if err := writeSummary ( summary , func ( ) error { return summary . WriteResolvedPipeline ( effective ) } ) ; err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "write debug resolved pipeline: %w" , err ) )
2026-07-04 01:06:04 +00:00
}
2026-07-18 12:42:10 +00:00
if err := writeSummary ( summary , func ( ) error {
return summary . WriteResolvedReferences ( pipeline . ReferenceProvenance ( effective . ResolvedPipeline ) )
2026-07-08 02:18:59 +00:00
} ) ; err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "write debug resolved references: %w" , err ) )
2026-07-05 14:44:44 +00:00
}
2026-07-04 01:06:04 +00:00
registries , err := effectiveRegistries ( opts )
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
2026-07-04 01:06:04 +00:00
}
ctx := context . Background ( )
2026-07-05 18:03:23 +00:00
factoryProfileID := ""
if len ( profileIDs ) == 1 {
factoryProfileID = profileIDs [ 0 ]
}
2026-07-30 02:11:35 +00:00
llmClient , llmProfiles , err := opts . LLMClientFactory ( ctx , effective . Config , factoryProfileID , runtimeOverrides )
2026-07-04 01:06:04 +00:00
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "create LLM client for profile %q: %w" , factoryProfileID , err ) )
2026-07-04 01:06:04 +00:00
}
2026-07-28 13:36:01 -05:00
var llmFingerprints [ ] checkpoint . Fingerprint
if effective . Config . Cache . Checkpoints . Enabled {
llmFingerprints , err = llmCheckpointFingerprints ( llmClient )
if err != nil {
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "prepare LLM checkpoint identity: %w" , err ) )
}
}
2026-07-17 06:30:56 +00:00
llmClient = pipeline . WithDebugLLMRecording ( llmClient , debugRecorder )
2026-07-17 06:18:46 +00:00
prepared , err := pipeline . Prepare ( effective . ResolvedPipeline , registries , pipeline . ModuleDependencies { LLM : llmClient } )
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "prepare pipeline %q: %w" , pipelineID , err ) )
2026-07-17 06:18:46 +00:00
}
rawInput , err := os . ReadFile ( strings . TrimSpace ( * inputPath ) )
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "read input %q: %w" , strings . TrimSpace ( * inputPath ) , err ) )
2026-07-17 06:18:46 +00:00
}
2026-08-03 19:05:49 +00:00
effectiveSessionID , err := resolvePromptSessionID ( requestedSessionID . value , effective . ResolvedPipeline . Input . Module , rawInput )
if err != nil {
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
}
2026-08-03 19:09:49 +00:00
invocation . SessionID = effectiveSessionID
if err := writeSummary ( summary , func ( ) error { return summary . WriteInvocation ( invocation ) } ) ; err != nil {
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "write debug invocation metadata: %w" , err ) )
}
2026-07-18 12:42:10 +00:00
chunkPlans , err := chunkPlanStoreForRun ( effective . Config . Cache . ChunkPlans , opts )
2026-07-18 00:31:53 +00:00
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
2026-07-18 00:31:53 +00:00
}
2026-08-09 00:29:04 +00:00
checkpointRecorder , checkpointLoader , err := checkpointHandlersForRun ( effective . Config . Cache . Checkpoints , opts , effective . ResolvedPipeline , prepared . CheckpointFingerprints ( ) , llmFingerprints , rawInput , only , llmProfiles , strings . TrimSpace ( llmProfile . value ) , effectiveSessionID , runtimeOverrides , * resume )
2026-07-08 02:46:38 +00:00
if err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
2026-07-08 02:46:38 +00:00
}
2026-07-04 01:06:04 +00:00
2026-07-17 06:18:46 +00:00
output , err := pipeline . New ( ) . Run ( ctx , pipeline . RunInput {
2026-07-21 21:59:48 +00:00
Prepared : prepared ,
Path : strings . TrimSpace ( * inputPath ) ,
RawInput : rawInput ,
2026-08-03 19:05:49 +00:00
SessionID : effectiveSessionID ,
2026-07-21 21:59:48 +00:00
RunID : runID ,
StartedAt : startedAt ,
LLMProfiles : llmProfiles ,
Metadata : runMetadata ( effective . Config . Output . Directory , debugPath ) ,
2026-08-27 16:40:17 +00:00
Diagnostics : referenceDiagnostics ,
2026-07-21 21:59:48 +00:00
ChunkCacheMode : effective . Config . Cache . ChunkPlans . Mode ,
ChunkPlans : chunkPlans ,
Checkpoints : checkpointRecorder ,
Checkpoint : checkpointLoader ,
CheckpointPolicy : checkpointPolicy ,
Debug : debugRecorder ,
ExtractWorkers : cfg . Concurrency . StageWorkers [ "extract" ] ,
2026-07-04 01:06:04 +00:00
} )
2026-08-27 18:41:59 +00:00
diagnosticProjection , diagnosticErr := contracts . ProjectDiagnosticCollection ( output . Diagnostics )
if diagnosticErr == nil {
commandState . observeOutput ( output , diagnosticProjection )
}
2026-07-04 01:06:04 +00:00
if err != nil {
2026-07-18 14:31:46 +00:00
primaryErr := fmt . Errorf ( "run pipeline %q: %w" , pipelineID , err )
2026-07-18 12:42:10 +00:00
if output . Manifest . PipelineID != "" {
if summaryErr := writePartialSummary ( summary , output ) ; summaryErr != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , primaryErr , fmt . Errorf ( "write debug summary: %w" , summaryErr ) )
2026-07-18 00:45:01 +00:00
}
2026-07-04 01:11:57 +00:00
}
2026-08-27 18:41:59 +00:00
if diagnosticErr != nil {
return failPipelineCommand ( stderr , commandState , terminalWriter , primaryErr , fmt . Errorf ( "summarize run diagnostics: %w" , diagnosticErr ) )
}
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , primaryErr )
2026-07-04 01:06:04 +00:00
}
2026-08-27 18:41:59 +00:00
if diagnosticErr != nil {
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "summarize run diagnostics: %w" , diagnosticErr ) )
}
2026-07-04 01:06:04 +00:00
2026-07-18 12:42:10 +00:00
if err := writePartialSummary ( summary , output ) ; err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , fmt . Errorf ( "write debug summary: %w" , err ) )
2026-07-04 01:11:57 +00:00
}
2026-07-26 17:14:16 +00:00
var encodedResult [ ] byte
if * machineOutput {
2026-08-27 18:41:59 +00:00
result , err := newRunResultWithDiagnostics ( effective . ResolvedPipeline , output , runOutputDir , debugPath , diagnosticProjection )
2026-07-26 17:14:16 +00:00
if err != nil {
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
}
encodedResult , err = encodeRunResult ( result )
if err != nil {
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
}
}
2026-07-03 21:26:05 -05:00
if err := writeOutputFiles ( runOutputDir , output . OutputFiles ) ; err != nil {
2026-07-18 14:31:46 +00:00
return failPipelineCommand ( stderr , commandState , terminalWriter , err )
2026-07-03 21:26:05 -05:00
}
2026-07-18 14:31:46 +00:00
if primaryErr , persistenceErr := commandState . terminalize ( terminalWriter , nil ) ; primaryErr != nil {
return writePipelineCommandFailure ( stderr , commandState , primaryErr , persistenceErr )
2026-07-04 01:11:57 +00:00
}
2026-07-26 17:14:16 +00:00
if * machineOutput {
if err := writeRunResult ( stdout , encodedResult ) ; err != nil {
return writePipelineCommandFailure ( stderr , commandState , errors . New ( "write run result" ) , nil )
}
} else {
fmt . Fprintf ( stdout , "pipeline %q complete: outputs=%d rejected=%d output=%s\n" , effective . PipelineID , len ( output . NormalizeOutputs ) , len ( output . Rejected ) , runOutputDir )
if debugPath != "" {
fmt . Fprintf ( stdout , "debug=%s\n" , debugPath )
}
2026-07-18 12:42:10 +00:00
}
2026-08-27 18:41:59 +00:00
if warningGroups := len ( diagnosticProjection . Warnings ) ; warningGroups > 0 {
fmt . Fprintf ( stderr , "notarius: run completed with %d warning group(s), %d occurrence(s)" , warningGroups , diagnosticProjection . WarningOccurrenceCount )
if warningFile , ok := logicalOutputFile ( output . OutputFiles , "warnings.json" ) ; ok {
fmt . Fprintf ( stderr , "; details=%s" , filepath . Join ( runOutputDir , warningFile ) )
}
fmt . Fprintln ( stderr )
2026-07-04 01:06:04 +00:00
}
return 0
}
2026-08-27 18:41:59 +00:00
func logicalOutputFile ( files [ ] contracts . OutputFile , name string ) ( string , bool ) {
for _ , file := range files {
if file . Name == name {
return file . Name , true
}
}
return "" , false
}
2026-07-18 12:42:10 +00:00
func writeSummary ( summary * debugbundle . SummaryWriter , write func ( ) error ) error {
if summary == nil {
2026-07-08 02:18:59 +00:00
return nil
}
return write ( )
}
2026-07-18 12:42:10 +00:00
func writePartialSummary ( summary * debugbundle . SummaryWriter , output pipeline . RunOutput ) error {
if summary == nil {
return nil
}
if err := summary . WriteRunManifest ( output . Manifest ) ; err != nil {
return err
}
if output . ChunkPlan != nil {
if err := summary . WriteChunkPlan ( * output . ChunkPlan ) ; err != nil {
return err
}
}
2026-08-27 16:51:56 +00:00
if err := summary . WriteDiagnostics ( output . Diagnostics ) ; err != nil {
2026-07-18 12:42:10 +00:00
return err
}
return summary . WriteCheckpointEvents ( output . CheckpointEvents )
}
2026-07-08 03:02:50 +00:00
func checkpointHandlersForRun (
2026-07-18 12:42:10 +00:00
settings config . CheckpointCacheConfig ,
opts Options ,
2026-07-08 02:46:38 +00:00
resolved pipeline . ResolvedPipeline ,
2026-07-20 15:14:41 -05:00
componentFingerprints [ ] pipeline . CheckpointFingerprint ,
2026-07-28 13:36:01 -05:00
llmFingerprints [ ] checkpoint . Fingerprint ,
2026-07-08 02:46:38 +00:00
rawInput [ ] byte ,
only [ ] string ,
llmProfiles [ ] artifacts . LLMProfileManifest ,
llmProfileOverride string ,
sessionID string ,
2026-07-30 02:11:35 +00:00
runtimeOverrides LLMRuntimeOverrides ,
2026-07-08 03:02:50 +00:00
resume bool ,
) ( pipeline . CheckpointRecorder , pipeline . CheckpointLoader , error ) {
2026-07-19 10:15:28 -05:00
if ! settings . Enabled {
if resume {
return nil , nil , fmt . Errorf ( "--resume requires cache.checkpoints.enabled: true" )
}
2026-07-18 12:42:10 +00:00
return pipeline . NoopCheckpointRecorder ( ) , pipeline . NoopCheckpointLoader ( ) , nil
}
2026-07-18 04:55:33 +00:00
identity , err := checkpoint . NewIdentity ( checkpoint . IdentityInput {
2026-07-28 13:36:01 -05:00
Pipeline : resolved ,
InputKey : resolved . Input . Module ,
RawInputDigest : rawInputDigest ( rawInput ) ,
SelectedLanes : only ,
2026-07-30 02:11:35 +00:00
RuntimeOverrides : runtimeOverrideFingerprints ( llmProfileOverride , sessionID , runtimeOverrides ) ,
2026-07-28 13:36:01 -05:00
References : pipeline . ReferenceProvenance ( resolved ) ,
ProvenanceFingerprints : combineCheckpointFingerprints (
llmProfileFingerprints ( llmProfiles ) ,
llmFingerprints ,
checkpointIdentityFingerprints ( componentFingerprints ) ,
) ,
2026-07-08 02:46:38 +00:00
} )
if err != nil {
2026-07-08 03:02:50 +00:00
return nil , nil , fmt . Errorf ( "create checkpoint identity: %w" , err )
2026-07-08 02:46:38 +00:00
}
2026-07-18 12:42:10 +00:00
checkpointRoot := strings . TrimSpace ( settings . Directory )
if checkpointRoot == "" {
checkpointRoot , err = config . DefaultCheckpointRoot ( opts . UserCacheDir )
if err != nil {
return nil , nil , fmt . Errorf ( "resolve checkpoint root: %w" , err )
}
2026-07-18 04:55:33 +00:00
}
recorder , err := checkpoint . NewFilesystemRecorder ( checkpointRoot , identity )
2026-07-08 02:46:38 +00:00
if err != nil {
2026-07-08 03:02:50 +00:00
return nil , nil , fmt . Errorf ( "create checkpoint recorder: %w" , err )
}
2026-07-19 10:15:28 -05:00
loader := pipeline . NoopCheckpointLoader ( )
if resume {
loader , err = checkpoint . NewFilesystemLoader ( checkpointRoot , identity )
if err != nil {
return nil , nil , fmt . Errorf ( "create checkpoint loader: %w" , err )
}
2026-07-08 02:46:38 +00:00
}
2026-07-08 03:02:50 +00:00
return recorder , loader , nil
2026-07-08 02:46:38 +00:00
}
2026-07-28 13:36:01 -05:00
func llmCheckpointFingerprints ( client contracts . StructuredLLMClient ) ( [ ] checkpoint . Fingerprint , error ) {
provider , ok := client . ( frameworkllm . CheckpointFingerprintProvider )
if ! ok {
return nil , nil
}
values , err := provider . LLMCheckpointFingerprints ( )
if err != nil {
return nil , err
}
out := make ( [ ] checkpoint . Fingerprint , 0 , len ( values ) )
for _ , value := range values {
out = append ( out , checkpoint . Fingerprint { Name : value . Name , Value : value . Value } )
}
return out , nil
}
func combineCheckpointFingerprints ( sources ... [ ] checkpoint . Fingerprint ) [ ] checkpoint . Fingerprint {
var out [ ] checkpoint . Fingerprint
for _ , source := range sources {
out = append ( out , source ... )
}
return out
}
2026-07-21 21:59:48 +00:00
func recomputePolicy ( resolved pipeline . ResolvedPipeline , requestedStep string ) ( pipeline . CheckpointExecutionPolicy , error ) {
requestedStep = strings . TrimSpace ( requestedStep )
if requestedStep == "" {
return pipeline . CheckpointExecutionPolicy { } , fmt . Errorf ( "--recompute-step must not be empty" )
}
var selected * pipeline . ResolvedPipelineStep
for index := range resolved . Steps {
if strings . TrimSpace ( resolved . Steps [ index ] . ID ) == requestedStep {
selected = & resolved . Steps [ index ]
break
}
}
if selected == nil {
return pipeline . CheckpointExecutionPolicy { } , fmt . Errorf ( "unknown pipeline step %q" , requestedStep )
}
policy := pipeline . CheckpointExecutionPolicy { ForcedLanes : make ( map [ string ] struct { } ) , RequireReusableLanes : make ( map [ string ] struct { } ) }
var forced [ ] pipeline . ResolvedArtifactLane
for _ , lane := range selected . ArtifactLanes {
lane . StepID = selected . ID
policy . ForcedLanes [ pipeline . CheckpointLaneKey ( selected . ID , lane . ID ) ] = struct { } { }
forced = append ( forced , lane )
}
lanes := make ( map [ string ] pipeline . ResolvedArtifactLane )
dependents := make ( map [ string ] [ ] pipeline . ResolvedArtifactLane )
for _ , step := range resolved . Steps {
for _ , lane := range step . ArtifactLanes {
lane . StepID = step . ID
lanes [ pipeline . CheckpointLaneKey ( step . ID , lane . ID ) ] = lane
for _ , target := range [ ] pipeline . ResolvedReferenceTarget { lane . ExtractReferences , lane . MergeReferences , lane . NormalizeReferences } {
for _ , binding := range target . Bindings {
if binding . Artifact != nil {
producerKey := pipeline . CheckpointLaneKey ( binding . Artifact . Step , binding . Artifact . Lane )
dependents [ producerKey ] = append ( dependents [ producerKey ] , lane )
}
}
}
}
}
processed := make ( map [ string ] struct { } )
queue := append ( [ ] pipeline . ResolvedArtifactLane ( nil ) , forced ... )
for len ( queue ) > 0 {
lane := queue [ 0 ]
queue = queue [ 1 : ]
key := pipeline . CheckpointLaneKey ( lane . StepID , lane . ID )
if _ , ok := processed [ key ] ; ok {
continue
}
processed [ key ] = struct { } { }
for _ , dependent := range dependents [ key ] {
dependentKey := pipeline . CheckpointLaneKey ( dependent . StepID , dependent . ID )
if _ , alreadyForced := policy . ForcedLanes [ dependentKey ] ; alreadyForced {
continue
}
policy . ForcedLanes [ dependentKey ] = struct { } { }
forced = append ( forced , dependent )
queue = append ( queue , dependent )
}
}
processed = make ( map [ string ] struct { } )
queue = append ( [ ] pipeline . ResolvedArtifactLane ( nil ) , forced ... )
for len ( queue ) > 0 {
lane := queue [ 0 ]
queue = queue [ 1 : ]
key := pipeline . CheckpointLaneKey ( lane . StepID , lane . ID )
if _ , ok := processed [ key ] ; ok {
continue
}
processed [ key ] = struct { } { }
for _ , target := range [ ] pipeline . ResolvedReferenceTarget { lane . ExtractReferences , lane . MergeReferences , lane . NormalizeReferences } {
for _ , binding := range target . Bindings {
if binding . Artifact == nil {
continue
}
producerKey := pipeline . CheckpointLaneKey ( binding . Artifact . Step , binding . Artifact . Lane )
if _ , forcedAlready := policy . ForcedLanes [ producerKey ] ; ! forcedAlready {
policy . RequireReusableLanes [ producerKey ] = struct { } { }
}
if producer , ok := lanes [ producerKey ] ; ok {
queue = append ( queue , producer )
}
}
}
}
return policy , nil
}
2026-07-20 15:14:41 -05:00
func checkpointIdentityFingerprints ( values [ ] pipeline . CheckpointFingerprint ) [ ] checkpoint . Fingerprint {
if len ( values ) == 0 {
return nil
}
out := make ( [ ] checkpoint . Fingerprint , len ( values ) )
for index , value := range values {
out [ index ] = checkpoint . Fingerprint { Name : "component:" + value . Name , Value : value . Value }
}
return out
}
2026-07-08 02:46:38 +00:00
func rawInputDigest ( data [ ] byte ) string {
sum := sha256 . Sum256 ( data )
return "sha256:" + hex . EncodeToString ( sum [ : ] )
}
2026-07-30 02:11:35 +00:00
func runtimeOverrideFingerprints ( llmProfileOverride string , sessionID string , runtimeOverrides LLMRuntimeOverrides ) [ ] checkpoint . Fingerprint {
2026-07-18 04:55:33 +00:00
var values [ ] checkpoint . Fingerprint
2026-07-08 02:46:38 +00:00
if strings . TrimSpace ( llmProfileOverride ) != "" {
2026-07-18 04:55:33 +00:00
values = append ( values , checkpoint . Fingerprint { Name : "llm_profile_override" , Value : strings . TrimSpace ( llmProfileOverride ) } )
2026-07-08 02:46:38 +00:00
}
if strings . TrimSpace ( sessionID ) != "" {
2026-07-18 04:55:33 +00:00
values = append ( values , checkpoint . Fingerprint { Name : "session_id" , Value : strings . TrimSpace ( sessionID ) } )
2026-07-08 02:46:38 +00:00
}
2026-07-30 02:11:35 +00:00
if runtimeOverrides . ReasoningEffort != nil {
value := strings . TrimSpace ( * runtimeOverrides . ReasoningEffort )
if value == "" {
value = "<cleared>"
}
values = append ( values , checkpoint . Fingerprint { Name : "reasoning_effort_override" , Value : value } )
}
2026-07-08 02:46:38 +00:00
return values
}
2026-07-18 04:55:33 +00:00
func llmProfileFingerprints ( profiles [ ] artifacts . LLMProfileManifest ) [ ] checkpoint . Fingerprint {
2026-07-08 02:46:38 +00:00
if len ( profiles ) == 0 {
return nil
}
2026-07-18 04:55:33 +00:00
values := make ( [ ] checkpoint . Fingerprint , 0 , len ( profiles ) )
2026-07-08 02:46:38 +00:00
for _ , profile := range profiles {
id := strings . TrimSpace ( profile . ID )
if id == "" {
continue
}
2026-07-18 04:55:33 +00:00
values = append ( values , checkpoint . Fingerprint {
2026-07-08 02:46:38 +00:00
Name : "llm_profile:" + id ,
Value : strings . TrimSpace ( profile . Provider ) + ":" + strings . TrimSpace ( profile . Model ) ,
} )
}
return values
}
2026-07-04 01:11:57 +00:00
func configSource ( configPath string ) string {
if strings . TrimSpace ( configPath ) != "" {
return "flag"
}
return "discovered"
}
func writeOutputFiles ( runOutputDir string , files [ ] contracts . OutputFile ) error {
for _ , file := range files {
2026-08-09 01:41:37 +00:00
if _ , err := outputFilePath ( runOutputDir , file . Name ) ; err != nil {
2026-07-04 01:11:57 +00:00
return err
}
}
2026-07-18 14:21:26 +00:00
outputParent := filepath . Dir ( runOutputDir )
if err := os . MkdirAll ( outputParent , 0 o755 ) ; err != nil {
return fmt . Errorf ( "create output parent %q: %w" , outputParent , err )
}
if err := os . Mkdir ( runOutputDir , 0 o755 ) ; err != nil {
if os . IsExist ( err ) {
return fmt . Errorf ( "output run directory %q already exists" , runOutputDir )
}
return fmt . Errorf ( "create output run directory %q: %w" , runOutputDir , err )
2026-07-04 01:11:57 +00:00
}
2026-08-09 01:41:37 +00:00
for _ , file := range files {
if err := fileio . WriteBytes ( runOutputDir , file . Name , file . Bytes , 0 o755 , 0 o644 ) ; err != nil {
return fmt . Errorf ( "write output file %q: %w" , file . Name , err )
2026-07-04 01:11:57 +00:00
}
}
return nil
}
func outputFilePath ( runOutputDir , logicalName string ) ( string , error ) {
name := strings . TrimSpace ( logicalName )
if name == "" {
return "" , fmt . Errorf ( "output file name must not be empty" )
}
if strings . Contains ( name , ` \ ` ) {
return "" , fmt . Errorf ( "output file name %q must use slash-separated relative paths" , name )
}
if path . IsAbs ( name ) || filepath . IsAbs ( name ) {
return "" , fmt . Errorf ( "output file name %q must be relative" , name )
}
if strings . Contains ( name , ".." ) {
return "" , fmt . Errorf ( "output file name %q must not contain .." , name )
}
cleaned := path . Clean ( name )
if cleaned == "." || cleaned != name {
return "" , fmt . Errorf ( "output file name %q must be clean" , name )
}
root , err := filepath . Abs ( runOutputDir )
if err != nil {
return "" , fmt . Errorf ( "resolve output directory %q: %w" , runOutputDir , err )
}
target , err := filepath . Abs ( filepath . Join ( root , filepath . FromSlash ( cleaned ) ) )
if err != nil {
return "" , fmt . Errorf ( "resolve output file %q: %w" , name , err )
}
rel , err := filepath . Rel ( root , target )
if err != nil {
return "" , fmt . Errorf ( "resolve output file %q: %w" , name , err )
}
if rel == "." || strings . HasPrefix ( rel , ".." + string ( filepath . Separator ) ) || rel == ".." {
return "" , fmt . Errorf ( "output file name %q resolves outside output directory" , name )
}
return target , nil
}
2026-07-04 01:06:04 +00:00
func reorderRunArgs ( args [ ] string ) [ ] string {
var flags [ ] string
var positionals [ ] string
for i := 0 ; i < len ( args ) ; i ++ {
arg := args [ i ]
if arg == "--" {
positionals = append ( positionals , args [ i + 1 : ] ... )
break
}
if strings . HasPrefix ( arg , "-" ) {
flags = append ( flags , arg )
if runFlagTakesValue ( arg ) && ! strings . Contains ( arg , "=" ) && i + 1 < len ( args ) {
i ++
flags = append ( flags , args [ i ] )
}
continue
}
positionals = append ( positionals , arg )
}
return append ( flags , positionals ... )
}
func runFlagTakesValue ( arg string ) bool {
switch arg {
2026-07-30 02:11:35 +00:00
case "--config" , "--input" , "--only" , "--output-dir" , "--debug-dir" , "--llm-profile" , "--session-id" , "--reasoning-effort" , "--chunk_cache" , "--reference" , "--without-reference" , "--recompute-step" :
2026-07-04 01:06:04 +00:00
return true
default :
return false
}
}
2026-07-18 00:31:53 +00:00
type chunkCacheFlag struct {
value pipeline . ChunkCacheMode
set bool
}
func ( f * chunkCacheFlag ) String ( ) string {
if f == nil {
return ""
}
return string ( f . value )
}
func ( f * chunkCacheFlag ) Set ( raw string ) error {
mode , err := pipeline . ParseChunkCacheMode ( raw )
if err != nil {
return err
}
f . value = mode
f . set = true
return nil
}
func ( f chunkCacheFlag ) explicitValue ( ) string {
if ! f . set {
return ""
}
return string ( f . value )
}
2026-07-18 12:42:10 +00:00
func chunkPlanStoreForRun ( cfg config . ChunkPlanCacheConfig , opts Options ) ( pipeline . ChunkPlanStore , error ) {
2026-07-18 00:31:53 +00:00
if cfg . Mode == pipeline . ChunkCacheBypass {
return nil , nil
}
root := strings . TrimSpace ( cfg . Directory )
if root == "" {
var err error
2026-07-18 04:55:33 +00:00
root , err = config . DefaultChunkPlanRoot ( opts . UserCacheDir )
2026-07-18 00:31:53 +00:00
if err != nil {
return nil , fmt . Errorf ( "resolve chunk plan root: %w" , err )
}
}
store , err := opts . ChunkPlanStoreFactory ( root )
if err != nil {
return nil , fmt . Errorf ( "create chunk plan store at %q: %w" , root , err )
}
if store == nil {
return nil , fmt . Errorf ( "create chunk plan store at %q: factory returned nil" , root )
}
return store , nil
}
2026-07-05 17:51:36 +00:00
func validateRunFlagValues ( args [ ] string ) error {
for i , arg := range args {
2026-07-30 02:11:35 +00:00
if arg != "--session-id" && arg != "--reasoning-effort" {
2026-07-05 17:51:36 +00:00
continue
}
if i + 1 >= len ( args ) || strings . HasPrefix ( args [ i + 1 ] , "-" ) {
2026-07-30 02:11:35 +00:00
return fmt . Errorf ( "flag needs an argument: %s" , arg )
2026-07-05 17:51:36 +00:00
}
}
return nil
}
2026-07-18 12:42:10 +00:00
func flagWasProvided ( args [ ] string , name string ) bool {
for _ , arg := range args {
if arg == name || strings . HasPrefix ( arg , name + "=" ) {
return true
}
}
return false
}
2026-07-04 01:06:04 +00:00
func effectiveLLMProfileIDs ( resolved pipeline . ResolvedPipeline ) [ ] string {
seen := make ( map [ string ] struct { } )
add := func ( binding pipeline . ModuleBinding ) {
id := strings . TrimSpace ( binding . LLMProfile )
if id != "" {
seen [ id ] = struct { } { }
}
}
2026-08-03 17:15:07 +00:00
if resolved . InputExecutionClass == contracts . ExecutionClassLLMBacked {
add ( resolved . Input )
}
if resolved . ChunkExecutionClass == contracts . ExecutionClassLLMBacked {
add ( resolved . Chunk )
}
2026-07-21 21:05:28 +00:00
for _ , lane := range resolved . AllArtifactLanes ( ) {
2026-08-03 17:15:07 +00:00
if lane . ExtractExecutionClass == contracts . ExecutionClassLLMBacked {
add ( lane . Extract )
}
if lane . MergeExecutionClass == contracts . ExecutionClassLLMBacked {
add ( lane . Merge )
}
if lane . NormalizeExecutionClass == contracts . ExecutionClassLLMBacked {
add ( lane . Normalize )
}
2026-07-04 01:06:04 +00:00
}
2026-07-07 21:21:12 +00:00
for _ , chain := range resolved . ValidatorChains {
for _ , validator := range chain . Validators {
if validator . ExecutionClass == contracts . ExecutionClassLLMBacked {
add ( validator . Binding )
}
}
}
2026-08-03 17:15:07 +00:00
if resolved . OutputExecutionClass == contracts . ExecutionClassLLMBacked {
add ( resolved . Output )
}
2026-07-04 01:06:04 +00:00
ids := make ( [ ] string , 0 , len ( seen ) )
for id := range seen {
ids = append ( ids , id )
}
sort . Strings ( ids )
return ids
}
2026-07-18 12:42:10 +00:00
func runMetadata ( outputDir , debugDir string ) map [ string ] any {
2026-07-04 01:06:04 +00:00
metadata := make ( map [ string ] any )
if dir := strings . TrimSpace ( outputDir ) ; dir != "" {
metadata [ "output_dir" ] = dir
}
2026-07-18 12:42:10 +00:00
if dir := strings . TrimSpace ( debugDir ) ; dir != "" {
metadata [ "debug_dir" ] = dir
2026-07-04 01:06:04 +00:00
}
if len ( metadata ) == 0 {
return nil
}
return metadata
}
2026-07-03 17:01:24 +00:00
func runConfig ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
if len ( args ) == 0 {
fmt . Fprintln ( stderr , "notarius: config requires a subcommand" )
writeUsage ( stderr )
return 2
}
switch args [ 0 ] {
case "validate" :
return runConfigValidate ( args [ 1 : ] , stdout , stderr , opts )
default :
fmt . Fprintf ( stderr , "notarius: unknown config subcommand %q\n" , args [ 0 ] )
writeUsage ( stderr )
return 2
}
}
func runConfigValidate ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
fs := flag . NewFlagSet ( "config validate" , flag . ContinueOnError )
fs . SetOutput ( io . Discard )
configPath := fs . String ( "config" , "" , "config file path" )
pipelineID := fs . String ( "pipeline" , "" , "pipeline ID" )
onlyRaw := fs . String ( "only" , "" , "comma-separated artifact lanes" )
if err := fs . Parse ( args ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
if fs . NArg ( ) != 0 {
fmt . Fprintf ( stderr , "notarius: unexpected argument %q\n" , fs . Arg ( 0 ) )
return 2
}
if strings . TrimSpace ( * onlyRaw ) != "" && strings . TrimSpace ( * pipelineID ) == "" {
fmt . Fprintln ( stderr , "notarius: --only requires --pipeline" )
return 2
}
2026-07-03 20:54:22 +00:00
only , err := parseOnly ( * onlyRaw )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
2026-07-03 17:01:24 +00:00
cfg , path , err := loadConfig ( * configPath , opts )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
if strings . TrimSpace ( * pipelineID ) != "" {
2026-07-04 00:59:32 +00:00
catalog , err := effectiveCatalog ( opts )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
2026-07-05 18:03:23 +00:00
effective , err := cfg . Resolve ( config . ResolveInput {
2026-07-03 17:01:24 +00:00
PipelineID : * pipelineID ,
2026-07-03 20:54:22 +00:00
Only : only ,
2026-07-04 00:59:32 +00:00
Catalog : catalog ,
2026-07-05 18:03:23 +00:00
} )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
2026-08-03 16:34:48 +00:00
if err := validateExplicitPromptKitProfiles ( context . Background ( ) , effective . Config , effectiveLLMProfileIDs ( effective . ResolvedPipeline ) , opts . promptKitAssets ) ; err != nil {
2026-07-03 17:01:24 +00:00
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
fmt . Fprintf ( stdout , "config %q is valid for pipeline %q\n" , path , strings . TrimSpace ( * pipelineID ) )
return 0
}
if err := cfg . Validate ( ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
fmt . Fprintf ( stdout , "config %q is valid\n" , path )
return 0
}
func runPipelines ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
if len ( args ) == 0 {
fmt . Fprintln ( stderr , "notarius: pipelines requires a subcommand" )
writeUsage ( stderr )
return 2
}
switch args [ 0 ] {
case "list" :
return runPipelinesList ( args [ 1 : ] , stdout , stderr , opts )
default :
fmt . Fprintf ( stderr , "notarius: unknown pipelines subcommand %q\n" , args [ 0 ] )
writeUsage ( stderr )
return 2
}
}
func runPipelinesList ( args [ ] string , stdout , stderr io . Writer , opts Options ) int {
fs := flag . NewFlagSet ( "pipelines list" , flag . ContinueOnError )
fs . SetOutput ( io . Discard )
configPath := fs . String ( "config" , "" , "config file path" )
jsonOutput := fs . Bool ( "json" , false , "write JSON output" )
if err := fs . Parse ( args ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 2
}
if fs . NArg ( ) != 0 {
fmt . Fprintf ( stderr , "notarius: unexpected argument %q\n" , fs . Arg ( 0 ) )
return 2
}
cfg , _ , err := loadConfig ( * configPath , opts )
if err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
if err := cfg . Validate ( ) ; err != nil {
fmt . Fprintf ( stderr , "notarius: %v\n" , err )
return 1
}
ids := sortedPipelineIDs ( cfg )
if * jsonOutput {
payload := struct {
Pipelines [ ] string ` json:"pipelines" `
} { Pipelines : ids }
encoded , err := json . Marshal ( payload )
if err != nil {
fmt . Fprintf ( stderr , "notarius: marshal pipeline list: %v\n" , err )
return 1
}
fmt . Fprintf ( stdout , "%s\n" , encoded )
return 0
}
for _ , id := range ids {
fmt . Fprintln ( stdout , id )
}
return 0
}
func loadConfig ( configPath string , opts Options ) ( config . Config , string , error ) {
path , err := discoverConfigPath ( configPath , opts )
if err != nil {
return config . Config { } , "" , err
}
fileCfg , err := config . LoadFileConfig ( path )
if err != nil {
return config . Config { } , "" , err
}
cfg := config . Default ( )
if err := cfg . ApplyFileConfigWithLookup ( fileCfg , opts . LookupEnv ) ; err != nil {
return config . Config { } , "" , err
}
if err := cfg . ApplyEnvOverridesWithLookup ( opts . LookupEnv ) ; err != nil {
return config . Config { } , "" , err
}
return cfg , path , nil
}
func discoverConfigPath ( configPath string , opts Options ) ( string , error ) {
if path := strings . TrimSpace ( configPath ) ; path != "" {
if err := requireConfigFile ( path ) ; err != nil {
return "" , err
}
return path , nil
}
if path , ok := opts . LookupEnv ( "NOTARIUS_CONFIG" ) ; ok && strings . TrimSpace ( path ) != "" {
path = strings . TrimSpace ( path )
if err := requireConfigFile ( path ) ; err != nil {
return "" , err
}
return path , nil
}
if _ , err := os . Stat ( defaultConfigPath ) ; err == nil {
return defaultConfigPath , nil
} else if err != nil && ! os . IsNotExist ( err ) {
return "" , fmt . Errorf ( "check default config %q: %w" , defaultConfigPath , err )
}
return "" , fmt . Errorf ( "config file not found; pass --config or set NOTARIUS_CONFIG" )
}
func requireConfigFile ( path string ) error {
info , err := os . Stat ( path )
if err != nil {
return fmt . Errorf ( "config file %q is not available: %w" , path , err )
}
if info . IsDir ( ) {
return fmt . Errorf ( "config file %q is a directory" , path )
}
return nil
}
2026-07-03 20:54:22 +00:00
func parseOnly ( raw string ) ( [ ] string , error ) {
2026-07-03 17:01:24 +00:00
if strings . TrimSpace ( raw ) == "" {
2026-07-03 20:54:22 +00:00
return nil , nil
2026-07-03 17:01:24 +00:00
}
parts := strings . Split ( raw , "," )
result := make ( [ ] string , 0 , len ( parts ) )
for _ , part := range parts {
2026-07-03 20:54:22 +00:00
trimmed := strings . TrimSpace ( part )
if trimmed == "" {
return nil , fmt . Errorf ( "--only must contain comma-separated non-empty artifact lane IDs" )
2026-07-03 17:01:24 +00:00
}
2026-07-03 20:54:22 +00:00
result = append ( result , trimmed )
2026-07-03 17:01:24 +00:00
}
2026-07-03 20:54:22 +00:00
return result , nil
2026-07-03 17:01:24 +00:00
}
2026-07-05 14:27:23 +00:00
type stringListFlag [ ] string
func ( flag * stringListFlag ) String ( ) string {
if flag == nil {
return ""
}
return strings . Join ( * flag , "," )
}
func ( flag * stringListFlag ) Set ( value string ) error {
* flag = append ( * flag , value )
return nil
}
2026-07-05 17:51:36 +00:00
type sessionIDFlag struct {
value string
set bool
}
2026-07-21 21:59:48 +00:00
type singleValueFlag struct {
2026-07-30 02:11:35 +00:00
name string
2026-07-21 21:59:48 +00:00
value string
set bool
}
func ( flag * singleValueFlag ) String ( ) string {
if flag == nil {
return ""
}
return flag . value
}
func ( flag * singleValueFlag ) Set ( value string ) error {
if flag . set {
2026-07-30 02:11:35 +00:00
return fmt . Errorf ( "%s may be specified only once" , flag . name )
2026-07-21 21:59:48 +00:00
}
flag . value = value
flag . set = true
return nil
}
2026-07-05 17:51:36 +00:00
func ( flag * sessionIDFlag ) String ( ) string {
if flag == nil {
return ""
}
return flag . value
}
func ( flag * sessionIDFlag ) Set ( value string ) error {
flag . value = value
flag . set = true
return nil
}
2026-07-05 14:27:23 +00:00
type cliReferenceRequest struct {
2026-07-05 16:31:23 +00:00
Selector cliReferenceSelector
2026-07-05 14:27:23 +00:00
Source string
}
type cliReferenceUnbindRequest struct {
2026-07-05 16:31:23 +00:00
Selector cliReferenceSelector
}
type cliReferenceSelector struct {
2026-08-29 12:34:31 +00:00
Scope cliReferenceSelectorScope
2026-07-05 14:27:23 +00:00
LaneID string
2026-07-05 16:31:23 +00:00
Stage pipeline . ModuleStage
2026-07-05 14:27:23 +00:00
SlotName string
}
2026-08-29 12:34:31 +00:00
type cliReferenceSelectorScope uint8
const (
cliReferenceScopePipeline cliReferenceSelectorScope = iota
cliReferenceScopeLane
cliReferenceScopeChunk
cliReferenceScopeBinding
)
2026-07-05 14:27:23 +00:00
func parseReferenceFlags ( values [ ] string ) ( [ ] cliReferenceRequest , error ) {
if len ( values ) == 0 {
return nil , nil
}
requests := make ( [ ] cliReferenceRequest , 0 , len ( values ) )
for _ , raw := range values {
name , source , ok := strings . Cut ( raw , "=" )
if ! ok {
2026-08-29 12:34:31 +00:00
return nil , fmt . Errorf ( "--reference must use slot=path, lane.slot=path, or lane.stage.slot=path" )
2026-07-05 14:27:23 +00:00
}
if strings . TrimSpace ( source ) == "" {
return nil , fmt . Errorf ( "--reference path must not be empty; use --without-reference to unbind" )
}
2026-07-05 16:31:23 +00:00
selector , err := parseReferenceSelector ( name , "--reference" )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , err
}
requests = append ( requests , cliReferenceRequest {
2026-07-05 16:31:23 +00:00
Selector : selector ,
2026-07-05 14:27:23 +00:00
Source : strings . TrimSpace ( source ) ,
} )
}
return requests , nil
}
func parseReferenceUnbindFlags ( values [ ] string ) ( [ ] cliReferenceUnbindRequest , error ) {
if len ( values ) == 0 {
return nil , nil
}
requests := make ( [ ] cliReferenceUnbindRequest , 0 , len ( values ) )
for _ , raw := range values {
if strings . Contains ( raw , "=" ) {
2026-07-05 16:31:23 +00:00
return nil , fmt . Errorf ( "--without-reference must use a reference selector without =path" )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
selector , err := parseReferenceSelector ( raw , "--without-reference" )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , err
}
requests = append ( requests , cliReferenceUnbindRequest {
2026-07-05 16:31:23 +00:00
Selector : selector ,
2026-07-05 14:27:23 +00:00
} )
}
return requests , nil
}
2026-07-05 16:31:23 +00:00
func parseReferenceSelector ( raw string , flagName string ) ( cliReferenceSelector , error ) {
2026-07-05 14:27:23 +00:00
selector := strings . TrimSpace ( raw )
if selector == "" {
2026-07-05 16:31:23 +00:00
return cliReferenceSelector { } , fmt . Errorf ( "%s reference slot must not be empty" , flagName )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
parts := strings . Split ( selector , "." )
for _ , part := range parts {
if strings . TrimSpace ( part ) == "" {
return cliReferenceSelector { } , fmt . Errorf ( "%s must use non-empty reference selector values" , flagName )
}
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
switch len ( parts ) {
case 1 :
2026-08-29 12:34:31 +00:00
return cliReferenceSelector { Scope : cliReferenceScopePipeline , SlotName : strings . TrimSpace ( parts [ 0 ] ) } , nil
2026-07-05 16:31:23 +00:00
case 2 :
first := strings . TrimSpace ( parts [ 0 ] )
slotName := strings . TrimSpace ( parts [ 1 ] )
if first == string ( pipeline . StageChunk ) {
2026-08-29 12:34:31 +00:00
return cliReferenceSelector { Scope : cliReferenceScopeChunk , Stage : pipeline . StageChunk , SlotName : slotName } , nil
2026-07-05 16:31:23 +00:00
}
2026-08-29 12:34:31 +00:00
return cliReferenceSelector { Scope : cliReferenceScopeLane , LaneID : first , SlotName : slotName } , nil
2026-07-05 16:31:23 +00:00
case 3 :
laneID := strings . TrimSpace ( parts [ 0 ] )
stage := pipeline . ModuleStage ( strings . TrimSpace ( parts [ 1 ] ) )
slotName := strings . TrimSpace ( parts [ 2 ] )
2026-07-07 19:10:55 +00:00
if stage != pipeline . StageExtract && stage != pipeline . StageMerge && stage != pipeline . StageNormalize {
return cliReferenceSelector { } , fmt . Errorf ( "%s lane-qualified selector must use lane.extract.slot, lane.merge.slot, or lane.normalize.slot" , flagName )
2026-07-05 14:27:23 +00:00
}
2026-08-29 12:34:31 +00:00
return cliReferenceSelector { Scope : cliReferenceScopeBinding , LaneID : laneID , Stage : stage , SlotName : slotName } , nil
2026-07-05 16:31:23 +00:00
default :
2026-08-29 12:34:31 +00:00
return cliReferenceSelector { } , fmt . Errorf ( "%s must use slot, chunk.slot, lane.slot, lane.extract.slot, lane.merge.slot, or lane.normalize.slot" , flagName )
2026-07-05 14:27:23 +00:00
}
}
func resolveCLIReferenceRequests (
cfg config . Config ,
pipelineID string ,
only [ ] string ,
catalog pipeline . ModuleCatalog ,
referenceRequests [ ] cliReferenceRequest ,
unbindRequests [ ] cliReferenceUnbindRequest ,
) ( [ ] pipeline . ReferenceBinding , [ ] pipeline . ReferenceUnbind , error ) {
if len ( referenceRequests ) == 0 && len ( unbindRequests ) == 0 {
return nil , nil , nil
}
2026-07-05 16:31:23 +00:00
targets , err := selectedReferenceTargets ( cfg , pipelineID , only , catalog )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , nil , err
}
2026-08-29 12:34:31 +00:00
// Broad CLI selectors are only presentation syntax. Collapse them into one
// highest-specificity action per concrete framework target before pipeline
// resolution so the generic reference contract stays stage-and-lane exact.
actions := make ( map [ cliReferenceTargetKey ] resolvedCLIReferenceAction )
2026-07-05 14:27:23 +00:00
for _ , request := range referenceRequests {
2026-08-29 12:34:31 +00:00
matches , err := resolveCLIReferenceTargets ( targets , request . Selector )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , nil , err
}
2026-08-29 12:34:31 +00:00
for _ , target := range matches {
candidate := resolvedCLIReferenceAction {
kind : cliReferenceActionBind ,
selector : request . Selector ,
target : target ,
slotName : request . Selector . SlotName ,
source : request . Source ,
specificity : request . Selector . specificity ( ) ,
}
if err := mergeCLIReferenceAction ( actions , candidate ) ; err != nil {
return nil , nil , err
}
}
2026-07-05 14:27:23 +00:00
}
for _ , request := range unbindRequests {
2026-08-29 12:34:31 +00:00
matches , err := resolveCLIReferenceTargets ( targets , request . Selector )
2026-07-05 14:27:23 +00:00
if err != nil {
return nil , nil , err
}
2026-08-29 12:34:31 +00:00
for _ , target := range matches {
candidate := resolvedCLIReferenceAction {
kind : cliReferenceActionUnbind ,
selector : request . Selector ,
target : target ,
slotName : request . Selector . SlotName ,
specificity : request . Selector . specificity ( ) ,
}
if err := mergeCLIReferenceAction ( actions , candidate ) ; err != nil {
return nil , nil , err
}
}
}
resolved := make ( [ ] resolvedCLIReferenceAction , 0 , len ( actions ) )
for _ , action := range actions {
resolved = append ( resolved , action )
2026-07-05 14:27:23 +00:00
}
2026-08-29 12:34:31 +00:00
sort . Slice ( resolved , func ( i , j int ) bool {
left , right := resolved [ i ] , resolved [ j ]
if left . target . laneID != right . target . laneID {
return left . target . laneID < right . target . laneID
}
if left . target . stage != right . target . stage {
return referenceStageOrder ( left . target . stage ) < referenceStageOrder ( right . target . stage )
}
return left . slotName < right . slotName
} )
2026-07-05 14:27:23 +00:00
2026-08-29 12:34:31 +00:00
overrides := make ( [ ] pipeline . ReferenceBinding , 0 , len ( resolved ) )
unbinds := make ( [ ] pipeline . ReferenceUnbind , 0 , len ( resolved ) )
for _ , action := range resolved {
switch action . kind {
case cliReferenceActionBind :
overrides = append ( overrides , pipeline . ReferenceBinding {
Stage : action . target . stage ,
LaneID : action . target . laneID ,
SlotName : action . slotName ,
Source : action . source ,
BindingSource : contracts . ReferenceBindingSourceCLI ,
} )
case cliReferenceActionUnbind :
unbinds = append ( unbinds , pipeline . ReferenceUnbind {
Stage : action . target . stage ,
LaneID : action . target . laneID ,
SlotName : action . slotName ,
} )
}
}
2026-07-05 14:27:23 +00:00
return overrides , unbinds , nil
}
2026-08-29 12:34:31 +00:00
type cliReferenceActionKind uint8
const (
cliReferenceActionBind cliReferenceActionKind = iota
cliReferenceActionUnbind
)
type cliReferenceTargetKey struct {
stage pipeline . ModuleStage
laneID string
slotName string
}
type resolvedCLIReferenceAction struct {
kind cliReferenceActionKind
selector cliReferenceSelector
target selectedReferenceTarget
slotName string
source string
specificity int
}
func mergeCLIReferenceAction ( actions map [ cliReferenceTargetKey ] resolvedCLIReferenceAction , candidate resolvedCLIReferenceAction ) error {
key := cliReferenceTargetKey { stage : candidate . target . stage , laneID : candidate . target . laneID , slotName : candidate . slotName }
current , ok := actions [ key ]
if ! ok || candidate . specificity > current . specificity {
actions [ key ] = candidate
return nil
}
if candidate . specificity < current . specificity {
return nil
}
if candidate . kind != current . kind {
return fmt . Errorf ( "reference target %q slot %q is both bound by %q and unbound by %q at the same specificity" , targetLabel ( candidate . target ) , candidate . slotName , current . selector . String ( ) , candidate . selector . String ( ) )
}
actions [ key ] = candidate
return nil
}
func ( selector cliReferenceSelector ) specificity ( ) int {
switch selector . Scope {
case cliReferenceScopePipeline :
return 0
case cliReferenceScopeLane :
return 1
case cliReferenceScopeChunk , cliReferenceScopeBinding :
return 2
default :
return - 1
}
}
func ( selector cliReferenceSelector ) String ( ) string {
switch selector . Scope {
case cliReferenceScopePipeline :
return selector . SlotName
case cliReferenceScopeLane :
return selector . LaneID + "." + selector . SlotName
case cliReferenceScopeChunk :
return "chunk." + selector . SlotName
case cliReferenceScopeBinding :
return selector . LaneID + "." + string ( selector . Stage ) + "." + selector . SlotName
default :
return selector . SlotName
}
}
2026-07-05 16:31:23 +00:00
type selectedReferenceTarget struct {
laneID string
stage pipeline . ModuleStage
module string
slots map [ string ] struct { }
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
func selectedReferenceTargets ( cfg config . Config , pipelineID string , only [ ] string , catalog pipeline . ModuleCatalog ) ( [ ] selectedReferenceTarget , error ) {
2026-07-05 14:27:23 +00:00
profile , ok := lookupCLIReferencePipeline ( cfg . Pipelines , pipelineID )
if ! ok {
return nil , fmt . Errorf ( "pipeline %q is not configured" , strings . TrimSpace ( pipelineID ) )
}
2026-07-21 21:05:28 +00:00
if profile . Steps != nil && len ( only ) > 0 {
return nil , fmt . Errorf ( "pipeline %q --only is not supported for explicit ordered steps" , strings . TrimSpace ( pipelineID ) )
}
2026-07-05 14:27:23 +00:00
lanesByID := make ( map [ string ] pipeline . ArtifactLaneProfile , len ( profile . Artifacts ) )
2026-07-21 21:05:28 +00:00
addLanes := func ( values map [ string ] pipeline . ArtifactLaneProfile ) error {
for rawLaneID , lane := range values {
laneID := strings . TrimSpace ( rawLaneID )
if laneID == "" {
return fmt . Errorf ( "pipeline %q artifact lane id must not be empty" , strings . TrimSpace ( pipelineID ) )
}
if _ , ok := lanesByID [ laneID ] ; ok {
return fmt . Errorf ( "pipeline %q artifact lane %q is duplicated after trimming" , strings . TrimSpace ( pipelineID ) , laneID )
}
lanesByID [ laneID ] = lane
2026-07-05 14:27:23 +00:00
}
2026-07-21 21:05:28 +00:00
return nil
}
if profile . Steps == nil {
if err := addLanes ( profile . Artifacts ) ; err != nil {
return nil , err
}
} else {
for _ , step := range profile . Steps {
if err := addLanes ( step . Artifacts ) ; err != nil {
return nil , err
}
2026-07-05 14:27:23 +00:00
}
}
selectedIDs := make ( [ ] string , 0 , len ( lanesByID ) )
if len ( only ) == 0 {
for laneID := range lanesByID {
selectedIDs = append ( selectedIDs , laneID )
}
} else {
seen := make ( map [ string ] struct { } , len ( only ) )
for _ , rawLaneID := range only {
laneID := strings . TrimSpace ( rawLaneID )
if laneID == "" {
return nil , fmt . Errorf ( "pipeline %q selected artifact lane id must not be empty" , strings . TrimSpace ( pipelineID ) )
}
if _ , ok := lanesByID [ laneID ] ; ! ok {
return nil , fmt . Errorf ( "pipeline %q selected artifact lane %q is not declared" , strings . TrimSpace ( pipelineID ) , laneID )
}
if _ , ok := seen [ laneID ] ; ! ok {
selectedIDs = append ( selectedIDs , laneID )
seen [ laneID ] = struct { } { }
}
}
}
sort . Strings ( selectedIDs )
2026-07-07 19:10:55 +00:00
targets := make ( [ ] selectedReferenceTarget , 0 , 1 + len ( selectedIDs ) * 3 )
2026-07-05 16:31:23 +00:00
chunk := pipeline . Binding ( profile . Chunk . Module )
chunk . Module = strings . TrimSpace ( profile . Chunk . Module )
if chunk . Module == "" {
chunk . Module = pipeline . DefaultChunkModule
}
chunkSpec , err := cliReferenceChunkerSpec ( catalog , chunk . Module )
if err != nil {
return nil , fmt . Errorf ( "pipeline %q chunk module %q: %w" , strings . TrimSpace ( pipelineID ) , chunk . Module , err )
}
targets = append ( targets , selectedReferenceTarget {
stage : pipeline . StageChunk ,
module : chunk . Module ,
slots : referenceSlotSet ( chunkSpec . ReferenceSlots ) ,
} )
2026-07-05 14:27:23 +00:00
for _ , laneID := range selectedIDs {
lane := lanesByID [ laneID ]
extractModule := strings . TrimSpace ( lane . Extract . Module )
if extractModule == "" {
return nil , fmt . Errorf ( "pipeline %q lane %q extract module must not be empty" , strings . TrimSpace ( pipelineID ) , laneID )
}
2026-07-05 16:31:23 +00:00
extractSpec , err := cliReferenceExtractorSpec ( catalog , extractModule )
if err != nil {
return nil , fmt . Errorf ( "pipeline %q lane %q extract module %q: %w" , strings . TrimSpace ( pipelineID ) , laneID , extractModule , err )
2026-07-05 14:27:23 +00:00
}
2026-07-17 13:53:55 +00:00
artifactKind := extractSpec . ArtifactKind
if artifactKind == "" {
return nil , fmt . Errorf ( "pipeline %q lane %q extract module %q does not declare an artifact kind" , strings . TrimSpace ( pipelineID ) , laneID , extractModule )
}
2026-07-05 16:31:23 +00:00
targets = append ( targets , selectedReferenceTarget {
laneID : laneID ,
stage : pipeline . StageExtract ,
module : extractModule ,
slots : referenceSlotSet ( extractSpec . ReferenceSlots ) ,
} )
2026-07-07 19:10:55 +00:00
mergeModule := strings . TrimSpace ( lane . Merge . Module )
if mergeModule == "" {
mergeModule = pipeline . DefaultMergeModule
}
2026-07-17 13:53:55 +00:00
mergeSpec , err := cliReferenceMergerSpec ( catalog , mergeModule , artifactKind )
2026-07-07 19:10:55 +00:00
if err != nil {
return nil , fmt . Errorf ( "pipeline %q lane %q merge module %q: %w" , strings . TrimSpace ( pipelineID ) , laneID , mergeModule , err )
}
targets = append ( targets , selectedReferenceTarget {
laneID : laneID ,
stage : pipeline . StageMerge ,
module : mergeModule ,
slots : referenceSlotSet ( mergeSpec . ReferenceSlots ) ,
} )
2026-07-05 16:31:23 +00:00
normalizeModule := strings . TrimSpace ( lane . Normalize . Module )
if normalizeModule == "" {
normalizeModule = pipeline . DefaultNormalizeModule
2026-07-05 14:27:23 +00:00
}
2026-07-17 13:53:55 +00:00
normalizeSpec , err := cliReferenceNormalizerSpec ( catalog , normalizeModule , artifactKind )
2026-07-05 16:31:23 +00:00
if err != nil {
return nil , fmt . Errorf ( "pipeline %q lane %q normalize module %q: %w" , strings . TrimSpace ( pipelineID ) , laneID , normalizeModule , err )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
targets = append ( targets , selectedReferenceTarget {
laneID : laneID ,
stage : pipeline . StageNormalize ,
module : normalizeModule ,
slots : referenceSlotSet ( normalizeSpec . ReferenceSlots ) ,
} )
2026-07-05 14:27:23 +00:00
}
2026-07-05 16:31:23 +00:00
return targets , nil
2026-07-05 14:27:23 +00:00
}
func lookupCLIReferencePipeline ( profiles map [ string ] pipeline . PipelineProfile , pipelineID string ) ( pipeline . PipelineProfile , bool ) {
pipelineID = strings . TrimSpace ( pipelineID )
for rawID , profile := range profiles {
if strings . TrimSpace ( rawID ) == pipelineID {
return profile , true
}
}
return pipeline . PipelineProfile { } , false
}
2026-07-05 16:31:23 +00:00
func cliReferenceChunkerSpec ( catalog pipeline . ModuleCatalog , module string ) ( pipeline . ModuleSpec , error ) {
if catalog . Chunkers == nil {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
spec , ok := catalog . Chunkers . Spec ( module )
if ! ok {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
return spec , nil
}
func cliReferenceExtractorSpec ( catalog pipeline . ModuleCatalog , module string ) ( pipeline . ModuleSpec , error ) {
if catalog . Extractors == nil {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
spec , ok := catalog . Extractors . Spec ( module )
if ! ok {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
return spec , nil
}
2026-07-17 13:53:55 +00:00
func cliReferenceMergerSpec ( catalog pipeline . ModuleCatalog , module string , kind contracts . ArtifactKind ) ( pipeline . ModuleSpec , error ) {
2026-07-07 19:10:55 +00:00
if catalog . Mergers == nil {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
2026-07-17 13:53:55 +00:00
registered := catalog . Mergers . RegisteredArtifactKinds ( module )
if len ( registered ) == 0 {
2026-07-07 19:10:55 +00:00
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
2026-07-17 13:53:55 +00:00
spec , ok := catalog . Mergers . SpecForArtifact ( module , kind )
if ! ok {
return pipeline . ModuleSpec { } , cliReferenceArtifactVariantError ( "merger" , module , kind , registered )
}
2026-07-07 19:10:55 +00:00
return spec , nil
}
2026-07-17 13:53:55 +00:00
func cliReferenceNormalizerSpec ( catalog pipeline . ModuleCatalog , module string , kind contracts . ArtifactKind ) ( pipeline . ModuleSpec , error ) {
2026-07-05 16:31:23 +00:00
if catalog . Normalizers == nil {
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
2026-07-17 13:53:55 +00:00
registered := catalog . Normalizers . RegisteredArtifactKinds ( module )
if len ( registered ) == 0 {
2026-07-05 16:31:23 +00:00
return pipeline . ModuleSpec { } , fmt . Errorf ( "module %q is not registered" , module )
}
2026-07-17 13:53:55 +00:00
spec , ok := catalog . Normalizers . SpecForArtifact ( module , kind )
if ! ok {
return pipeline . ModuleSpec { } , cliReferenceArtifactVariantError ( "normalizer" , module , kind , registered )
}
2026-07-05 16:31:23 +00:00
return spec , nil
}
2026-07-17 13:53:55 +00:00
func cliReferenceArtifactVariantError ( moduleType string , module string , kind contracts . ArtifactKind , registered [ ] contracts . ArtifactKind ) error {
values := make ( [ ] string , len ( registered ) )
for i , value := range registered {
values [ i ] = string ( value )
}
if len ( values ) == 0 {
return fmt . Errorf ( "%s %q has no typed variant for artifact kind %q" , moduleType , module , kind )
}
return fmt . Errorf ( "%s %q has no typed variant for artifact kind %q; registered kinds: %s" , moduleType , module , kind , strings . Join ( values , ", " ) )
}
2026-07-05 16:31:23 +00:00
func referenceSlotSet ( slots [ ] contracts . ReferenceSlot ) map [ string ] struct { } {
slotSet := make ( map [ string ] struct { } , len ( slots ) )
for _ , slot := range slots {
slotSet [ slot . Name ] = struct { } { }
}
return slotSet
}
2026-08-29 12:34:31 +00:00
func resolveCLIReferenceTargets ( targets [ ] selectedReferenceTarget , selector cliReferenceSelector ) ( [ ] selectedReferenceTarget , error ) {
2026-07-05 16:31:23 +00:00
slotName := strings . TrimSpace ( selector . SlotName )
if slotName == "" {
2026-08-29 12:34:31 +00:00
return nil , fmt . Errorf ( "reference slot must not be empty" )
2026-07-05 16:31:23 +00:00
}
2026-08-29 12:34:31 +00:00
switch selector . Scope {
case cliReferenceScopePipeline :
matches := make ( [ ] selectedReferenceTarget , 0 , len ( targets ) )
for _ , target := range targets {
if _ , ok := target . slots [ slotName ] ; ok {
matches = append ( matches , target )
}
}
if len ( matches ) == 0 {
return nil , fmt . Errorf ( "reference slot %q is not declared by any selected target" , slotName )
}
return matches , nil
case cliReferenceScopeChunk :
2026-07-05 16:31:23 +00:00
for _ , target := range targets {
if target . stage != pipeline . StageChunk {
continue
}
if _ , ok := target . slots [ slotName ] ; ! ok {
2026-08-29 12:34:31 +00:00
return nil , fmt . Errorf ( "reference slot %q is not declared by chunk module %q" , slotName , target . module )
2026-07-05 16:31:23 +00:00
}
2026-08-29 12:34:31 +00:00
return [ ] selectedReferenceTarget { target } , nil
2026-07-07 19:10:55 +00:00
}
2026-08-29 12:34:31 +00:00
return nil , fmt . Errorf ( "reference chunk target is not selected" )
case cliReferenceScopeLane :
laneSelected := false
matches := make ( [ ] selectedReferenceTarget , 0 , 3 )
2026-07-05 16:31:23 +00:00
for _ , target := range targets {
2026-08-29 12:34:31 +00:00
if target . laneID != selector . LaneID {
continue
}
laneSelected = true
if _ , ok := target . slots [ slotName ] ; ok {
matches = append ( matches , target )
2026-07-05 14:27:23 +00:00
}
}
2026-08-29 12:34:31 +00:00
if ! laneSelected {
return nil , fmt . Errorf ( "reference lane %q is not selected" , selector . LaneID )
2026-07-07 19:10:55 +00:00
}
2026-08-29 12:34:31 +00:00
if len ( matches ) == 0 {
return nil , fmt . Errorf ( "reference slot %q is not declared by selected lane %q" , slotName , selector . LaneID )
2026-07-05 16:31:23 +00:00
}
2026-08-29 12:34:31 +00:00
return matches , nil
case cliReferenceScopeBinding :
laneSelected := false
for _ , target := range targets {
if target . laneID != selector . LaneID {
continue
}
laneSelected = true
if target . stage != selector . Stage {
continue
}
if _ , ok := target . slots [ slotName ] ; ! ok {
return nil , fmt . Errorf ( "reference slot %q is not declared by selected %s target %q" , slotName , selector . Stage , targetLabel ( target ) )
}
return [ ] selectedReferenceTarget { target } , nil
2026-07-05 16:31:23 +00:00
}
2026-08-29 12:34:31 +00:00
if ! laneSelected {
return nil , fmt . Errorf ( "reference lane %q is not selected" , selector . LaneID )
2026-07-05 14:27:23 +00:00
}
2026-08-29 12:34:31 +00:00
return nil , fmt . Errorf ( "reference %s target is not selected for lane %q" , selector . Stage , selector . LaneID )
2026-07-05 14:27:23 +00:00
default :
2026-08-29 12:34:31 +00:00
return nil , fmt . Errorf ( "reference selector has unknown scope" )
2026-07-05 16:31:23 +00:00
}
}
func targetLabel ( target selectedReferenceTarget ) string {
if target . stage == pipeline . StageChunk {
return "chunk"
}
return target . laneID + "." + string ( target . stage )
}
2026-08-29 12:34:31 +00:00
func referenceStageOrder ( stage pipeline . ModuleStage ) int {
switch stage {
case pipeline . StageChunk :
return 0
case pipeline . StageExtract :
return 1
case pipeline . StageMerge :
return 2
case pipeline . StageNormalize :
return 3
default :
return 4
2026-07-05 14:27:23 +00:00
}
}
2026-07-03 17:01:24 +00:00
func sortedPipelineIDs ( cfg config . Config ) [ ] string {
ids := make ( [ ] string , 0 , len ( cfg . Pipelines ) )
for id := range cfg . Pipelines {
ids = append ( ids , strings . TrimSpace ( id ) )
}
sort . Strings ( ids )
return ids
}