Prepare pipelines before source execution

This commit is contained in:
2026-07-17 06:18:46 +00:00
parent 1c84d19e5f
commit ce3a07512f
26 changed files with 1562 additions and 404 deletions

View File

@@ -33,7 +33,9 @@ without exposing Scriptorium types through stage contracts.
2. creating a `ScriptoriumClient` from the effective profile source; 2. creating a `ScriptoriumClient` from the effective profile source;
3. attaching an `LLMProfileRecorder`; 3. attaching an `LLMProfileRecorder`;
4. creating a scheduler from the effective concurrency limit; 4. creating a scheduler from the effective concurrency limit;
5. returning a `ScheduledClient` wrapper. 5. returning a `ScheduledClient` wrapper; and
6. injecting that one shared client into complete pipeline preparation before
the source file is read or the runner is invoked.
The CLI separately gathers explicit profile IDs from resolved LLM-capable stage The CLI separately gathers explicit profile IDs from resolved LLM-capable stage
and validator bindings. It prepares a small internal check prompt for each ID so and validator bindings. It prepares a small internal check prompt for each ID so

View File

@@ -19,10 +19,15 @@ validator chains and prompt asset collection.
Production extract, merge, normalize, and validator packages currently use the Production extract, merge, normalize, and validator packages currently use the
explicit legacy raw registration APIs. Typed registration is framework-ready, explicit legacy raw registration APIs. Typed registration is framework-ready,
but no production artifact kind or codec is registered yet. but no production artifact kind or codec is registered yet. All selected
production implementations are constructed during pipeline preparation through
temporary adapters around their existing zero-argument constructors. Their raw
option maps and LLM clients remain operation inputs until each implementation
migrates to its construction-owned decoder and injected dependencies.
Specs expose capability and execution metadata without constructing an Specs expose capability and execution metadata without constructing an
implementation. Chunk, extract, merge, and normalize modules that accept implementation. Registry entries separately expose option validation and
run-local construction. Chunk, extract, merge, and normalize modules that accept
auxiliary material declare identical reference slots from both auxiliary material declare identical reference slots from both
`ReferenceSlots()` and `ModuleSpec().ReferenceSlots`; registration tests enforce `ReferenceSlots()` and `ModuleSpec().ReferenceSlots`; registration tests enforce
that agreement. Runtime delivery uses the corresponding stage request's that agreement. Runtime delivery uses the corresponding stage request's

View File

@@ -15,7 +15,8 @@ output files returned by the runner. Diagnostics, checkpoints, and debug
recorders are optional side-channel collaborators supplied at this boundary. recorders are optional side-channel collaborators supplied at this boundary.
Pipeline execution is serial. Resolution produces a fixed ordered workflow and Pipeline execution is serial. Resolution produces a fixed ordered workflow and
a sorted set of artifact lanes before the runner constructs any stage module. a sorted set of artifact lanes. Preparation constructs the complete module and
validator set before the runner receives source bytes.
## Application Boundary ## Application Boundary
@@ -39,7 +40,7 @@ a sorted set of artifact lanes before the runner constructs any stage module.
| Package | Implemented responsibility | | Package | Implemented responsibility |
| --- | --- | | --- | --- |
| `internal/framework/contracts` | Source-stage contracts plus artifact identity, schema, serialized representation, codec, validator, reference, output, and structured-completion interfaces and data types. | | `internal/framework/contracts` | Source-stage contracts plus artifact identity, schema, serialized representation, codec, validator, reference, output, and structured-completion interfaces and data types. |
| `internal/framework/pipeline` | Module and artifact-codec registries, profile resolution, capability checks, reference materialization, validator-chain resolution, retries, orchestration, warnings, and manifest population. | | `internal/framework/pipeline` | Module and artifact-codec registries, option validation, profile resolution, capability checks, reference materialization, complete pipeline preparation, retries, orchestration, warnings, and manifest population. |
| `internal/framework/validate` | Shared validator decision and cardinality helpers. | | `internal/framework/validate` | Shared validator decision and cardinality helpers. |
| `internal/framework/llm` | Scriptorium-backed structured completions, prompt/schema registration, scheduling, profile recording, and secret redaction. | | `internal/framework/llm` | Scriptorium-backed structured completions, prompt/schema registration, scheduling, profile recording, and secret redaction. |
| `internal/framework/promptfs` | Builds module prompt filesystems from module-owned and caller-provided shared prompt assets. | | `internal/framework/promptfs` | Builds module prompt filesystems from module-owned and caller-provided shared prompt assets. |
@@ -56,9 +57,13 @@ Artifact registries support heterogeneous typed extraction entries and
kind-specific merger, normalizer, and validator variants. Resolution derives a kind-specific merger, normalizer, and validator variants. Resolution derives a
lane's kind from its extractor, requires the matching codec, verifies exact Go lane's kind from its extractor, requires the matching codec, verifies exact Go
type equality across the lane, and records schema identity in the resolved lane type equality across the lane, and records schema identity in the resolved lane
and pipeline digest. Production module families do not register typed variants and pipeline digest. Registry entries carry separate option-validation and
yet and continue through explicitly named legacy raw registrations. The current run-local construction closures. Preparation injects shared dependencies and
runner rejects a typed resolved lane instead of routing it through raw execution. constructs input, chunk, validators, ordered lanes, and output before source
parsing. Production module families do not register typed variants yet and
continue through explicitly named legacy raw registrations and temporary
zero-argument constructor adapters. The current runner rejects a typed prepared
lane instead of routing it through raw execution.
## Production Extensions ## Production Extensions

View File

@@ -7,7 +7,8 @@ defaults, and selectable keys are defined in
[Configuration](../config.md#pipelines). [Configuration](../config.md#pipelines).
Pipeline execution is serial. Resolution fixes the selected lanes and all Pipeline execution is serial. Resolution fixes the selected lanes and all
stage bindings before the runner constructs stage implementations. stage bindings; preparation constructs every selected implementation before the
runner begins source work.
## Resolution ## Resolution
@@ -24,7 +25,9 @@ calls `pipeline.ResolvePipeline`.
selects exact-type merger, normalizer, and validator variants; selects exact-type merger, normalizer, and validator variants;
5. checks required and provided capabilities in workflow order; 5. checks required and provided capabilities in workflow order;
6. resolves target-aware reference bindings and validator chains; 6. resolves target-aware reference bindings and validator chains;
7. calculates a digest over the resolved structure, including typed artifact 7. validates each selected module and validator option set through its registry
entry; and
8. calculates a digest over the resolved structure, including typed artifact
kind and schema identity. kind and schema identity.
Resolution returns a `ResolvedPipeline` containing ordered lanes, concrete Resolution returns a `ResolvedPipeline` containing ordered lanes, concrete
@@ -53,7 +56,8 @@ runtime sensitive-data handling belongs in [Operations](../operations.md).
## Registries And Specs ## Registries And Specs
`pipeline.Registries` holds constructors used during execution. `pipeline.Registries` holds option validators and run-local builders used during
resolution and preparation.
`pipeline.ModuleCatalog` exposes their specs during configuration validation and `pipeline.ModuleCatalog` exposes their specs during configuration validation and
resolution. Separate registries exist for every stage and for validators; resolution. Separate registries exist for every stage and for validators;
`ValidatorChainRegistry` stores production default-chain mappings. Both `ValidatorChainRegistry` stores production default-chain mappings. Both
@@ -77,24 +81,37 @@ A `ModuleSpec` declares its stage plus required and provided capabilities.
Chunk, extract, merge, and normalize specs may also declare reference slots. Chunk, extract, merge, and normalize specs may also declare reference slots.
Registry implementations defensively copy spec metadata, reject duplicate keys, Registry implementations defensively copy spec metadata, reject duplicate keys,
and verify that a constructed implementation reports the registered key. and verify that a constructed implementation reports the registered key.
Builder registrations accept `ModuleDependencies` and cloned raw options through
one `BuildRequest`. Existing production registrations are adapted from their
zero-argument constructors while their implementation-owned option decoders are
migrated separately.
A `ValidatorSpec` declares a validator key and execution class. Resolution uses A `ValidatorSpec` declares a validator key and execution class. Resolution uses
the execution class to reject incompatible profile bindings before execution. the execution class to reject incompatible profile bindings before execution.
The current production catalog and default chain are listed only in The current production catalog and default chain are listed only in
[Configuration](../config.md#implemented-production-validators). [Configuration](../config.md#implemented-production-validators).
## Runner Boundary ## Preparation And Runner Boundary
`pipeline.RunInput` carries the resolved pipeline, raw source input, structured `pipeline.Prepare` receives a resolved pipeline, the registries, and shared
LLM client, run identity and timing, optional session and profile metadata, and module dependencies. It constructs input; chunk and its validators; each lane's
checkpoint/debug collaborators. The runner parses source bytes through the extract, merge, and normalize modules and validator chains in resolved order;
selected input adapter. Later stage requests receive the generic source model; then output. It stops at the first error with pipeline, stage, lane, module, and
extract requests receive chunk-scoped input material, while chunk, merge, and validator context as applicable. It never invokes an operation method.
normalize requests retain access to the original source material.
Typed lanes can be composed and resolved but are not passed to the current raw `PreparedPipeline` keeps private constructed executors and exposes cloned
runner. The runner rejects such input before source work; production lanes are resolved input, chunk, lane, and output identities. `pipeline.RunInput` carries
still resolved and executed exclusively through the legacy raw path. that prepared pipeline, raw source input, run identity and timing, optional
session and profile metadata, and checkpoint/debug collaborators. The runner
parses source bytes through the already constructed input adapter. Later stage
requests receive the generic source model; extract requests receive
chunk-scoped input material, while chunk, merge, and normalize requests retain
access to the original source material.
Typed lanes can be composed, resolved, and prepared but are not executed by the
current raw runner. The runner rejects such input before source work;
production lanes are still resolved and executed exclusively through the
legacy raw path.
Source validation requires every unit to carry a canonical self-reference to Source validation requires every unit to carry a canonical self-reference to
its containing document and its own unit ID. Explicit clone, checkpoint, and its containing document and its own unit ID. Explicit clone, checkpoint, and
@@ -111,17 +128,17 @@ runner returns.
The runner: The runner:
1. validates its input and registries; 1. validates its prepared input;
2. builds the input adapter, parses the raw input, and validates the generic 2. parses the raw input with the prepared adapter and validates the generic
source document; source document;
3. obtains or executes the chunk result; 3. obtains or executes the chunk result;
4. validates and canonicalizes chunks; 4. validates and canonicalizes chunks;
5. executes each resolved artifact lane in order; 5. executes each resolved artifact lane in order;
6. builds the output encoder and validates its logical file results; 6. invokes the prepared output encoder and validates its logical file results;
7. returns the assembled manifest, outcomes, warnings, and files. 7. returns the assembled manifest, outcomes, warnings, and files.
Within each artifact lane, it builds the extractor, merger, and normalizer, Within each artifact lane, it reuses the prepared extractor, merger, normalizer,
then performs these transitions: and validators while performing these transitions:
1. extract once per accepted chunk and add runner-owned lane, source, and chunk 1. extract once per accepted chunk and add runner-owned lane, source, and chunk
provenance; provenance;
@@ -208,8 +225,10 @@ durable manifest and logical file schemas are defined in the
- `internal/framework/pipeline/artifact_codec_registry_test.go`: typed codec - `internal/framework/pipeline/artifact_codec_registry_test.go`: typed codec
metadata, registration, erasure safety, strict decoding, and cloning. metadata, registration, erasure safety, strict decoding, and cloning.
- `internal/framework/pipeline/typed_resolution_test.go`: heterogeneous typed - `internal/framework/pipeline/typed_resolution_test.go`: heterogeneous typed
lane resolution, target-specific validators, incompatibilities, ordering, and lane resolution and preparation, target-specific validators,
schema-sensitive pipeline identity. incompatibilities, ordering, and schema-sensitive pipeline identity.
- `internal/framework/pipeline/preparation_test.go`: option validation,
construction order, dependency failures, and the before-source-work boundary.
- `internal/framework/pipeline/references_test.go`: target resolution and - `internal/framework/pipeline/references_test.go`: target resolution and
materialization. materialization.
- `internal/framework/pipeline/runner_test.go`: stage transitions, retries, - `internal/framework/pipeline/runner_test.go`: stage transitions, retries,

View File

@@ -90,6 +90,12 @@ and verifies module availability and capabilities before execution. Structural
pipeline choices must not be scattered through conditionals or hidden behind pipeline choices must not be scattered through conditionals or hidden behind
ad hoc command flags. ad hoc command flags.
Resolution validates every selected module and validator option set. A separate
preparation boundary then constructs the complete input, chunk, lane,
validation, and output implementation set in pipeline order. The runner accepts
only that prepared set, so construction and dependency failures occur before
source parsing or any other module operation.
Stage ownership is explicit: Stage ownership is explicit:
- input modules convert external material into the generic source model; - input modules convert external material into the generic source model;

View File

@@ -263,11 +263,6 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved references: %w", err)) return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved references: %w", err))
} }
rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath))
if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("read input %q: %w", strings.TrimSpace(*inputPath), err))
}
registries, err := effectiveRegistries(opts) registries, err := effectiveRegistries(opts)
if err != nil { if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err) return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
@@ -282,16 +277,23 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
if err != nil { if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("create LLM client for profile %q: %w", factoryProfileID, err)) return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("create LLM client for profile %q: %w", factoryProfileID, err))
} }
prepared, err := pipeline.Prepare(effective.ResolvedPipeline, registries, pipeline.ModuleDependencies{LLM: llmClient})
if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("prepare pipeline %q: %w", pipelineID, err))
}
rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath))
if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("read input %q: %w", strings.TrimSpace(*inputPath), err))
}
checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(workspaceSettings, effective.ResolvedPipeline, rawInput, only, llmProfiles, strings.TrimSpace(*llmProfile), strings.TrimSpace(sessionID.value), *resume) checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(workspaceSettings, effective.ResolvedPipeline, rawInput, only, llmProfiles, strings.TrimSpace(*llmProfile), strings.TrimSpace(sessionID.value), *resume)
if err != nil { if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err) return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
} }
output, err := pipeline.New(registries).Run(ctx, pipeline.RunInput{ output, err := pipeline.New().Run(ctx, pipeline.RunInput{
Pipeline: effective.ResolvedPipeline, Prepared: prepared,
Path: strings.TrimSpace(*inputPath), Path: strings.TrimSpace(*inputPath),
RawInput: rawInput, RawInput: rawInput,
LLMClient: llmClient,
SessionID: strings.TrimSpace(sessionID.value), SessionID: strings.TrimSpace(sessionID.value),
RunID: runID, RunID: runID,
StartedAt: startedAt, StartedAt: startedAt,

View File

@@ -3989,42 +3989,42 @@ func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.Module
func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) { func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) {
t.Helper() t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil { if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return fakeRunInputAdapter{}, nil }); err != nil {
t.Fatalf("register input: %v", err) t.Fatalf("register input: %v", err)
} }
} }
func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) { func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) {
t.Helper() t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil { if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return fakeRunChunker{}, nil }); err != nil {
t.Fatalf("register chunker: %v", err) t.Fatalf("register chunker: %v", err)
} }
} }
func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) { func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) {
t.Helper() t.Helper()
if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawExtractor, error) { return nil, nil }); err != nil { if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawExtractor, error) { return fakeRunExtractor{}, nil }); err != nil {
t.Fatalf("register extractor: %v", err) t.Fatalf("register extractor: %v", err)
} }
} }
func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) { func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) {
t.Helper() t.Helper()
if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawMerger, error) { return nil, nil }); err != nil { if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawMerger, error) { return fakeRunMerger{}, nil }); err != nil {
t.Fatalf("register merger: %v", err) t.Fatalf("register merger: %v", err)
} }
} }
func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) { func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) {
t.Helper() t.Helper()
if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawNormalizer, error) { return nil, nil }); err != nil { if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawNormalizer, error) { return fakeRunNormalizer{}, nil }); err != nil {
t.Fatalf("register normalizer: %v", err) t.Fatalf("register normalizer: %v", err)
} }
} }
func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) { func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) {
t.Helper() t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil { if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return jsonoutput.New(), nil }); err != nil {
t.Fatalf("register output: %v", err) t.Fatalf("register output: %v", err)
} }
} }

View File

@@ -8,16 +8,19 @@ import (
) )
type ChunkerConstructor func() (contracts.Chunker, error) type ChunkerConstructor func() (contracts.Chunker, error)
type ChunkerBuilder func(BuildRequest) (contracts.Chunker, error)
type ChunkerRegistry struct { type ChunkerRegistry struct {
constructors map[string]ChunkerConstructor builders map[string]ChunkerBuilder
specs map[string]ModuleSpec optionValidators map[string]OptionValidator
specs map[string]ModuleSpec
} }
func NewChunkerRegistry() *ChunkerRegistry { func NewChunkerRegistry() *ChunkerRegistry {
return &ChunkerRegistry{ return &ChunkerRegistry{
constructors: make(map[string]ChunkerConstructor), builders: make(map[string]ChunkerBuilder),
specs: make(map[string]ModuleSpec), optionValidators: make(map[string]OptionValidator),
specs: make(map[string]ModuleSpec),
} }
} }
@@ -26,6 +29,15 @@ func (r *ChunkerRegistry) Register(key string, constructor ChunkerConstructor) e
} }
func (r *ChunkerRegistry) RegisterWithSpec(spec ModuleSpec, constructor ChunkerConstructor) error { func (r *ChunkerRegistry) RegisterWithSpec(spec ModuleSpec, constructor ChunkerConstructor) error {
if constructor == nil {
return fmt.Errorf("chunker constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return r.RegisterBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.Chunker, error) {
return constructor()
})
}
func (r *ChunkerRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder ChunkerBuilder) error {
if r == nil { if r == nil {
return fmt.Errorf("chunker registry must not be nil") return fmt.Errorf("chunker registry must not be nil")
} }
@@ -34,25 +46,36 @@ func (r *ChunkerRegistry) RegisterWithSpec(spec ModuleSpec, constructor ChunkerC
if err := validateModuleSpec("chunker", StageChunk, normalizedSpec); err != nil { if err := validateModuleSpec("chunker", StageChunk, normalizedSpec); err != nil {
return err return err
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("chunker constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("chunker option validator for %q must not be nil", normalizedSpec.Key)
} }
if _, ok := r.constructors[normalizedSpec.Key]; ok { if builder == nil {
return fmt.Errorf("chunker builder for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.builders[normalizedSpec.Key]; ok {
return fmt.Errorf("chunker %q is already registered", normalizedSpec.Key) return fmt.Errorf("chunker %q is already registered", normalizedSpec.Key)
} }
if r.constructors == nil { if r.builders == nil {
r.constructors = make(map[string]ChunkerConstructor) r.builders = make(map[string]ChunkerBuilder)
}
if r.optionValidators == nil {
r.optionValidators = make(map[string]OptionValidator)
} }
if r.specs == nil { if r.specs == nil {
r.specs = make(map[string]ModuleSpec) r.specs = make(map[string]ModuleSpec)
} }
r.constructors[normalizedSpec.Key] = constructor r.builders[normalizedSpec.Key] = builder
r.optionValidators[normalizedSpec.Key] = validateOptions
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil return nil
} }
func (r *ChunkerRegistry) Build(key string) (contracts.Chunker, error) { func (r *ChunkerRegistry) Build(key string) (contracts.Chunker, error) {
return r.BuildWithRequest(key, BuildRequest{})
}
func (r *ChunkerRegistry) BuildWithRequest(key string, request BuildRequest) (contracts.Chunker, error) {
if r == nil { if r == nil {
return nil, fmt.Errorf("chunker registry must not be nil") return nil, fmt.Errorf("chunker registry must not be nil")
} }
@@ -62,12 +85,12 @@ func (r *ChunkerRegistry) Build(key string) (contracts.Chunker, error) {
return nil, fmt.Errorf("chunker key must not be empty") return nil, fmt.Errorf("chunker key must not be empty")
} }
constructor, ok := r.constructors[normalizedKey] builder, ok := r.builders[normalizedKey]
if !ok { if !ok {
return nil, fmt.Errorf("chunker %q is not registered", normalizedKey) return nil, fmt.Errorf("chunker %q is not registered", normalizedKey)
} }
chunker, err := constructor() chunker, err := builder(cloneBuildRequest(request))
if err != nil { if err != nil {
return nil, fmt.Errorf("build chunker %q: %w", normalizedKey, err) return nil, fmt.Errorf("build chunker %q: %w", normalizedKey, err)
} }
@@ -81,6 +104,18 @@ func (r *ChunkerRegistry) Build(key string) (contracts.Chunker, error) {
return chunker, nil return chunker, nil
} }
func (r *ChunkerRegistry) ValidateOptions(key string, options map[string]any) error {
if r == nil {
return fmt.Errorf("chunker registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
validator, ok := r.optionValidators[normalizedKey]
if !ok {
return fmt.Errorf("chunker %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *ChunkerRegistry) Spec(key string) (ModuleSpec, bool) { func (r *ChunkerRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil { if r == nil {
return ModuleSpec{}, false return ModuleSpec{}, false
@@ -98,5 +133,5 @@ func (r *ChunkerRegistry) RegisteredKeys() []string {
return nil return nil
} }
return sortedRegistryKeys(r.constructors) return sortedRegistryKeys(r.builders)
} }

View File

@@ -0,0 +1,67 @@
package pipeline
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
// ModuleDependencies contains run-scoped collaborators shared by constructed
// modules. Implementations retain only the dependencies they use.
type ModuleDependencies struct {
LLM contracts.StructuredLLMClient
}
// BuildRequest contains the stable dependencies and configured options used to
// construct one module or validator for a run.
type BuildRequest struct {
Dependencies ModuleDependencies
Options map[string]any
}
// OptionValidator validates one module binding without constructing it.
type OptionValidator func(map[string]any) error
func allowLegacyOptions(map[string]any) error { return nil }
func validateRegisteredOptions(validator OptionValidator, options map[string]any) error {
if validator == nil {
return fmt.Errorf("option validator must not be nil")
}
return validator(cloneOptions(options))
}
func cloneBuildRequest(request BuildRequest) BuildRequest {
return BuildRequest{
Dependencies: request.Dependencies,
Options: cloneOptions(request.Options),
}
}
// RejectUnknownOptions provides the common strict-map check used by module-
// owned option decoders. Values remain the implementation's responsibility.
func RejectUnknownOptions(options map[string]any, allowed ...string) error {
known := make(map[string]struct{}, len(allowed))
for _, key := range allowed {
key = strings.TrimSpace(key)
if key != "" {
known[key] = struct{}{}
}
}
unknown := make([]string, 0)
for key := range options {
if _, ok := known[key]; !ok {
unknown = append(unknown, key)
}
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
if len(unknown) == 1 {
return fmt.Errorf("unknown option %q", unknown[0])
}
return fmt.Errorf("unknown options %q", unknown)
}

View File

@@ -9,24 +9,28 @@ import (
) )
type LegacyRawExtractorConstructor func() (contracts.LegacyRawExtractor, error) type LegacyRawExtractorConstructor func() (contracts.LegacyRawExtractor, error)
type LegacyRawExtractorBuilder func(BuildRequest) (contracts.LegacyRawExtractor, error)
type ExtractorRegistry struct { type ExtractorRegistry struct {
legacyConstructors map[string]LegacyRawExtractorConstructor legacyBuilders map[string]LegacyRawExtractorBuilder
typedEntries map[string]typedExtractorEntry legacyValidators map[string]OptionValidator
specs map[string]ModuleSpec typedEntries map[string]typedExtractorEntry
specs map[string]ModuleSpec
} }
type typedExtractorEntry struct { type typedExtractorEntry struct {
spec ModuleSpec spec ModuleSpec
valueType reflect.Type valueType reflect.Type
constructor func() (any, error) validateOptions OptionValidator
builder func(BuildRequest) (any, error)
} }
func NewExtractorRegistry() *ExtractorRegistry { func NewExtractorRegistry() *ExtractorRegistry {
return &ExtractorRegistry{ return &ExtractorRegistry{
legacyConstructors: make(map[string]LegacyRawExtractorConstructor), legacyBuilders: make(map[string]LegacyRawExtractorBuilder),
typedEntries: make(map[string]typedExtractorEntry), legacyValidators: make(map[string]OptionValidator),
specs: make(map[string]ModuleSpec), typedEntries: make(map[string]typedExtractorEntry),
specs: make(map[string]ModuleSpec),
} }
} }
@@ -35,6 +39,15 @@ func (r *ExtractorRegistry) RegisterLegacyRaw(key string, constructor LegacyRawE
} }
func (r *ExtractorRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor LegacyRawExtractorConstructor) error { func (r *ExtractorRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor LegacyRawExtractorConstructor) error {
if constructor == nil {
return fmt.Errorf("extractor constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return r.RegisterLegacyRawBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.LegacyRawExtractor, error) {
return constructor()
})
}
func (r *ExtractorRegistry) RegisterLegacyRawBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder LegacyRawExtractorBuilder) error {
if r == nil { if r == nil {
return fmt.Errorf("extractor registry must not be nil") return fmt.Errorf("extractor registry must not be nil")
} }
@@ -45,24 +58,40 @@ func (r *ExtractorRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, construct
if normalizedSpec.ArtifactKind != "" { if normalizedSpec.ArtifactKind != "" {
return fmt.Errorf("legacy raw extractor %q must not declare an artifact kind", normalizedSpec.Key) return fmt.Errorf("legacy raw extractor %q must not declare an artifact kind", normalizedSpec.Key)
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("extractor constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("extractor option validator for %q must not be nil", normalizedSpec.Key)
}
if builder == nil {
return fmt.Errorf("extractor builder for %q must not be nil", normalizedSpec.Key)
} }
if _, ok := r.specs[normalizedSpec.Key]; ok { if _, ok := r.specs[normalizedSpec.Key]; ok {
return fmt.Errorf("extractor %q is already registered", normalizedSpec.Key) return fmt.Errorf("extractor %q is already registered", normalizedSpec.Key)
} }
if r.legacyConstructors == nil { if r.legacyBuilders == nil {
r.legacyConstructors = make(map[string]LegacyRawExtractorConstructor) r.legacyBuilders = make(map[string]LegacyRawExtractorBuilder)
}
if r.legacyValidators == nil {
r.legacyValidators = make(map[string]OptionValidator)
} }
if r.specs == nil { if r.specs == nil {
r.specs = make(map[string]ModuleSpec) r.specs = make(map[string]ModuleSpec)
} }
r.legacyConstructors[normalizedSpec.Key] = constructor r.legacyBuilders[normalizedSpec.Key] = builder
r.legacyValidators[normalizedSpec.Key] = validateOptions
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil return nil
} }
func RegisterExtractor[T any](registry *ExtractorRegistry, spec ModuleSpec, constructor func() (contracts.Extractor[T], error)) error { func RegisterExtractor[T any](registry *ExtractorRegistry, spec ModuleSpec, constructor func() (contracts.Extractor[T], error)) error {
if constructor == nil {
return fmt.Errorf("extractor constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return RegisterExtractorBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.Extractor[T], error) {
return constructor()
})
}
func RegisterExtractorBuilder[T any](registry *ExtractorRegistry, spec ModuleSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.Extractor[T], error)) error {
if registry == nil { if registry == nil {
return fmt.Errorf("extractor registry must not be nil") return fmt.Errorf("extractor registry must not be nil")
} }
@@ -73,17 +102,21 @@ func RegisterExtractor[T any](registry *ExtractorRegistry, spec ModuleSpec, cons
if normalizedSpec.ArtifactKind == "" { if normalizedSpec.ArtifactKind == "" {
return fmt.Errorf("typed extractor %q artifact kind must not be empty", normalizedSpec.Key) return fmt.Errorf("typed extractor %q artifact kind must not be empty", normalizedSpec.Key)
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("extractor constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("extractor option validator for %q must not be nil", normalizedSpec.Key)
}
if builder == nil {
return fmt.Errorf("extractor builder for %q must not be nil", normalizedSpec.Key)
} }
if _, ok := registry.specs[normalizedSpec.Key]; ok { if _, ok := registry.specs[normalizedSpec.Key]; ok {
return fmt.Errorf("extractor %q is already registered", normalizedSpec.Key) return fmt.Errorf("extractor %q is already registered", normalizedSpec.Key)
} }
entry := typedExtractorEntry{ entry := typedExtractorEntry{
spec: cloneModuleSpec(normalizedSpec), spec: cloneModuleSpec(normalizedSpec),
valueType: reflect.TypeFor[T](), valueType: reflect.TypeFor[T](),
constructor: func() (any, error) { validateOptions: validateOptions,
return constructor() builder: func(request BuildRequest) (any, error) {
return builder(cloneBuildRequest(request))
}, },
} }
if registry.typedEntries == nil { if registry.typedEntries == nil {
@@ -98,6 +131,10 @@ func RegisterExtractor[T any](registry *ExtractorRegistry, spec ModuleSpec, cons
} }
func (r *ExtractorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawExtractor, error) { func (r *ExtractorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawExtractor, error) {
return r.BuildLegacyRawWithRequest(key, BuildRequest{})
}
func (r *ExtractorRegistry) BuildLegacyRawWithRequest(key string, request BuildRequest) (contracts.LegacyRawExtractor, error) {
if r == nil { if r == nil {
return nil, fmt.Errorf("extractor registry must not be nil") return nil, fmt.Errorf("extractor registry must not be nil")
} }
@@ -105,11 +142,11 @@ func (r *ExtractorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawExtra
if normalizedKey == "" { if normalizedKey == "" {
return nil, fmt.Errorf("extractor key must not be empty") return nil, fmt.Errorf("extractor key must not be empty")
} }
constructor, ok := r.legacyConstructors[normalizedKey] builder, ok := r.legacyBuilders[normalizedKey]
if !ok { if !ok {
return nil, fmt.Errorf("legacy raw extractor %q is not registered", normalizedKey) return nil, fmt.Errorf("legacy raw extractor %q is not registered", normalizedKey)
} }
extractor, err := constructor() extractor, err := builder(cloneBuildRequest(request))
if err != nil { if err != nil {
return nil, fmt.Errorf("build extractor %q: %w", normalizedKey, err) return nil, fmt.Errorf("build extractor %q: %w", normalizedKey, err)
} }
@@ -122,6 +159,21 @@ func (r *ExtractorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawExtra
return extractor, nil return extractor, nil
} }
func (r *ExtractorRegistry) validateOptions(key string, options map[string]any) error {
if r == nil {
return fmt.Errorf("extractor registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
if entry, ok := r.typedEntries[normalizedKey]; ok {
return validateRegisteredOptions(entry.validateOptions, options)
}
validator, ok := r.legacyValidators[normalizedKey]
if !ok {
return fmt.Errorf("extractor %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *ExtractorRegistry) Spec(key string) (ModuleSpec, bool) { func (r *ExtractorRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil { if r == nil {
return ModuleSpec{}, false return ModuleSpec{}, false

View File

@@ -8,16 +8,19 @@ import (
) )
type InputAdapterConstructor func() (contracts.InputAdapter, error) type InputAdapterConstructor func() (contracts.InputAdapter, error)
type InputAdapterBuilder func(BuildRequest) (contracts.InputAdapter, error)
type InputAdapterRegistry struct { type InputAdapterRegistry struct {
constructors map[string]InputAdapterConstructor builders map[string]InputAdapterBuilder
specs map[string]ModuleSpec optionValidators map[string]OptionValidator
specs map[string]ModuleSpec
} }
func NewInputAdapterRegistry() *InputAdapterRegistry { func NewInputAdapterRegistry() *InputAdapterRegistry {
return &InputAdapterRegistry{ return &InputAdapterRegistry{
constructors: make(map[string]InputAdapterConstructor), builders: make(map[string]InputAdapterBuilder),
specs: make(map[string]ModuleSpec), optionValidators: make(map[string]OptionValidator),
specs: make(map[string]ModuleSpec),
} }
} }
@@ -26,6 +29,15 @@ func (r *InputAdapterRegistry) Register(key string, constructor InputAdapterCons
} }
func (r *InputAdapterRegistry) RegisterWithSpec(spec ModuleSpec, constructor InputAdapterConstructor) error { func (r *InputAdapterRegistry) RegisterWithSpec(spec ModuleSpec, constructor InputAdapterConstructor) error {
if constructor == nil {
return fmt.Errorf("input adapter constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return r.RegisterBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.InputAdapter, error) {
return constructor()
})
}
func (r *InputAdapterRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder InputAdapterBuilder) error {
if r == nil { if r == nil {
return fmt.Errorf("input adapter registry must not be nil") return fmt.Errorf("input adapter registry must not be nil")
} }
@@ -34,25 +46,36 @@ func (r *InputAdapterRegistry) RegisterWithSpec(spec ModuleSpec, constructor Inp
if err := validateModuleSpec("input adapter", StageInput, normalizedSpec); err != nil { if err := validateModuleSpec("input adapter", StageInput, normalizedSpec); err != nil {
return err return err
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("input adapter constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("input adapter option validator for %q must not be nil", normalizedSpec.Key)
} }
if _, ok := r.constructors[normalizedSpec.Key]; ok { if builder == nil {
return fmt.Errorf("input adapter builder for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.builders[normalizedSpec.Key]; ok {
return fmt.Errorf("input adapter %q is already registered", normalizedSpec.Key) return fmt.Errorf("input adapter %q is already registered", normalizedSpec.Key)
} }
if r.constructors == nil { if r.builders == nil {
r.constructors = make(map[string]InputAdapterConstructor) r.builders = make(map[string]InputAdapterBuilder)
}
if r.optionValidators == nil {
r.optionValidators = make(map[string]OptionValidator)
} }
if r.specs == nil { if r.specs == nil {
r.specs = make(map[string]ModuleSpec) r.specs = make(map[string]ModuleSpec)
} }
r.constructors[normalizedSpec.Key] = constructor r.builders[normalizedSpec.Key] = builder
r.optionValidators[normalizedSpec.Key] = validateOptions
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil return nil
} }
func (r *InputAdapterRegistry) Build(key string) (contracts.InputAdapter, error) { func (r *InputAdapterRegistry) Build(key string) (contracts.InputAdapter, error) {
return r.BuildWithRequest(key, BuildRequest{})
}
func (r *InputAdapterRegistry) BuildWithRequest(key string, request BuildRequest) (contracts.InputAdapter, error) {
if r == nil { if r == nil {
return nil, fmt.Errorf("input adapter registry must not be nil") return nil, fmt.Errorf("input adapter registry must not be nil")
} }
@@ -62,12 +85,12 @@ func (r *InputAdapterRegistry) Build(key string) (contracts.InputAdapter, error)
return nil, fmt.Errorf("input adapter key must not be empty") return nil, fmt.Errorf("input adapter key must not be empty")
} }
constructor, ok := r.constructors[normalizedKey] builder, ok := r.builders[normalizedKey]
if !ok { if !ok {
return nil, fmt.Errorf("input adapter %q is not registered", normalizedKey) return nil, fmt.Errorf("input adapter %q is not registered", normalizedKey)
} }
adapter, err := constructor() adapter, err := builder(cloneBuildRequest(request))
if err != nil { if err != nil {
return nil, fmt.Errorf("build input adapter %q: %w", normalizedKey, err) return nil, fmt.Errorf("build input adapter %q: %w", normalizedKey, err)
} }
@@ -81,6 +104,18 @@ func (r *InputAdapterRegistry) Build(key string) (contracts.InputAdapter, error)
return adapter, nil return adapter, nil
} }
func (r *InputAdapterRegistry) ValidateOptions(key string, options map[string]any) error {
if r == nil {
return fmt.Errorf("input adapter registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
validator, ok := r.optionValidators[normalizedKey]
if !ok {
return fmt.Errorf("input adapter %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *InputAdapterRegistry) Spec(key string) (ModuleSpec, bool) { func (r *InputAdapterRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil { if r == nil {
return ModuleSpec{}, false return ModuleSpec{}, false
@@ -98,5 +133,5 @@ func (r *InputAdapterRegistry) RegisteredKeys() []string {
return nil return nil
} }
return sortedRegistryKeys(r.constructors) return sortedRegistryKeys(r.builders)
} }

View File

@@ -9,6 +9,7 @@ import (
) )
type LegacyRawMergerConstructor func() (contracts.LegacyRawMerger, error) type LegacyRawMergerConstructor func() (contracts.LegacyRawMerger, error)
type LegacyRawMergerBuilder func(BuildRequest) (contracts.LegacyRawMerger, error)
type artifactVariantKey struct { type artifactVariantKey struct {
module string module string
@@ -16,22 +17,25 @@ type artifactVariantKey struct {
} }
type MergerRegistry struct { type MergerRegistry struct {
legacyConstructors map[string]LegacyRawMergerConstructor legacyBuilders map[string]LegacyRawMergerBuilder
legacySpecs map[string]ModuleSpec legacyValidators map[string]OptionValidator
typedEntries map[artifactVariantKey]typedMergerEntry legacySpecs map[string]ModuleSpec
typedEntries map[artifactVariantKey]typedMergerEntry
} }
type typedMergerEntry struct { type typedMergerEntry struct {
spec ModuleSpec spec ModuleSpec
valueType reflect.Type valueType reflect.Type
constructor func() (any, error) validateOptions OptionValidator
builder func(BuildRequest) (any, error)
} }
func NewMergerRegistry() *MergerRegistry { func NewMergerRegistry() *MergerRegistry {
return &MergerRegistry{ return &MergerRegistry{
legacyConstructors: make(map[string]LegacyRawMergerConstructor), legacyBuilders: make(map[string]LegacyRawMergerBuilder),
legacySpecs: make(map[string]ModuleSpec), legacyValidators: make(map[string]OptionValidator),
typedEntries: make(map[artifactVariantKey]typedMergerEntry), legacySpecs: make(map[string]ModuleSpec),
typedEntries: make(map[artifactVariantKey]typedMergerEntry),
} }
} }
@@ -40,6 +44,15 @@ func (r *MergerRegistry) RegisterLegacyRaw(key string, constructor LegacyRawMerg
} }
func (r *MergerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor LegacyRawMergerConstructor) error { func (r *MergerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor LegacyRawMergerConstructor) error {
if constructor == nil {
return fmt.Errorf("merger constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return r.RegisterLegacyRawBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.LegacyRawMerger, error) {
return constructor()
})
}
func (r *MergerRegistry) RegisterLegacyRawBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder LegacyRawMergerBuilder) error {
if r == nil { if r == nil {
return fmt.Errorf("merger registry must not be nil") return fmt.Errorf("merger registry must not be nil")
} }
@@ -50,24 +63,40 @@ func (r *MergerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor
if normalizedSpec.ArtifactKind != "" { if normalizedSpec.ArtifactKind != "" {
return fmt.Errorf("legacy raw merger %q must not declare an artifact kind", normalizedSpec.Key) return fmt.Errorf("legacy raw merger %q must not declare an artifact kind", normalizedSpec.Key)
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("merger constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("merger option validator for %q must not be nil", normalizedSpec.Key)
} }
if _, ok := r.legacyConstructors[normalizedSpec.Key]; ok { if builder == nil {
return fmt.Errorf("merger builder for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.legacyBuilders[normalizedSpec.Key]; ok {
return fmt.Errorf("legacy raw merger %q is already registered", normalizedSpec.Key) return fmt.Errorf("legacy raw merger %q is already registered", normalizedSpec.Key)
} }
if r.legacyConstructors == nil { if r.legacyBuilders == nil {
r.legacyConstructors = make(map[string]LegacyRawMergerConstructor) r.legacyBuilders = make(map[string]LegacyRawMergerBuilder)
}
if r.legacyValidators == nil {
r.legacyValidators = make(map[string]OptionValidator)
} }
if r.legacySpecs == nil { if r.legacySpecs == nil {
r.legacySpecs = make(map[string]ModuleSpec) r.legacySpecs = make(map[string]ModuleSpec)
} }
r.legacyConstructors[normalizedSpec.Key] = constructor r.legacyBuilders[normalizedSpec.Key] = builder
r.legacyValidators[normalizedSpec.Key] = validateOptions
r.legacySpecs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) r.legacySpecs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil return nil
} }
func RegisterMerger[T any](registry *MergerRegistry, spec ModuleSpec, constructor func() (contracts.Merger[T], error)) error { func RegisterMerger[T any](registry *MergerRegistry, spec ModuleSpec, constructor func() (contracts.Merger[T], error)) error {
if constructor == nil {
return fmt.Errorf("merger constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return RegisterMergerBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.Merger[T], error) {
return constructor()
})
}
func RegisterMergerBuilder[T any](registry *MergerRegistry, spec ModuleSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.Merger[T], error)) error {
if registry == nil { if registry == nil {
return fmt.Errorf("merger registry must not be nil") return fmt.Errorf("merger registry must not be nil")
} }
@@ -78,8 +107,11 @@ func RegisterMerger[T any](registry *MergerRegistry, spec ModuleSpec, constructo
if normalizedSpec.ArtifactKind == "" { if normalizedSpec.ArtifactKind == "" {
return fmt.Errorf("typed merger %q artifact kind must not be empty", normalizedSpec.Key) return fmt.Errorf("typed merger %q artifact kind must not be empty", normalizedSpec.Key)
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("merger constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("merger option validator for %q must not be nil", normalizedSpec.Key)
}
if builder == nil {
return fmt.Errorf("merger builder for %q must not be nil", normalizedSpec.Key)
} }
key := artifactVariantKey{module: normalizedSpec.Key, kind: normalizedSpec.ArtifactKind} key := artifactVariantKey{module: normalizedSpec.Key, kind: normalizedSpec.ArtifactKind}
if _, ok := registry.typedEntries[key]; ok { if _, ok := registry.typedEntries[key]; ok {
@@ -89,16 +121,21 @@ func RegisterMerger[T any](registry *MergerRegistry, spec ModuleSpec, constructo
registry.typedEntries = make(map[artifactVariantKey]typedMergerEntry) registry.typedEntries = make(map[artifactVariantKey]typedMergerEntry)
} }
registry.typedEntries[key] = typedMergerEntry{ registry.typedEntries[key] = typedMergerEntry{
spec: cloneModuleSpec(normalizedSpec), spec: cloneModuleSpec(normalizedSpec),
valueType: reflect.TypeFor[T](), valueType: reflect.TypeFor[T](),
constructor: func() (any, error) { validateOptions: validateOptions,
return constructor() builder: func(request BuildRequest) (any, error) {
return builder(cloneBuildRequest(request))
}, },
} }
return nil return nil
} }
func (r *MergerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawMerger, error) { func (r *MergerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawMerger, error) {
return r.BuildLegacyRawWithRequest(key, BuildRequest{})
}
func (r *MergerRegistry) BuildLegacyRawWithRequest(key string, request BuildRequest) (contracts.LegacyRawMerger, error) {
if r == nil { if r == nil {
return nil, fmt.Errorf("merger registry must not be nil") return nil, fmt.Errorf("merger registry must not be nil")
} }
@@ -106,11 +143,11 @@ func (r *MergerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawMerger,
if normalizedKey == "" { if normalizedKey == "" {
return nil, fmt.Errorf("merger key must not be empty") return nil, fmt.Errorf("merger key must not be empty")
} }
constructor, ok := r.legacyConstructors[normalizedKey] builder, ok := r.legacyBuilders[normalizedKey]
if !ok { if !ok {
return nil, fmt.Errorf("legacy raw merger %q is not registered", normalizedKey) return nil, fmt.Errorf("legacy raw merger %q is not registered", normalizedKey)
} }
merger, err := constructor() merger, err := builder(cloneBuildRequest(request))
if err != nil { if err != nil {
return nil, fmt.Errorf("build merger %q: %w", normalizedKey, err) return nil, fmt.Errorf("build merger %q: %w", normalizedKey, err)
} }
@@ -123,6 +160,25 @@ func (r *MergerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawMerger,
return merger, nil return merger, nil
} }
func (r *MergerRegistry) validateOptions(key string, kind contracts.ArtifactKind, options map[string]any) error {
if r == nil {
return fmt.Errorf("merger registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
if kind != "" {
entry, ok := r.typedEntry(normalizedKey, kind)
if !ok {
return fmt.Errorf("merger %q variant for artifact kind %q is not registered", normalizedKey, kind)
}
return validateRegisteredOptions(entry.validateOptions, options)
}
validator, ok := r.legacyValidators[normalizedKey]
if !ok {
return fmt.Errorf("legacy raw merger %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *MergerRegistry) Spec(key string) (ModuleSpec, bool) { func (r *MergerRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil { if r == nil {
return ModuleSpec{}, false return ModuleSpec{}, false

View File

@@ -9,24 +9,28 @@ import (
) )
type LegacyRawNormalizerConstructor func() (contracts.LegacyRawNormalizer, error) type LegacyRawNormalizerConstructor func() (contracts.LegacyRawNormalizer, error)
type LegacyRawNormalizerBuilder func(BuildRequest) (contracts.LegacyRawNormalizer, error)
type NormalizerRegistry struct { type NormalizerRegistry struct {
legacyConstructors map[string]LegacyRawNormalizerConstructor legacyBuilders map[string]LegacyRawNormalizerBuilder
legacySpecs map[string]ModuleSpec legacyValidators map[string]OptionValidator
typedEntries map[artifactVariantKey]typedNormalizerEntry legacySpecs map[string]ModuleSpec
typedEntries map[artifactVariantKey]typedNormalizerEntry
} }
type typedNormalizerEntry struct { type typedNormalizerEntry struct {
spec ModuleSpec spec ModuleSpec
valueType reflect.Type valueType reflect.Type
constructor func() (any, error) validateOptions OptionValidator
builder func(BuildRequest) (any, error)
} }
func NewNormalizerRegistry() *NormalizerRegistry { func NewNormalizerRegistry() *NormalizerRegistry {
return &NormalizerRegistry{ return &NormalizerRegistry{
legacyConstructors: make(map[string]LegacyRawNormalizerConstructor), legacyBuilders: make(map[string]LegacyRawNormalizerBuilder),
legacySpecs: make(map[string]ModuleSpec), legacyValidators: make(map[string]OptionValidator),
typedEntries: make(map[artifactVariantKey]typedNormalizerEntry), legacySpecs: make(map[string]ModuleSpec),
typedEntries: make(map[artifactVariantKey]typedNormalizerEntry),
} }
} }
@@ -35,6 +39,15 @@ func (r *NormalizerRegistry) RegisterLegacyRaw(key string, constructor LegacyRaw
} }
func (r *NormalizerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor LegacyRawNormalizerConstructor) error { func (r *NormalizerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor LegacyRawNormalizerConstructor) error {
if constructor == nil {
return fmt.Errorf("normalizer constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return r.RegisterLegacyRawBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.LegacyRawNormalizer, error) {
return constructor()
})
}
func (r *NormalizerRegistry) RegisterLegacyRawBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder LegacyRawNormalizerBuilder) error {
if r == nil { if r == nil {
return fmt.Errorf("normalizer registry must not be nil") return fmt.Errorf("normalizer registry must not be nil")
} }
@@ -45,24 +58,40 @@ func (r *NormalizerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, construc
if normalizedSpec.ArtifactKind != "" { if normalizedSpec.ArtifactKind != "" {
return fmt.Errorf("legacy raw normalizer %q must not declare an artifact kind", normalizedSpec.Key) return fmt.Errorf("legacy raw normalizer %q must not declare an artifact kind", normalizedSpec.Key)
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("normalizer constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("normalizer option validator for %q must not be nil", normalizedSpec.Key)
} }
if _, ok := r.legacyConstructors[normalizedSpec.Key]; ok { if builder == nil {
return fmt.Errorf("normalizer builder for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.legacyBuilders[normalizedSpec.Key]; ok {
return fmt.Errorf("legacy raw normalizer %q is already registered", normalizedSpec.Key) return fmt.Errorf("legacy raw normalizer %q is already registered", normalizedSpec.Key)
} }
if r.legacyConstructors == nil { if r.legacyBuilders == nil {
r.legacyConstructors = make(map[string]LegacyRawNormalizerConstructor) r.legacyBuilders = make(map[string]LegacyRawNormalizerBuilder)
}
if r.legacyValidators == nil {
r.legacyValidators = make(map[string]OptionValidator)
} }
if r.legacySpecs == nil { if r.legacySpecs == nil {
r.legacySpecs = make(map[string]ModuleSpec) r.legacySpecs = make(map[string]ModuleSpec)
} }
r.legacyConstructors[normalizedSpec.Key] = constructor r.legacyBuilders[normalizedSpec.Key] = builder
r.legacyValidators[normalizedSpec.Key] = validateOptions
r.legacySpecs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) r.legacySpecs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil return nil
} }
func RegisterNormalizer[T any](registry *NormalizerRegistry, spec ModuleSpec, constructor func() (contracts.Normalizer[T], error)) error { func RegisterNormalizer[T any](registry *NormalizerRegistry, spec ModuleSpec, constructor func() (contracts.Normalizer[T], error)) error {
if constructor == nil {
return fmt.Errorf("normalizer constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return RegisterNormalizerBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.Normalizer[T], error) {
return constructor()
})
}
func RegisterNormalizerBuilder[T any](registry *NormalizerRegistry, spec ModuleSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.Normalizer[T], error)) error {
if registry == nil { if registry == nil {
return fmt.Errorf("normalizer registry must not be nil") return fmt.Errorf("normalizer registry must not be nil")
} }
@@ -73,8 +102,11 @@ func RegisterNormalizer[T any](registry *NormalizerRegistry, spec ModuleSpec, co
if normalizedSpec.ArtifactKind == "" { if normalizedSpec.ArtifactKind == "" {
return fmt.Errorf("typed normalizer %q artifact kind must not be empty", normalizedSpec.Key) return fmt.Errorf("typed normalizer %q artifact kind must not be empty", normalizedSpec.Key)
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("normalizer constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("normalizer option validator for %q must not be nil", normalizedSpec.Key)
}
if builder == nil {
return fmt.Errorf("normalizer builder for %q must not be nil", normalizedSpec.Key)
} }
key := artifactVariantKey{module: normalizedSpec.Key, kind: normalizedSpec.ArtifactKind} key := artifactVariantKey{module: normalizedSpec.Key, kind: normalizedSpec.ArtifactKind}
if _, ok := registry.typedEntries[key]; ok { if _, ok := registry.typedEntries[key]; ok {
@@ -84,16 +116,21 @@ func RegisterNormalizer[T any](registry *NormalizerRegistry, spec ModuleSpec, co
registry.typedEntries = make(map[artifactVariantKey]typedNormalizerEntry) registry.typedEntries = make(map[artifactVariantKey]typedNormalizerEntry)
} }
registry.typedEntries[key] = typedNormalizerEntry{ registry.typedEntries[key] = typedNormalizerEntry{
spec: cloneModuleSpec(normalizedSpec), spec: cloneModuleSpec(normalizedSpec),
valueType: reflect.TypeFor[T](), valueType: reflect.TypeFor[T](),
constructor: func() (any, error) { validateOptions: validateOptions,
return constructor() builder: func(request BuildRequest) (any, error) {
return builder(cloneBuildRequest(request))
}, },
} }
return nil return nil
} }
func (r *NormalizerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawNormalizer, error) { func (r *NormalizerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawNormalizer, error) {
return r.BuildLegacyRawWithRequest(key, BuildRequest{})
}
func (r *NormalizerRegistry) BuildLegacyRawWithRequest(key string, request BuildRequest) (contracts.LegacyRawNormalizer, error) {
if r == nil { if r == nil {
return nil, fmt.Errorf("normalizer registry must not be nil") return nil, fmt.Errorf("normalizer registry must not be nil")
} }
@@ -101,11 +138,11 @@ func (r *NormalizerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawNorm
if normalizedKey == "" { if normalizedKey == "" {
return nil, fmt.Errorf("normalizer key must not be empty") return nil, fmt.Errorf("normalizer key must not be empty")
} }
constructor, ok := r.legacyConstructors[normalizedKey] builder, ok := r.legacyBuilders[normalizedKey]
if !ok { if !ok {
return nil, fmt.Errorf("legacy raw normalizer %q is not registered", normalizedKey) return nil, fmt.Errorf("legacy raw normalizer %q is not registered", normalizedKey)
} }
normalizer, err := constructor() normalizer, err := builder(cloneBuildRequest(request))
if err != nil { if err != nil {
return nil, fmt.Errorf("build normalizer %q: %w", normalizedKey, err) return nil, fmt.Errorf("build normalizer %q: %w", normalizedKey, err)
} }
@@ -118,6 +155,25 @@ func (r *NormalizerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawNorm
return normalizer, nil return normalizer, nil
} }
func (r *NormalizerRegistry) validateOptions(key string, kind contracts.ArtifactKind, options map[string]any) error {
if r == nil {
return fmt.Errorf("normalizer registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
if kind != "" {
entry, ok := r.typedEntry(normalizedKey, kind)
if !ok {
return fmt.Errorf("normalizer %q variant for artifact kind %q is not registered", normalizedKey, kind)
}
return validateRegisteredOptions(entry.validateOptions, options)
}
validator, ok := r.legacyValidators[normalizedKey]
if !ok {
return fmt.Errorf("legacy raw normalizer %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *NormalizerRegistry) Spec(key string) (ModuleSpec, bool) { func (r *NormalizerRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil { if r == nil {
return ModuleSpec{}, false return ModuleSpec{}, false

View File

@@ -0,0 +1,65 @@
package pipeline
import "fmt"
func validateResolvedOptions(resolved ResolvedPipeline, catalog ModuleCatalog) error {
if err := catalog.Inputs.ValidateOptions(resolved.Input.Module, resolved.Input.Options); err != nil {
return moduleOptionsError(resolved.ID, "", StageInput, resolved.Input.Module, err)
}
if err := catalog.Chunkers.ValidateOptions(resolved.Chunk.Module, resolved.Chunk.Options); err != nil {
return moduleOptionsError(resolved.ID, "", StageChunk, resolved.Chunk.Module, err)
}
if err := validateChainOptions(resolved, catalog, StageChunk, "", resolved.Chunk.Module); err != nil {
return err
}
for _, lane := range resolved.ArtifactLanes {
if err := catalog.Extractors.validateOptions(lane.Extract.Module, lane.Extract.Options); err != nil {
return moduleOptionsError(resolved.ID, lane.ID, StageExtract, lane.Extract.Module, err)
}
if err := validateChainOptions(resolved, catalog, StageExtract, lane.ID, lane.Extract.Module); err != nil {
return err
}
if err := catalog.Mergers.validateOptions(lane.Merge.Module, lane.ArtifactKind, lane.Merge.Options); err != nil {
return moduleOptionsError(resolved.ID, lane.ID, StageMerge, lane.Merge.Module, err)
}
if err := validateChainOptions(resolved, catalog, StageMerge, lane.ID, lane.Merge.Module); err != nil {
return err
}
if err := catalog.Normalizers.validateOptions(lane.Normalize.Module, lane.ArtifactKind, lane.Normalize.Options); err != nil {
return moduleOptionsError(resolved.ID, lane.ID, StageNormalize, lane.Normalize.Module, err)
}
if err := validateChainOptions(resolved, catalog, StageNormalize, lane.ID, lane.Normalize.Module); err != nil {
return err
}
}
if err := catalog.Outputs.ValidateOptions(resolved.Output.Module, resolved.Output.Options); err != nil {
return moduleOptionsError(resolved.ID, "", StageOutput, resolved.Output.Module, err)
}
return nil
}
func validateChainOptions(resolved ResolvedPipeline, catalog ModuleCatalog, stage ModuleStage, laneID, moduleKey string) error {
chain := resolvedValidatorChain(stage, laneID, moduleKey, resolved.ValidatorChains)
for _, validator := range chain.Validators {
if err := catalog.Validators.validateOptions(validator); err != nil {
return validatorOptionsError(resolved.ID, laneID, stage, moduleKey, validator.Binding.Module, err)
}
}
return nil
}
func moduleOptionsError(pipelineID, laneID string, stage ModuleStage, moduleKey string, cause error) error {
if laneID == "" {
return fmt.Errorf("pipeline %q %s module %q options: %w", pipelineID, stage, moduleKey, cause)
}
return fmt.Errorf("pipeline %q lane %q %s module %q options: %w", pipelineID, laneID, stage, moduleKey, cause)
}
func validatorOptionsError(pipelineID, laneID string, stage ModuleStage, moduleKey, validatorKey string, cause error) error {
if laneID == "" {
return fmt.Errorf("pipeline %q %s module %q validator %q options: %w", pipelineID, stage, moduleKey, validatorKey, cause)
}
return fmt.Errorf("pipeline %q lane %q %s module %q validator %q options: %w", pipelineID, laneID, stage, moduleKey, validatorKey, cause)
}

View File

@@ -8,16 +8,19 @@ import (
) )
type OutputEncoderConstructor func() (contracts.OutputEncoder, error) type OutputEncoderConstructor func() (contracts.OutputEncoder, error)
type OutputEncoderBuilder func(BuildRequest) (contracts.OutputEncoder, error)
type OutputEncoderRegistry struct { type OutputEncoderRegistry struct {
constructors map[string]OutputEncoderConstructor builders map[string]OutputEncoderBuilder
specs map[string]ModuleSpec optionValidators map[string]OptionValidator
specs map[string]ModuleSpec
} }
func NewOutputEncoderRegistry() *OutputEncoderRegistry { func NewOutputEncoderRegistry() *OutputEncoderRegistry {
return &OutputEncoderRegistry{ return &OutputEncoderRegistry{
constructors: make(map[string]OutputEncoderConstructor), builders: make(map[string]OutputEncoderBuilder),
specs: make(map[string]ModuleSpec), optionValidators: make(map[string]OptionValidator),
specs: make(map[string]ModuleSpec),
} }
} }
@@ -26,6 +29,15 @@ func (r *OutputEncoderRegistry) Register(key string, constructor OutputEncoderCo
} }
func (r *OutputEncoderRegistry) RegisterWithSpec(spec ModuleSpec, constructor OutputEncoderConstructor) error { func (r *OutputEncoderRegistry) RegisterWithSpec(spec ModuleSpec, constructor OutputEncoderConstructor) error {
if constructor == nil {
return fmt.Errorf("output encoder constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return r.RegisterBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.OutputEncoder, error) {
return constructor()
})
}
func (r *OutputEncoderRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder OutputEncoderBuilder) error {
if r == nil { if r == nil {
return fmt.Errorf("output encoder registry must not be nil") return fmt.Errorf("output encoder registry must not be nil")
} }
@@ -34,25 +46,36 @@ func (r *OutputEncoderRegistry) RegisterWithSpec(spec ModuleSpec, constructor Ou
if err := validateModuleSpec("output encoder", StageOutput, normalizedSpec); err != nil { if err := validateModuleSpec("output encoder", StageOutput, normalizedSpec); err != nil {
return err return err
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("output encoder constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("output encoder option validator for %q must not be nil", normalizedSpec.Key)
} }
if _, ok := r.constructors[normalizedSpec.Key]; ok { if builder == nil {
return fmt.Errorf("output encoder builder for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.builders[normalizedSpec.Key]; ok {
return fmt.Errorf("output encoder %q is already registered", normalizedSpec.Key) return fmt.Errorf("output encoder %q is already registered", normalizedSpec.Key)
} }
if r.constructors == nil { if r.builders == nil {
r.constructors = make(map[string]OutputEncoderConstructor) r.builders = make(map[string]OutputEncoderBuilder)
}
if r.optionValidators == nil {
r.optionValidators = make(map[string]OptionValidator)
} }
if r.specs == nil { if r.specs == nil {
r.specs = make(map[string]ModuleSpec) r.specs = make(map[string]ModuleSpec)
} }
r.constructors[normalizedSpec.Key] = constructor r.builders[normalizedSpec.Key] = builder
r.optionValidators[normalizedSpec.Key] = validateOptions
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil return nil
} }
func (r *OutputEncoderRegistry) Build(key string) (contracts.OutputEncoder, error) { func (r *OutputEncoderRegistry) Build(key string) (contracts.OutputEncoder, error) {
return r.BuildWithRequest(key, BuildRequest{})
}
func (r *OutputEncoderRegistry) BuildWithRequest(key string, request BuildRequest) (contracts.OutputEncoder, error) {
if r == nil { if r == nil {
return nil, fmt.Errorf("output encoder registry must not be nil") return nil, fmt.Errorf("output encoder registry must not be nil")
} }
@@ -62,12 +85,12 @@ func (r *OutputEncoderRegistry) Build(key string) (contracts.OutputEncoder, erro
return nil, fmt.Errorf("output encoder key must not be empty") return nil, fmt.Errorf("output encoder key must not be empty")
} }
constructor, ok := r.constructors[normalizedKey] builder, ok := r.builders[normalizedKey]
if !ok { if !ok {
return nil, fmt.Errorf("output encoder %q is not registered", normalizedKey) return nil, fmt.Errorf("output encoder %q is not registered", normalizedKey)
} }
encoder, err := constructor() encoder, err := builder(cloneBuildRequest(request))
if err != nil { if err != nil {
return nil, fmt.Errorf("build output encoder %q: %w", normalizedKey, err) return nil, fmt.Errorf("build output encoder %q: %w", normalizedKey, err)
} }
@@ -81,6 +104,18 @@ func (r *OutputEncoderRegistry) Build(key string) (contracts.OutputEncoder, erro
return encoder, nil return encoder, nil
} }
func (r *OutputEncoderRegistry) ValidateOptions(key string, options map[string]any) error {
if r == nil {
return fmt.Errorf("output encoder registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
validator, ok := r.optionValidators[normalizedKey]
if !ok {
return fmt.Errorf("output encoder %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *OutputEncoderRegistry) Spec(key string) (ModuleSpec, bool) { func (r *OutputEncoderRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil { if r == nil {
return ModuleSpec{}, false return ModuleSpec{}, false
@@ -98,5 +133,5 @@ func (r *OutputEncoderRegistry) RegisteredKeys() []string {
return nil return nil
} }
return sortedRegistryKeys(r.constructors) return sortedRegistryKeys(r.builders)
} }

View File

@@ -0,0 +1,211 @@
package pipeline
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestResolvePipelineValidatesModuleAndValidatorOptions(t *testing.T) {
tests := []struct {
name string
mutate func(*PipelineProfile)
want []string
}{
{
name: "module",
mutate: func(profile *PipelineProfile) {
profile.Input.Options = map[string]any{"surprise": true}
},
want: []string{`pipeline "construction" input module "input" options`, `unknown option "surprise"`},
},
{
name: "validator",
mutate: func(profile *PipelineProfile) {
profile.Chunk.Validators = ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured", Options: map[string]any{"surprise": true}}}}
},
want: []string{`pipeline "construction" chunk module "chunk" validator "configured" options`, `unknown option "surprise"`},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registries, _ := constructionRegistries(t, nil, nil)
profile := constructionProfile()
test.mutate(&profile)
_, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
if err == nil {
t.Fatal("ResolvePipeline() error = nil, want option validation error")
}
for _, want := range test.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("ResolvePipeline() error = %q, want substring %q", err, want)
}
}
})
}
}
func TestPrepareConstructsEverythingInStableOrder(t *testing.T) {
var built []string
registries, _ := constructionRegistries(t, &built, nil)
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
if err != nil {
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
}
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
want := []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"}
if !reflect.DeepEqual(built, want) {
t.Fatalf("construction order = %#v, want %#v", built, want)
}
if prepared.Input.Module != "input" || prepared.Chunk.Module != "chunk" || prepared.Output.Module != "output" || len(prepared.ArtifactLanes) != 1 {
t.Fatalf("PreparedPipeline = %#v, want explicit resolved components", prepared)
}
}
func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
tests := []struct {
name string
deps ModuleDependencies
configure func(*constructionFailure)
want string
wantBuilt []string
}{
{
name: "missing required llm dependency",
configure: func(failure *constructionFailure) {
failure.requireExtractorLLM = true
},
want: `lane "artifact" extract module "extract"`,
wantBuilt: []string{"input", "chunk", "validator", "extract"},
},
{
name: "late output construction",
configure: func(failure *constructionFailure) {
failure.output = errors.New("output unavailable")
},
want: `output module "output"`,
wantBuilt: []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
failure := &constructionFailure{}
test.configure(failure)
var built []string
registries, input := constructionRegistries(t, &built, failure)
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
if err != nil {
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
}
_, err = Prepare(resolved, registries, test.deps)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Prepare() error = %v, want substring %q", err, test.want)
}
if len(input.requests) != 0 {
t.Fatalf("input Parse calls = %d, want zero", len(input.requests))
}
if !reflect.DeepEqual(built, test.wantBuilt) {
t.Fatalf("construction order = %#v, want %#v", built, test.wantBuilt)
}
})
}
}
type constructionFailure struct {
requireExtractorLLM bool
output error
}
func constructionProfile() PipelineProfile {
validators := ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured"}}}
return PipelineProfile{
ID: "construction",
Input: Binding("input"),
Chunk: ModuleBinding{Module: "chunk", Validators: validators},
Artifacts: map[string]ArtifactLaneProfile{
"artifact": {
Extract: ModuleBinding{Module: "extract", Validators: validators},
Merge: ModuleBinding{Module: "merge", Validators: validators},
Normalize: ModuleBinding{Module: "normalize", Validators: validators},
},
},
Output: Binding("output"),
}
}
func constructionRegistries(t *testing.T, built *[]string, failure *constructionFailure) (Registries, *runnerInputAdapter) {
t.Helper()
if built == nil {
built = &[]string{}
}
if failure == nil {
failure = &constructionFailure{}
}
record := func(name string) { *built = append(*built, name) }
strict := func(options map[string]any) error { return RejectUnknownOptions(options, "known") }
modules := defaultRunnerModules()
registries := Registries{
Inputs: NewInputAdapterRegistry(), Chunkers: NewChunkerRegistry(), ArtifactCodecs: NewArtifactCodecRegistry(),
Extractors: NewExtractorRegistry(), Mergers: NewMergerRegistry(), Normalizers: NewNormalizerRegistry(),
Validators: NewValidatorRegistry(), ValidatorChains: NewValidatorChainRegistry(), Outputs: NewOutputEncoderRegistry(),
}
if err := registries.Inputs.RegisterBuilderWithSpec(defaultModuleSpec("input", StageInput), strict, func(BuildRequest) (contracts.InputAdapter, error) {
record("input")
return modules.input, nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Chunkers.RegisterBuilderWithSpec(defaultModuleSpec("chunk", StageChunk), strict, func(BuildRequest) (contracts.Chunker, error) {
record("chunk")
return modules.chunker, nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Extractors.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("extract", StageExtract), strict, func(request BuildRequest) (contracts.LegacyRawExtractor, error) {
record("extract")
if failure.requireExtractorLLM && request.Dependencies.LLM == nil {
return nil, errors.New("structured LLM client is required")
}
return &runnerExtractor{key: "extract"}, nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Mergers.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("merge", StageMerge), strict, func(BuildRequest) (contracts.LegacyRawMerger, error) {
record("merge")
return modules.mergers["merge"], nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Normalizers.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("normalize", StageNormalize), strict, func(BuildRequest) (contracts.LegacyRawNormalizer, error) {
record("normalize")
return modules.normalizers["normalize"], nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Validators.RegisterLegacyRawBuilderWithSpec(ValidatorSpec{Key: "configured", ExecutionClass: contracts.ExecutionClassDeterministic}, strict, func(BuildRequest) (contracts.LegacyRawValidator, error) {
record("validator")
return modules.validators["configured"], nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Outputs.RegisterBuilderWithSpec(defaultModuleSpec("output", StageOutput), strict, func(BuildRequest) (contracts.OutputEncoder, error) {
record("output")
if failure.output != nil {
return nil, failure.output
}
return modules.output, nil
}); err != nil {
t.Fatal(err)
}
return registries, modules.input
}

View File

@@ -0,0 +1,370 @@
package pipeline
import (
"fmt"
"reflect"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
// PreparedPipeline owns the constructed, run-local implementation set for one
// resolved pipeline. Its implementation values are private so execution cannot
// replace or reconfigure them after preparation.
type PreparedPipeline struct {
Input ModuleBinding
Chunk ModuleBinding
ArtifactLanes []PreparedArtifactLane
Output ModuleBinding
resolved ResolvedPipeline
dependencies ModuleDependencies
input contracts.InputAdapter
chunker contracts.Chunker
chunkValidators preparedValidatorChain
lanes []preparedLaneExecutor
output contracts.OutputEncoder
}
type PreparedArtifactLane struct {
Resolved ResolvedArtifactLane
}
type preparedLaneExecutor struct {
resolved ResolvedArtifactLane
legacy *preparedLegacyLane
typed *preparedTypedLane
extractValidators preparedValidatorChain
mergeValidators preparedValidatorChain
normalizeValidators preparedValidatorChain
}
type preparedLegacyLane struct {
extractor contracts.LegacyRawExtractor
merger contracts.LegacyRawMerger
normalizer contracts.LegacyRawNormalizer
}
type preparedTypedLane struct {
extractor any
merger any
normalizer any
}
type preparedValidatorChain struct {
resolved ResolvedValidatorChain
validators []preparedValidator
}
type preparedValidator struct {
resolved ResolvedValidator
legacy contracts.LegacyRawValidator
typed any
chunk contracts.ChunkValidator
serialized contracts.SerializedValidator
}
// Prepare validates all configured options and constructs every selected
// module and validator before any operation method can run.
func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDependencies) (*PreparedPipeline, error) {
if err := validateResolvedPipeline(resolved, false); err != nil {
return nil, err
}
if err := validateRegistrySet(resolved, registries); err != nil {
return nil, err
}
stable := cloneResolvedPipeline(resolved)
prepared := &PreparedPipeline{
Input: cloneModuleBinding(stable.Input),
Chunk: cloneModuleBinding(stable.Chunk),
Output: cloneModuleBinding(stable.Output),
resolved: stable,
dependencies: deps,
}
request := func(binding ModuleBinding) BuildRequest {
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options)}
}
input, err := registries.Inputs.BuildWithRequest(stable.Input.Module, request(stable.Input))
if err != nil {
return nil, constructionError(stable.ID, "", StageInput, stable.Input.Module, "", err)
}
prepared.input = input
chunker, err := registries.Chunkers.BuildWithRequest(stable.Chunk.Module, request(stable.Chunk))
if err != nil {
return nil, constructionError(stable.ID, "", StageChunk, stable.Chunk.Module, "", err)
}
prepared.chunker = chunker
prepared.chunkValidators, err = prepareValidatorChain(stable, registries, deps, StageChunk, "", stable.Chunk.Module)
if err != nil {
return nil, err
}
prepared.ArtifactLanes = make([]PreparedArtifactLane, 0, len(stable.ArtifactLanes))
prepared.lanes = make([]preparedLaneExecutor, 0, len(stable.ArtifactLanes))
for _, lane := range stable.ArtifactLanes {
executor, err := prepareLane(stable, lane, registries, deps)
if err != nil {
return nil, err
}
prepared.ArtifactLanes = append(prepared.ArtifactLanes, PreparedArtifactLane{Resolved: cloneResolvedArtifactLane(lane)})
prepared.lanes = append(prepared.lanes, executor)
}
output, err := registries.Outputs.BuildWithRequest(stable.Output.Module, request(stable.Output))
if err != nil {
return nil, constructionError(stable.ID, "", StageOutput, stable.Output.Module, "", err)
}
prepared.output = output
return prepared, nil
}
func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registries Registries, deps ModuleDependencies) (preparedLaneExecutor, error) {
executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)}
request := func(binding ModuleBinding) BuildRequest {
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options)}
}
if lane.ArtifactKind == "" {
extractor, err := registries.Extractors.BuildLegacyRawWithRequest(lane.Extract.Module, request(lane.Extract))
if err != nil {
return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", err)
}
executor.legacy = &preparedLegacyLane{extractor: extractor}
} else {
entry, ok := registries.Extractors.typedEntry(lane.Extract.Module)
if !ok {
return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", fmt.Errorf("typed construction entry is not registered"))
}
module, err := buildErasedModule(entry.builder, request(lane.Extract), lane.Extract.Module, "extractor")
if err != nil {
return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", err)
}
executor.typed = &preparedTypedLane{extractor: module}
}
var err error
executor.extractValidators, err = prepareValidatorChain(pipeline, registries, deps, StageExtract, lane.ID, lane.Extract.Module)
if err != nil {
return preparedLaneExecutor{}, err
}
if lane.ArtifactKind == "" {
module, err := registries.Mergers.BuildLegacyRawWithRequest(lane.Merge.Module, request(lane.Merge))
if err != nil {
return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageMerge, lane.Merge.Module, "", err)
}
executor.legacy.merger = module
} else {
entry, ok := registries.Mergers.typedEntry(lane.Merge.Module, lane.ArtifactKind)
if !ok {
return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageMerge, lane.Merge.Module, "", fmt.Errorf("typed construction entry is not registered"))
}
module, err := buildErasedModule(entry.builder, request(lane.Merge), lane.Merge.Module, "merger")
if err != nil {
return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageMerge, lane.Merge.Module, "", err)
}
executor.typed.merger = module
}
executor.mergeValidators, err = prepareValidatorChain(pipeline, registries, deps, StageMerge, lane.ID, lane.Merge.Module)
if err != nil {
return preparedLaneExecutor{}, err
}
if lane.ArtifactKind == "" {
module, err := registries.Normalizers.BuildLegacyRawWithRequest(lane.Normalize.Module, request(lane.Normalize))
if err != nil {
return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageNormalize, lane.Normalize.Module, "", err)
}
executor.legacy.normalizer = module
} else {
entry, ok := registries.Normalizers.typedEntry(lane.Normalize.Module, lane.ArtifactKind)
if !ok {
return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageNormalize, lane.Normalize.Module, "", fmt.Errorf("typed construction entry is not registered"))
}
module, err := buildErasedModule(entry.builder, request(lane.Normalize), lane.Normalize.Module, "normalizer")
if err != nil {
return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageNormalize, lane.Normalize.Module, "", err)
}
executor.typed.normalizer = module
}
executor.normalizeValidators, err = prepareValidatorChain(pipeline, registries, deps, StageNormalize, lane.ID, lane.Normalize.Module)
if err != nil {
return preparedLaneExecutor{}, err
}
return executor, nil
}
func prepareValidatorChain(pipeline ResolvedPipeline, registries Registries, deps ModuleDependencies, stage ModuleStage, laneID, moduleKey string) (preparedValidatorChain, error) {
resolved := resolvedValidatorChain(stage, laneID, moduleKey, pipeline.ValidatorChains)
prepared := preparedValidatorChain{resolved: resolved}
for _, validator := range resolved.Validators {
request := BuildRequest{Dependencies: deps, Options: cloneOptions(validator.Binding.Options)}
built, err := buildPreparedValidator(registries.Validators, validator, request)
if err != nil {
return preparedValidatorChain{}, constructionError(pipeline.ID, laneID, stage, moduleKey, validator.Binding.Module, err)
}
prepared.validators = append(prepared.validators, built)
}
return prepared, nil
}
func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValidator, request BuildRequest) (preparedValidator, error) {
prepared := preparedValidator{resolved: resolved}
key := resolved.Binding.Module
var implementation any
var err error
switch resolved.Target {
case ValidatorTargetTyped:
entry, ok := registry.typedEntry(key, resolved.ArtifactKind)
if !ok {
return preparedValidator{}, fmt.Errorf("typed construction entry is not registered")
}
implementation, err = entry.builder(cloneBuildRequest(request))
prepared.typed = implementation
case ValidatorTargetChunk:
entry, ok := registry.chunkEntry(key)
if !ok {
return preparedValidator{}, fmt.Errorf("chunk construction entry is not registered")
}
prepared.chunk, err = entry.builder(cloneBuildRequest(request))
implementation = prepared.chunk
case ValidatorTargetSerialized:
entry, ok := registry.serializedEntry(key)
if !ok {
return preparedValidator{}, fmt.Errorf("serialized construction entry is not registered")
}
prepared.serialized, err = entry.builder(cloneBuildRequest(request))
implementation = prepared.serialized
default:
prepared.legacy, err = registry.BuildLegacyRawWithRequest(key, request)
implementation = prepared.legacy
}
if err != nil {
return preparedValidator{}, err
}
if isNilImplementation(implementation) {
return preparedValidator{}, fmt.Errorf("validator %q builder returned nil", key)
}
identity, ok := implementation.(interface {
Name() string
ExecutionClass() contracts.ExecutionClass
})
if !ok {
return preparedValidator{}, fmt.Errorf("validator %q builder returned incompatible implementation %T", key, implementation)
}
if identity.Name() != key {
return preparedValidator{}, fmt.Errorf("validator %q returned name %q", key, identity.Name())
}
if identity.ExecutionClass() != resolved.ExecutionClass {
return preparedValidator{}, fmt.Errorf("validator %q returned execution class %q, want %q", key, identity.ExecutionClass(), resolved.ExecutionClass)
}
return prepared, nil
}
func buildErasedModule(builder func(BuildRequest) (any, error), request BuildRequest, key, kind string) (any, error) {
implementation, err := builder(cloneBuildRequest(request))
if err != nil {
return nil, err
}
if isNilImplementation(implementation) {
return nil, fmt.Errorf("%s %q builder returned nil", kind, key)
}
identity, ok := implementation.(interface{ Key() string })
if !ok {
return nil, fmt.Errorf("%s %q builder returned incompatible implementation %T", kind, key, implementation)
}
if identity.Key() != key {
return nil, fmt.Errorf("%s %q returned key %q", kind, key, identity.Key())
}
return implementation, nil
}
func isNilImplementation(value any) bool {
if value == nil {
return true
}
reflected := reflect.ValueOf(value)
switch reflected.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return reflected.IsNil()
default:
return false
}
}
func constructionError(pipelineID, laneID string, stage ModuleStage, moduleKey, validatorKey string, cause error) error {
scope := fmt.Sprintf("pipeline %q %s module %q", pipelineID, stage, moduleKey)
if laneID != "" {
scope = fmt.Sprintf("pipeline %q lane %q %s module %q", pipelineID, laneID, stage, moduleKey)
}
if strings.TrimSpace(validatorKey) != "" {
scope += fmt.Sprintf(" validator %q", validatorKey)
}
return fmt.Errorf("prepare %s: %w", scope, cause)
}
func (registries Registries) catalog() ModuleCatalog {
return ModuleCatalog{
Inputs: registries.Inputs, Chunkers: registries.Chunkers, ArtifactCodecs: registries.ArtifactCodecs,
Extractors: registries.Extractors, Mergers: registries.Mergers, Normalizers: registries.Normalizers,
Validators: registries.Validators, ValidatorChains: registries.ValidatorChains, Outputs: registries.Outputs,
}
}
func validateRegistrySet(resolved ResolvedPipeline, registries Registries) error {
if registries.Inputs == nil {
return fmt.Errorf("input registry must not be nil")
}
if registries.Chunkers == nil {
return fmt.Errorf("chunker registry must not be nil")
}
if registries.Extractors == nil {
return fmt.Errorf("extractor registry must not be nil")
}
if registries.Mergers == nil {
return fmt.Errorf("merger registry must not be nil")
}
if registries.Normalizers == nil {
return fmt.Errorf("normalizer registry must not be nil")
}
if registries.Validators == nil {
for _, chain := range resolved.ValidatorChains {
if len(chain.Validators) > 0 {
return fmt.Errorf("validator registry must not be nil")
}
}
}
if registries.Outputs == nil {
return fmt.Errorf("output encoder registry must not be nil")
}
return nil
}
func cloneResolvedPipeline(in ResolvedPipeline) ResolvedPipeline {
out := in
out.Input = cloneModuleBinding(in.Input)
out.Chunk = cloneModuleBinding(in.Chunk)
out.Output = cloneModuleBinding(in.Output)
out.ChunkReferences = CloneReferenceTarget(in.ChunkReferences)
out.ValidatorChains = cloneResolvedValidatorChains(in.ValidatorChains)
if len(in.ArtifactLanes) > 0 {
out.ArtifactLanes = make([]ResolvedArtifactLane, len(in.ArtifactLanes))
for i, lane := range in.ArtifactLanes {
out.ArtifactLanes[i] = cloneResolvedArtifactLane(lane)
}
}
return out
}
func cloneResolvedArtifactLane(in ResolvedArtifactLane) ResolvedArtifactLane {
out := in
out.Extract = cloneModuleBinding(in.Extract)
out.Merge = cloneModuleBinding(in.Merge)
out.Normalize = cloneModuleBinding(in.Normalize)
out.Validators = cloneModuleBindings(in.Validators)
out.ExtractReferences = CloneReferenceTarget(in.ExtractReferences)
out.MergeReferences = CloneReferenceTarget(in.MergeReferences)
out.NormalizeReferences = CloneReferenceTarget(in.NormalizeReferences)
return out
}

View File

@@ -249,6 +249,9 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
if missing, ok := outputCapabilities.missing(outputSpec.Requires); ok { if missing, ok := outputCapabilities.missing(outputSpec.Requires); ok {
return ResolvedPipeline{}, capabilityError(pipelineID, "", StageOutput, resolved.Output.Module, missing) return ResolvedPipeline{}, capabilityError(pipelineID, "", StageOutput, resolved.Output.Module, missing)
} }
if err := validateResolvedOptions(resolved, catalog); err != nil {
return ResolvedPipeline{}, err
}
digest, err := resolvedPipelineDigest(resolved) digest, err := resolvedPipelineDigest(resolved)
if err != nil { if err != nil {

View File

@@ -14,8 +14,8 @@ func TestRunnerUsesRegistries(t *testing.T) {
var executed []string var executed []string
registries := integrationRegistries(t, &built, &executed) registries := integrationRegistries(t, &built, &executed)
output, err := New(registries).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, registries).Run(context.Background(), RunInput{
Pipeline: integrationPipeline(), pipeline: integrationPipeline(),
SourceID: "source-1", SourceID: "source-1",
RawInput: []byte("source text"), RawInput: []byte("source text"),
}) })

View File

@@ -29,20 +29,17 @@ type Registries struct {
Outputs *OutputEncoderRegistry Outputs *OutputEncoderRegistry
} }
type Runner struct { type Runner struct{}
registries Registries
}
func New(registries Registries) *Runner { func New() *Runner {
return &Runner{registries: registries} return &Runner{}
} }
type RunInput struct { type RunInput struct {
Pipeline ResolvedPipeline Prepared *PreparedPipeline
SourceID string SourceID string
Path string Path string
RawInput []byte RawInput []byte
LLMClient contracts.StructuredLLMClient
SessionID string SessionID string
RunID string RunID string
StartedAt time.Time StartedAt time.Time
@@ -52,6 +49,9 @@ type RunInput struct {
Checkpoints CheckpointRecorder Checkpoints CheckpointRecorder
Checkpoint CheckpointLoader Checkpoint CheckpointLoader
Debug DebugRecorder Debug DebugRecorder
pipeline ResolvedPipeline
llmClient contracts.StructuredLLMClient
} }
type RunOutput struct { type RunOutput struct {
@@ -70,9 +70,8 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if err := validateRunInput(input); err != nil { if err := validateRunInput(input); err != nil {
return output, err return output, err
} }
if err := r.validateRegistries(input.Pipeline); err != nil { input.pipeline = input.Prepared.resolved
return output, err input.llmClient = input.Prepared.dependencies.LLM
}
output.Manifest = manifestFromPipeline(input) output.Manifest = manifestFromPipeline(input)
checkpoints := input.Checkpoints checkpoints := input.Checkpoints
@@ -88,27 +87,24 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
debugRecorder = NoopDebugRecorder() debugRecorder = NoopDebugRecorder()
} }
input.Debug = debugRecorder input.Debug = debugRecorder
input.LLMClient = wrapDebugLLMClient(input.LLMClient, debugRecorder) input.llmClient = wrapDebugLLMClient(input.llmClient, debugRecorder)
defer func() { defer func() {
output.Manifest.LLMProfiles = mergeLLMProfileManifests(input.LLMProfiles, llmProfileManifests(input.LLMClient)) output.Manifest.LLMProfiles = mergeLLMProfileManifests(input.LLMProfiles, llmProfileManifests(input.llmClient))
}() }()
output.Warnings = append(output.Warnings, cloneWarnings(input.Warnings)...) output.Warnings = append(output.Warnings, cloneWarnings(input.Warnings)...)
if err := writeDebugTimed(debugRecorder, "run.json", debugTimedEnvelope{ if err := writeDebugTimed(debugRecorder, "run.json", debugTimedEnvelope{
Stage: "run", Stage: "run",
StartedAt: startedTime(input.StartedAt), StartedAt: startedTime(input.StartedAt),
Payload: map[string]any{ Payload: map[string]any{
"pipeline_id": input.Pipeline.ID, "pipeline_id": input.pipeline.ID,
"pipeline_digest": input.Pipeline.Digest, "pipeline_digest": input.pipeline.Digest,
"run_id": output.Manifest.RunID, "run_id": output.Manifest.RunID,
}, },
}); err != nil { }); err != nil {
return failOutput(output), fmt.Errorf("write debug run artifact: %w", err) return failOutput(output), fmt.Errorf("write debug run artifact: %w", err)
} }
adapter, err := r.registries.Inputs.Build(input.Pipeline.Input.Module) adapter := input.Prepared.input
if err != nil {
return failOutput(output), fmt.Errorf("build input adapter %q: %w", input.Pipeline.Input.Module, err)
}
attachModuleManifestMetadata(&output, "input", adapter) attachModuleManifestMetadata(&output, "input", adapter)
sourceCheckpoint, sourceDecision := checkpointLoader.Source(adapter.Key()) sourceCheckpoint, sourceDecision := checkpointLoader.Source(adapter.Key())
recordCheckpointEvent(&output, checkpointLoader, "source", "", adapter.Key(), sourceDecision) recordCheckpointEvent(&output, checkpointLoader, "source", "", adapter.Key(), sourceDecision)
@@ -122,7 +118,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
SourceID: input.SourceID, SourceID: input.SourceID,
Path: input.Path, Path: input.Path,
Raw: debugContentEnvelope(input.RawInput, sourceInputMediaType(input.Path), nil, nil), Raw: debugContentEnvelope(input.RawInput, sourceInputMediaType(input.Path), nil, nil),
Options: redactSensitiveMap(input.Pipeline.Input.Options), Options: redactSensitiveMap(input.pipeline.Input.Options),
Metadata: redactSensitiveMap(input.Metadata), Metadata: redactSensitiveMap(input.Metadata),
}, },
}); err != nil { }); err != nil {
@@ -136,8 +132,8 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
SourceID: input.SourceID, SourceID: input.SourceID,
Path: input.Path, Path: input.Path,
Raw: input.RawInput, Raw: input.RawInput,
LLMProfile: input.Pipeline.Input.LLMProfile, LLMProfile: input.pipeline.Input.LLMProfile,
Options: cloneOptions(input.Pipeline.Input.Options), Options: cloneOptions(input.pipeline.Input.Options),
Metadata: input.Metadata, Metadata: input.Metadata,
}) })
if err != nil { if err != nil {
@@ -169,10 +165,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
output.Manifest.Metadata = manifestMetadataWithSessionID(output.Manifest.Metadata, sessionID) output.Manifest.Metadata = manifestMetadataWithSessionID(output.Manifest.Metadata, sessionID)
output.Manifest.SourceDigests = []string{doc.Digest} output.Manifest.SourceDigests = []string{doc.Digest}
chunker, err := r.registries.Chunkers.Build(input.Pipeline.Chunk.Module) chunker := input.Prepared.chunker
if err != nil {
return failOutput(output), fmt.Errorf("build chunker %q: %w", input.Pipeline.Chunk.Module, err)
}
attachModuleManifestMetadata(&output, "chunker", chunker) attachModuleManifestMetadata(&output, "chunker", chunker)
var canonicalChunks []source.Chunk var canonicalChunks []source.Chunk
var chunkWarnings []contracts.Warning var chunkWarnings []contracts.Warning
@@ -188,7 +181,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
"decision": chunkDecision, "decision": chunkDecision,
"source": debugSourceDocumentEnvelope(doc), "source": debugSourceDocumentEnvelope(doc),
"source_input": debugContentEnvelope(sourceInput.Content, sourceInput.MediaType, nil, nil), "source_input": debugContentEnvelope(sourceInput.Content, sourceInput.MediaType, nil, nil),
"options": redactSensitiveMap(input.Pipeline.Chunk.Options), "options": redactSensitiveMap(input.pipeline.Chunk.Options),
"metadata": redactSensitiveMap(input.Metadata), "metadata": redactSensitiveMap(input.Metadata),
}, },
}); err != nil { }); err != nil {
@@ -204,7 +197,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if err := checkpoints.ChunkRunning(chunker.Key(), doc.Digest); err != nil { if err := checkpoints.ChunkRunning(chunker.Key(), doc.Digest); err != nil {
return failOutput(output), fmt.Errorf("write chunk checkpoint: %w", err) return failOutput(output), fmt.Errorf("write chunk checkpoint: %w", err)
} }
chunksAccepted, chunkRejection, err = runWithRetry(ctx, input.Pipeline.Chunk.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) { chunksAccepted, chunkRejection, err = runWithRetry(ctx, input.pipeline.Chunk.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
attemptStarted := time.Now().UTC() attemptStarted := time.Now().UTC()
attemptPath := path.Join("chunk", fmt.Sprintf("attempt-%02d", attempt)) attemptPath := path.Join("chunk", fmt.Sprintf("attempt-%02d", attempt))
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath) attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
@@ -212,10 +205,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
Source: doc, Source: doc,
SourceInput: sourceInput.Clone(), SourceInput: sourceInput.Clone(),
SessionID: sessionID, SessionID: sessionID,
References: CloneReferenceSet(input.Pipeline.ChunkReferences.ReferenceSet), References: CloneReferenceSet(input.pipeline.ChunkReferences.ReferenceSet),
LLMClient: input.LLMClient, LLMClient: input.llmClient,
LLMProfile: input.Pipeline.Chunk.LLMProfile, LLMProfile: input.pipeline.Chunk.LLMProfile,
Options: cloneOptions(input.Pipeline.Chunk.Options), Options: cloneOptions(input.pipeline.Chunk.Options),
Metadata: input.Metadata, Metadata: input.Metadata,
}) })
if err != nil { if err != nil {
@@ -254,7 +247,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
}, llmScope)) }, llmScope))
return false, nil, err return false, nil, err
} }
validationWarnings, rejection, err := r.validateChunksRaw(attemptCtx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.Pipeline.ChunkReferences.ReferenceSet, input.LLMClient, input.Metadata, input.Pipeline.ValidatorChains, attempt, input.Debug) validationWarnings, rejection, err := r.validateChunksRaw(attemptCtx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.pipeline.ChunkReferences.ReferenceSet, input.llmClient, input.Metadata, input.Prepared.chunkValidators, attempt, input.Debug)
if err != nil || rejection != nil { if err != nil || rejection != nil {
_ = writeDebugTimed(debugRecorder, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ _ = writeDebugTimed(debugRecorder, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{
Stage: string(StageChunk), Stage: string(StageChunk),
@@ -320,7 +313,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
} }
if chunksAccepted { if chunksAccepted {
for _, lane := range input.Pipeline.ArtifactLanes { for _, lane := range input.Prepared.lanes {
if err := r.runLane(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, canonicalChunks, lane, &output); err != nil { if err := r.runLane(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, canonicalChunks, lane, &output); err != nil {
return failOutput(output), err return failOutput(output), err
} }
@@ -335,10 +328,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
populateRawOutputManifest(&output) populateRawOutputManifest(&output)
output.Manifest.CompletedAt = timePtr(time.Now().UTC()) output.Manifest.CompletedAt = timePtr(time.Now().UTC())
encoder, err := r.registries.Outputs.Build(input.Pipeline.Output.Module) encoder := input.Prepared.output
if err != nil {
return failOutput(output), fmt.Errorf("build output encoder %q: %w", input.Pipeline.Output.Module, err)
}
attachModuleManifestMetadata(&output, "output", encoder) attachModuleManifestMetadata(&output, "output", encoder)
outputStarted := time.Now().UTC() outputStarted := time.Now().UTC()
if err := writeDebugTimed(debugRecorder, "output/input.json", debugTimedEnvelope{ if err := writeDebugTimed(debugRecorder, "output/input.json", debugTimedEnvelope{
@@ -350,7 +340,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
"normalize_outputs": debugNormalizeOutputEnvelopes(output.NormalizeOutputs), "normalize_outputs": debugNormalizeOutputEnvelopes(output.NormalizeOutputs),
"rejected": debugRejectedOutputEnvelopes(output.Rejected), "rejected": debugRejectedOutputEnvelopes(output.Rejected),
"warnings": output.Warnings, "warnings": output.Warnings,
"options": redactSensitiveMap(input.Pipeline.Output.Options), "options": redactSensitiveMap(input.pipeline.Output.Options),
"metadata": redactSensitiveMap(input.Metadata), "metadata": redactSensitiveMap(input.Metadata),
}, },
}); err != nil { }); err != nil {
@@ -361,8 +351,8 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
NormalizeOutputs: cloneNormalizeOutputs(output.NormalizeOutputs), NormalizeOutputs: cloneNormalizeOutputs(output.NormalizeOutputs),
Rejected: cloneRejectedOutputs(output.Rejected), Rejected: cloneRejectedOutputs(output.Rejected),
Warnings: output.Warnings, Warnings: output.Warnings,
LLMProfile: input.Pipeline.Output.LLMProfile, LLMProfile: input.pipeline.Output.LLMProfile,
Options: cloneOptions(input.Pipeline.Output.Options), Options: cloneOptions(input.pipeline.Output.Options),
Metadata: input.Metadata, Metadata: input.Metadata,
}) })
output.Warnings = append(output.Warnings, encoded.Warnings...) output.Warnings = append(output.Warnings, encoded.Warnings...)
@@ -389,19 +379,14 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
return output, nil return output, nil
} }
func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints CheckpointRecorder, checkpointLoader CheckpointLoader, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, chunks []source.Chunk, lane ResolvedArtifactLane, output *RunOutput) error { func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints CheckpointRecorder, checkpointLoader CheckpointLoader, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, chunks []source.Chunk, prepared preparedLaneExecutor, output *RunOutput) error {
extractor, err := r.registries.Extractors.BuildLegacyRaw(lane.Extract.Module) lane := prepared.resolved
if err != nil { if prepared.legacy == nil {
return fmt.Errorf("build extractor %q for lane %q: %w", lane.Extract.Module, lane.ID, err) return fmt.Errorf("resolved pipeline lane %q uses typed artifact kind %q, which the legacy raw runner cannot execute", lane.ID, lane.ArtifactKind)
}
merger, err := r.registries.Mergers.BuildLegacyRaw(lane.Merge.Module)
if err != nil {
return fmt.Errorf("build merger %q for lane %q: %w", lane.Merge.Module, lane.ID, err)
}
normalizer, err := r.registries.Normalizers.BuildLegacyRaw(lane.Normalize.Module)
if err != nil {
return fmt.Errorf("build normalizer %q for lane %q: %w", lane.Normalize.Module, lane.ID, err)
} }
extractor := prepared.legacy.extractor
merger := prepared.legacy.merger
normalizer := prepared.legacy.normalizer
setLaneManifestMetadata(output, lane.ID, extractor, merger, normalizer) setLaneManifestMetadata(output, lane.ID, extractor, merger, normalizer)
extractOutputs := make([]contracts.ExtractOutput, 0, len(chunks)) extractOutputs := make([]contracts.ExtractOutput, 0, len(chunks))
@@ -454,7 +439,7 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
SourceInput: chunkInputMaterial(sourceInput, chunk), SourceInput: chunkInputMaterial(sourceInput, chunk),
SessionID: sessionID, SessionID: sessionID,
References: CloneReferenceSet(lane.ExtractReferences.ReferenceSet), References: CloneReferenceSet(lane.ExtractReferences.ReferenceSet),
LLMClient: input.LLMClient, LLMClient: input.llmClient,
LLMProfile: lane.Extract.LLMProfile, LLMProfile: lane.Extract.LLMProfile,
Options: cloneOptions(lane.Extract.Options), Options: cloneOptions(lane.Extract.Options),
Metadata: input.Metadata, Metadata: input.Metadata,
@@ -489,11 +474,11 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
sourceInput: chunkInputMaterial(sourceInput, chunk), sourceInput: chunkInputMaterial(sourceInput, chunk),
sessionID: sessionID, sessionID: sessionID,
references: lane.ExtractReferences.ReferenceSet, references: lane.ExtractReferences.ReferenceSet,
llmClient: input.LLMClient, llmClient: input.llmClient,
schema: extractOutput.Schema, schema: extractOutput.Schema,
payload: extractOutput.Payload, payload: extractOutput.Payload,
metadata: input.Metadata, metadata: input.Metadata,
chains: input.Pipeline.ValidatorChains, prepared: prepared.extractValidators,
attempt: attempt, attempt: attempt,
debug: input.Debug, debug: input.Debug,
}) })
@@ -606,7 +591,7 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
SourceInput: sourceInput.Clone(), SourceInput: sourceInput.Clone(),
SessionID: sessionID, SessionID: sessionID,
References: CloneReferenceSet(lane.MergeReferences.ReferenceSet), References: CloneReferenceSet(lane.MergeReferences.ReferenceSet),
LLMClient: input.LLMClient, LLMClient: input.llmClient,
LLMProfile: lane.Merge.LLMProfile, LLMProfile: lane.Merge.LLMProfile,
Options: cloneOptions(lane.Merge.Options), Options: cloneOptions(lane.Merge.Options),
Metadata: input.Metadata, Metadata: input.Metadata,
@@ -636,12 +621,12 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
sourceInput: sourceInput.Clone(), sourceInput: sourceInput.Clone(),
sessionID: sessionID, sessionID: sessionID,
references: lane.MergeReferences.ReferenceSet, references: lane.MergeReferences.ReferenceSet,
llmClient: input.LLMClient, llmClient: input.llmClient,
schema: mergeOutput.Schema, schema: mergeOutput.Schema,
payload: mergeOutput.Payload, payload: mergeOutput.Payload,
extractOutputs: extractOutputs, extractOutputs: extractOutputs,
metadata: input.Metadata, metadata: input.Metadata,
chains: input.Pipeline.ValidatorChains, prepared: prepared.mergeValidators,
attempt: attempt, attempt: attempt,
debug: input.Debug, debug: input.Debug,
}) })
@@ -762,7 +747,7 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
SourceInput: sourceInput.Clone(), SourceInput: sourceInput.Clone(),
SessionID: sessionID, SessionID: sessionID,
References: CloneReferenceSet(lane.NormalizeReferences.ReferenceSet), References: CloneReferenceSet(lane.NormalizeReferences.ReferenceSet),
LLMClient: input.LLMClient, LLMClient: input.llmClient,
LLMProfile: lane.Normalize.LLMProfile, LLMProfile: lane.Normalize.LLMProfile,
Options: cloneOptions(lane.Normalize.Options), Options: cloneOptions(lane.Normalize.Options),
Metadata: input.Metadata, Metadata: input.Metadata,
@@ -792,12 +777,12 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
sourceInput: sourceInput.Clone(), sourceInput: sourceInput.Clone(),
sessionID: sessionID, sessionID: sessionID,
references: lane.NormalizeReferences.ReferenceSet, references: lane.NormalizeReferences.ReferenceSet,
llmClient: input.LLMClient, llmClient: input.llmClient,
schema: normalizeOutput.Schema, schema: normalizeOutput.Schema,
payload: normalizeOutput.Payload, payload: normalizeOutput.Payload,
mergeOutput: acceptedMerge, mergeOutput: acceptedMerge,
metadata: input.Metadata, metadata: input.Metadata,
chains: input.Pipeline.ValidatorChains, prepared: prepared.normalizeValidators,
attempt: attempt, attempt: attempt,
debug: input.Debug, debug: input.Debug,
}) })
@@ -899,7 +884,7 @@ type rawValidationTarget struct {
extractOutputs []contracts.ExtractOutput extractOutputs []contracts.ExtractOutput
mergeOutput contracts.MergeOutput mergeOutput contracts.MergeOutput
metadata map[string]any metadata map[string]any
chains []ResolvedValidatorChain prepared preparedValidatorChain
attempt int attempt int
debug DebugRecorder debug DebugRecorder
} }
@@ -951,7 +936,7 @@ func runWithRetry(ctx context.Context, retries int, run func(attempt int) (bool,
return false, lastRejection, nil return false, lastRejection, nil
} }
func (r *Runner) validateChunksRaw(ctx context.Context, doc *source.SourceDocument, moduleKey string, chunks []source.Chunk, sourceInput contracts.LLMInputMaterial, sessionID string, references contracts.ReferenceSet, llmClient contracts.StructuredLLMClient, metadata map[string]any, chains []ResolvedValidatorChain, attempt int, debug DebugRecorder) ([]contracts.Warning, *contracts.RejectedOutput, error) { func (r *Runner) validateChunksRaw(ctx context.Context, doc *source.SourceDocument, moduleKey string, chunks []source.Chunk, sourceInput contracts.LLMInputMaterial, sessionID string, references contracts.ReferenceSet, llmClient contracts.StructuredLLMClient, metadata map[string]any, prepared preparedValidatorChain, attempt int, debug DebugRecorder) ([]contracts.Warning, *contracts.RejectedOutput, error) {
return r.validateRaw(ctx, rawValidationTarget{ return r.validateRaw(ctx, rawValidationTarget{
stage: StageChunk, stage: StageChunk,
moduleKey: moduleKey, moduleKey: moduleKey,
@@ -963,26 +948,23 @@ func (r *Runner) validateChunksRaw(ctx context.Context, doc *source.SourceDocume
llmClient: llmClient, llmClient: llmClient,
chunks: chunks, chunks: chunks,
metadata: metadata, metadata: metadata,
chains: chains, prepared: prepared,
attempt: attempt, attempt: attempt,
debug: debug, debug: debug,
}) })
} }
func (r *Runner) validateRaw(ctx context.Context, target rawValidationTarget) ([]contracts.Warning, *contracts.RejectedOutput, error) { func (r *Runner) validateRaw(ctx context.Context, target rawValidationTarget) ([]contracts.Warning, *contracts.RejectedOutput, error) {
chain := resolvedValidatorChain(target.stage, target.laneID, target.moduleKey, target.chains) if len(target.prepared.validators) == 0 {
if len(chain.Validators) == 0 {
return nil, nil, nil return nil, nil, nil
} }
if r.registries.Validators == nil {
return nil, nil, fmt.Errorf("validator registry must not be nil")
}
var warnings []contracts.Warning var warnings []contracts.Warning
for index, validatorBinding := range chain.Validators { for index, preparedValidator := range target.prepared.validators {
validator, err := r.registries.Validators.BuildLegacyRaw(validatorBinding.Binding.Module) validator := preparedValidator.legacy
if err != nil { validatorBinding := preparedValidator.resolved
return nil, nil, fmt.Errorf("build validator %q: %w", validatorBinding.Binding.Module, err) if validator == nil {
return nil, nil, fmt.Errorf("validator %q is not available on the legacy raw path", validatorBinding.Binding.Module)
} }
request := target.validationRequest(validatorBinding.Binding) request := target.validationRequest(validatorBinding.Binding)
started := time.Now().UTC() started := time.Now().UTC()
@@ -1088,52 +1070,37 @@ func resolvedValidatorChain(stage ModuleStage, laneID string, moduleKey string,
} }
} }
func (r *Runner) validateRegistries(pipeline ResolvedPipeline) error { func validateRunInput(input RunInput) error {
if r.registries.Inputs == nil { if input.Prepared == nil {
return fmt.Errorf("input registry must not be nil") return fmt.Errorf("prepared pipeline must not be nil")
} }
if r.registries.Chunkers == nil { return validateResolvedPipeline(input.Prepared.resolved, true)
return fmt.Errorf("chunker registry must not be nil")
}
if r.registries.Extractors == nil {
return fmt.Errorf("extractor registry must not be nil")
}
if r.registries.Mergers == nil {
return fmt.Errorf("merger registry must not be nil")
}
if r.registries.Normalizers == nil {
return fmt.Errorf("normalizer registry must not be nil")
}
if r.registries.Outputs == nil {
return fmt.Errorf("output encoder registry must not be nil")
}
return nil
} }
func validateRunInput(input RunInput) error { func validateResolvedPipeline(pipeline ResolvedPipeline, rejectTyped bool) error {
if input.Pipeline.ID == "" { if pipeline.ID == "" {
return fmt.Errorf("resolved pipeline id must not be empty") return fmt.Errorf("resolved pipeline id must not be empty")
} }
if input.Pipeline.Digest == "" { if pipeline.Digest == "" {
return fmt.Errorf("resolved pipeline digest must not be empty") return fmt.Errorf("resolved pipeline digest must not be empty")
} }
if input.Pipeline.Input.Module == "" { if pipeline.Input.Module == "" {
return fmt.Errorf("resolved pipeline input module must not be empty") return fmt.Errorf("resolved pipeline input module must not be empty")
} }
if input.Pipeline.Chunk.Module == "" { if pipeline.Chunk.Module == "" {
return fmt.Errorf("resolved pipeline chunk module must not be empty") return fmt.Errorf("resolved pipeline chunk module must not be empty")
} }
if input.Pipeline.Output.Module == "" { if pipeline.Output.Module == "" {
return fmt.Errorf("resolved pipeline output module must not be empty") return fmt.Errorf("resolved pipeline output module must not be empty")
} }
if len(input.Pipeline.ArtifactLanes) == 0 { if len(pipeline.ArtifactLanes) == 0 {
return fmt.Errorf("resolved pipeline artifact lanes must not be empty") return fmt.Errorf("resolved pipeline artifact lanes must not be empty")
} }
for _, lane := range input.Pipeline.ArtifactLanes { for _, lane := range pipeline.ArtifactLanes {
if lane.ID == "" { if lane.ID == "" {
return fmt.Errorf("resolved pipeline artifact lane id must not be empty") return fmt.Errorf("resolved pipeline artifact lane id must not be empty")
} }
if lane.ArtifactKind != "" { if rejectTyped && lane.ArtifactKind != "" {
return fmt.Errorf("resolved pipeline lane %q uses typed artifact kind %q, which the legacy raw runner cannot execute", lane.ID, lane.ArtifactKind) return fmt.Errorf("resolved pipeline lane %q uses typed artifact kind %q, which the legacy raw runner cannot execute", lane.ID, lane.ArtifactKind)
} }
if lane.Extract.Module == "" { if lane.Extract.Module == "" {
@@ -1169,7 +1136,7 @@ func manifestFromPipeline(input RunInput) artifacts.RunManifest {
runID = fmt.Sprintf("run-%d", startedAt.UnixNano()) runID = fmt.Sprintf("run-%d", startedAt.UnixNano())
} }
pipeline := input.Pipeline pipeline := input.pipeline
manifest := artifacts.RunManifest{ manifest := artifacts.RunManifest{
PipelineID: pipeline.ID, PipelineID: pipeline.ID,
PipelineDigest: pipeline.Digest, PipelineDigest: pipeline.Digest,

View File

@@ -14,17 +14,17 @@ import (
) )
func TestNewAndDataTypes(t *testing.T) { func TestNewAndDataTypes(t *testing.T) {
runner := New(Registries{}) runner := New()
if runner == nil { if runner == nil {
t.Fatal("New() = nil, want runner") t.Fatal("New() = nil, want runner")
} }
input := RunInput{ input := RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
SourceID: "source-1", SourceID: "source-1",
Path: "input.txt", Path: "input.txt",
RawInput: []byte("source text"), RawInput: []byte("source text"),
LLMClient: fakeLLMClient{}, llmClient: fakeLLMClient{},
Metadata: map[string]any{"request": "test"}, Metadata: map[string]any{"request": "test"},
} }
output := RunOutput{ output := RunOutput{
@@ -35,7 +35,7 @@ func TestNewAndDataTypes(t *testing.T) {
OutputFiles: []contracts.OutputFile{{Name: "outputs/generic.json", ContentType: "application/json", Bytes: []byte(`{}`)}}, OutputFiles: []contracts.OutputFile{{Name: "outputs/generic.json", ContentType: "application/json", Bytes: []byte(`{}`)}},
} }
if input.Pipeline.ID != "pipeline-1" || input.SourceID != "source-1" { if input.pipeline.ID != "pipeline-1" || input.SourceID != "source-1" {
t.Fatalf("RunInput = %#v, want constructed fields", input) t.Fatalf("RunInput = %#v, want constructed fields", input)
} }
if output.Manifest.PipelineID != "pipeline-1" || len(output.NormalizeOutputs) != 1 || len(output.Rejected) != 1 || len(output.Warnings) != 1 || len(output.OutputFiles) != 1 { if output.Manifest.PipelineID != "pipeline-1" || len(output.NormalizeOutputs) != 1 || len(output.Rejected) != 1 || len(output.Warnings) != 1 || len(output.OutputFiles) != 1 {
@@ -43,6 +43,26 @@ func TestNewAndDataTypes(t *testing.T) {
} }
} }
type preparedRunnerHarness struct {
t *testing.T
registries Registries
}
func newPreparedRunner(t *testing.T, registries Registries) preparedRunnerHarness {
t.Helper()
return preparedRunnerHarness{t: t, registries: registries}
}
func (h preparedRunnerHarness) Run(ctx context.Context, input RunInput) (RunOutput, error) {
h.t.Helper()
prepared, err := Prepare(input.pipeline, h.registries, ModuleDependencies{LLM: input.llmClient})
if err != nil {
return RunOutput{}, err
}
input.Prepared = prepared
return New().Run(ctx, input)
}
func TestRunRejectsInvalidSetup(t *testing.T) { func TestRunRejectsInvalidSetup(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -54,10 +74,15 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) { return (*Runner)(nil).Run(context.Background(), RunInput{}) }, run: func() (RunOutput, error) { return (*Runner)(nil).Run(context.Background(), RunInput{}) },
error: "runner must not be nil", error: "runner must not be nil",
}, },
{
name: "nil prepared pipeline",
run: func() (RunOutput, error) { return New().Run(context.Background(), RunInput{}) },
error: "prepared pipeline must not be nil",
},
{ {
name: "empty pipeline id", name: "empty pipeline id",
run: func() (RunOutput, error) { run: func() (RunOutput, error) {
return New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: ResolvedPipeline{Digest: "sha256:pipeline"}}) return newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: ResolvedPipeline{Digest: "sha256:pipeline"}})
}, },
error: "pipeline id", error: "pipeline id",
}, },
@@ -66,7 +91,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) { run: func() (RunOutput, error) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
pipeline.Digest = "" pipeline.Digest = ""
return New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: pipeline}) return newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: pipeline})
}, },
error: "pipeline digest", error: "pipeline digest",
}, },
@@ -75,7 +100,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) { run: func() (RunOutput, error) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
pipeline.ArtifactLanes = nil pipeline.ArtifactLanes = nil
return New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: pipeline}) return newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: pipeline})
}, },
error: "artifact lanes", error: "artifact lanes",
}, },
@@ -84,7 +109,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) { run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil) registries := newRunnerRegistries(t, nil)
registries.Inputs = nil registries.Inputs = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
}, },
error: "input registry", error: "input registry",
}, },
@@ -93,7 +118,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) { run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil) registries := newRunnerRegistries(t, nil)
registries.Chunkers = nil registries.Chunkers = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
}, },
error: "chunker registry", error: "chunker registry",
}, },
@@ -102,7 +127,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) { run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil) registries := newRunnerRegistries(t, nil)
registries.Extractors = nil registries.Extractors = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
}, },
error: "extractor registry", error: "extractor registry",
}, },
@@ -111,7 +136,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) { run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil) registries := newRunnerRegistries(t, nil)
registries.Mergers = nil registries.Mergers = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
}, },
error: "merger registry", error: "merger registry",
}, },
@@ -120,7 +145,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) { run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil) registries := newRunnerRegistries(t, nil)
registries.Normalizers = nil registries.Normalizers = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
}, },
error: "normalizer registry", error: "normalizer registry",
}, },
@@ -129,7 +154,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) { run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil) registries := newRunnerRegistries(t, nil)
registries.Outputs = nil registries.Outputs = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
}, },
error: "output encoder registry", error: "output encoder registry",
}, },
@@ -147,7 +172,7 @@ func TestRunAllowsNilValidatorRegistryWithoutConfiguredValidators(t *testing.T)
registries := newRunnerRegistries(t, nil) registries := newRunnerRegistries(t, nil)
registries.Validators = nil registries.Validators = nil
_, err := New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) _, err := newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -190,9 +215,15 @@ func TestRunRejectsInputBuildParseAndInvalidSourceErrors(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
test.configure(modules) test.configure(modules)
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
assertRunError(t, err, test.want) assertRunError(t, err, test.want)
if test.name == "input build" {
if output.Manifest.PipelineID != "" {
t.Fatalf("PipelineID = %q, want no run manifest for preparation failure", output.Manifest.PipelineID)
}
return
}
if output.Manifest.ValidationStatus != "failed" { if output.Manifest.ValidationStatus != "failed" {
t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus) t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus)
} }
@@ -236,9 +267,15 @@ func TestRunRejectsChunkerBuildChunkAndEmptyChunkErrors(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
test.configure(modules) test.configure(modules)
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
assertRunError(t, err, test.want) assertRunError(t, err, test.want)
if test.name == "chunker build" {
if output.Manifest.PipelineID != "" {
t.Fatalf("PipelineID = %q, want no run manifest for preparation failure", output.Manifest.PipelineID)
}
return
}
if output.Manifest.ValidationStatus != "failed" { if output.Manifest.ValidationStatus != "failed" {
t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus) t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus)
} }
@@ -364,7 +401,7 @@ func TestRunRejectsInvalidChunks(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
modules.chunker.chunks = test.chunks modules.chunker.chunks = test.chunks
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
assertRunError(t, err, test.want) assertRunError(t, err, test.want)
if output.Manifest.ValidationStatus != "failed" { if output.Manifest.ValidationStatus != "failed" {
@@ -384,7 +421,7 @@ func TestRunAllowsPartialCoverageAndOverlappingChunks(t *testing.T) {
chunkWithUnits("chunk-1", "source-1", 1, unitWithID("u2")), chunkWithUnits("chunk-1", "source-1", 1, unitWithID("u2")),
} }
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -419,7 +456,7 @@ func TestRunCanonicalizesChunkUnitsBeforeExtraction(t *testing.T) {
}, },
} }
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -478,7 +515,7 @@ func TestRunPreservesChunkMetadataDuringCanonicalization(t *testing.T) {
}, },
} }
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -508,9 +545,9 @@ func TestRunExecutesChunksAndPassesChunkAndLLMClient(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
llmClient := fakeLLMClient{} llmClient := fakeLLMClient{}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
LLMClient: llmClient, llmClient: llmClient,
Metadata: map[string]any{"request": "test"}, Metadata: map[string]any{"request": "test"},
}) })
if err != nil { if err != nil {
@@ -546,8 +583,8 @@ func TestRunPassesSourceInputAndSessionIDToPromptCapableStages(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
rawInput := []byte("{\"source\":\"exact bytes\"}") rawInput := []byte("{\"source\":\"exact bytes\"}")
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
Path: "session.json", Path: "session.json",
RawInput: rawInput, RawInput: rawInput,
SessionID: " explicit-session ", SessionID: " explicit-session ",
@@ -619,8 +656,8 @@ func TestRunPassesSourceInputAndSessionIDToPromptCapableStages(t *testing.T) {
func TestRunDefaultsSessionIDFromParsedSourceDocumentID(t *testing.T) { func TestRunDefaultsSessionIDFromParsedSourceDocumentID(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
Path: "notes.unknown", Path: "notes.unknown",
RawInput: []byte("notes"), RawInput: []byte("notes"),
}) })
@@ -643,8 +680,8 @@ func TestRunPassesInputRequestFields(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
metadata := map[string]any{"request": "test"} metadata := map[string]any{"request": "test"}
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
SourceID: "source-1", SourceID: "source-1",
Path: "input.txt", Path: "input.txt",
RawInput: []byte("source text"), RawInput: []byte("source text"),
@@ -676,7 +713,7 @@ func TestRunPassesModuleBindingConfigToStageRequests(t *testing.T) {
pipeline.ArtifactLanes[0].Merge = ModuleBinding{Module: "merge", LLMProfile: "merge-profile", Options: map[string]any{"merge_option": "merge-value"}} pipeline.ArtifactLanes[0].Merge = ModuleBinding{Module: "merge", LLMProfile: "merge-profile", Options: map[string]any{"merge_option": "merge-value"}}
pipeline.ArtifactLanes[0].Normalize = ModuleBinding{Module: "normalize", LLMProfile: "normalize-profile", Options: map[string]any{"normalize_option": "normalize-value"}} pipeline.ArtifactLanes[0].Normalize = ModuleBinding{Module: "normalize", LLMProfile: "normalize-profile", Options: map[string]any{"normalize_option": "normalize-value"}}
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -724,7 +761,7 @@ func TestRunPassesLaneReferencesToExtractorRequests(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = testReferenceSet("roster", "reference text") pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = testReferenceSet("roster", "reference text")
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -745,7 +782,7 @@ func TestRunPassesMergeReferencesToMergerRequest(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
pipeline.ArtifactLanes[0].MergeReferences.ReferenceSet = testReferenceSet("merge_notes", "merge reference text") pipeline.ArtifactLanes[0].MergeReferences.ReferenceSet = testReferenceSet("merge_notes", "merge reference text")
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -766,7 +803,7 @@ func TestRunPassesChunkReferencesToChunkerRequest(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
pipeline.ChunkReferences.ReferenceSet = testReferenceSet("scene_guide", "chunk reference text") pipeline.ChunkReferences.ReferenceSet = testReferenceSet("scene_guide", "chunk reference text")
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -787,7 +824,7 @@ func TestRunPassesNormalizeReferencesToNormalizerRequest(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
pipeline.ArtifactLanes[0].NormalizeReferences.ReferenceSet = testReferenceSet("normalization_notes", "normalize reference text") pipeline.ArtifactLanes[0].NormalizeReferences.ReferenceSet = testReferenceSet("normalization_notes", "normalize reference text")
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -829,11 +866,11 @@ func TestRunPassesValidationRequestContextToValidators(t *testing.T) {
rawInput := []byte("{\"source\":\"exact bytes\"}") rawInput := []byte("{\"source\":\"exact bytes\"}")
llmClient := fakeLLMClient{} llmClient := fakeLLMClient{}
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: pipeline, pipeline: pipeline,
Path: "session.json", Path: "session.json",
RawInput: rawInput, RawInput: rawInput,
LLMClient: llmClient, llmClient: llmClient,
SessionID: "session-123", SessionID: "session-123",
Metadata: map[string]any{"request": "test"}, Metadata: map[string]any{"request": "test"},
}) })
@@ -911,7 +948,7 @@ func TestRunPassesValidationRequestContextToValidators(t *testing.T) {
} }
func TestRunAllowsNilLLMClientWhenModulesDoNotUseIt(t *testing.T) { func TestRunAllowsNilLLMClientWhenModulesDoNotUseIt(t *testing.T) {
_, err := New(newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) _, err := newPreparedRunner(t, newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil with nil LLM client when modules do not use it", err) t.Fatalf("Run() error = %v, want nil with nil LLM client when modules do not use it", err)
} }
@@ -921,8 +958,8 @@ func TestRunIncludesInputWarnings(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
warning := contracts.Warning{Scope: "reference", ReasonCode: "empty_reference", Message: "empty reference"} warning := contracts.Warning{Scope: "reference", ReasonCode: "empty_reference", Message: "empty reference"}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
Warnings: []contracts.Warning{warning}, Warnings: []contracts.Warning{warning},
}) })
if err != nil { if err != nil {
@@ -950,7 +987,7 @@ func TestRunRecordsTopLevelModuleMetadataForSingletonModules(t *testing.T) {
"output_profile": "output-metadata", "output_profile": "output-metadata",
} }
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -977,7 +1014,7 @@ func TestRunRecordsTopLevelModuleMetadataForSingletonModules(t *testing.T) {
func TestRunPassesPerChunkRawOutputsToMergeAndNormalize(t *testing.T) { func TestRunPassesPerChunkRawOutputsToMergeAndNormalize(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1015,7 +1052,7 @@ func TestRunPassesChunkContentAndMediaTypeToExtractors(t *testing.T) {
sourceChunkWithContent("chunk-0", 0, []byte(`{"chunk":0}`), "application/vnd.test+json"), sourceChunkWithContent("chunk-0", 0, []byte(`{"chunk":0}`), "application/vnd.test+json"),
} }
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1035,8 +1072,8 @@ func TestRunPassesChunkContentAndMediaTypeToExtractors(t *testing.T) {
func TestRunDoesNotPassCheckpointPathsToModules(t *testing.T) { func TestRunDoesNotPassCheckpointPathsToModules(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
Checkpoints: NoopCheckpointRecorder(), Checkpoints: NoopCheckpointRecorder(),
}) })
if err != nil { if err != nil {
@@ -1128,8 +1165,8 @@ func TestRunReusesCheckpointedWorkflowOutputs(t *testing.T) {
}, },
} }
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
Checkpoint: loader, Checkpoint: loader,
}) })
if err != nil { if err != nil {
@@ -1181,8 +1218,8 @@ func TestRunPreservesCheckpointedExtractRejections(t *testing.T) {
reuse: map[string]bool{"extract": true}, reuse: map[string]bool{"extract": true},
} }
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
Checkpoint: loader, Checkpoint: loader,
}) })
if err != nil { if err != nil {
@@ -1208,7 +1245,7 @@ func TestRunOmitsRejectedExtractOutputsFromMerge(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator)) setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1232,7 +1269,7 @@ func TestRunOmitsLaneWithNoAcceptedExtractOutputs(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator)) setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1258,7 +1295,7 @@ func TestRunRejectedMergePreventsNormalizeForLane(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageMerge, "alpha", "merge", resolvedValidatorForTest(validator)) setResolvedValidatorChain(t, &pipeline, StageMerge, "alpha", "merge", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1281,7 +1318,7 @@ func TestRunRejectedNormalizePreventsOutputForLane(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageNormalize, "alpha", "normalize", resolvedValidatorForTest(validator)) setResolvedValidatorChain(t, &pipeline, StageNormalize, "alpha", "normalize", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1304,7 +1341,7 @@ func TestRunRetriesSameModuleInputAfterFrameworkError(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
pipeline.ArtifactLanes[0].Extract.Retries = 1 pipeline.ArtifactLanes[0].Extract.Retries = 1
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1329,7 +1366,7 @@ func TestRunRetriesSameModuleInputAfterValidatorRejection(t *testing.T) {
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator)) setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
pipeline.ArtifactLanes[0].Extract.Retries = 1 pipeline.ArtifactLanes[0].Extract.Retries = 1
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1353,9 +1390,9 @@ func TestRunDebugFailedChunkAttemptReferencesScopedLLMOutput(t *testing.T) {
modules.chunker.err = errors.New("malformed structured output") modules.chunker.err = errors.New("malformed structured output")
recorder := newMemoryDebugRecorder() recorder := newMemoryDebugRecorder()
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
LLMClient: debugResponseLLMClient{content: []byte(`{"raw":true}`), profileID: "debug-profile"}, llmClient: debugResponseLLMClient{content: []byte(`{"raw":true}`), profileID: "debug-profile"},
Debug: recorder, Debug: recorder,
}) })
if err == nil || !strings.Contains(err.Error(), "malformed structured output") { if err == nil || !strings.Contains(err.Error(), "malformed structured output") {
@@ -1412,9 +1449,9 @@ func TestRunDebugWritesNonJSONLLMResponseContentAsText(t *testing.T) {
modules.chunker.err = errors.New("malformed structured output") modules.chunker.err = errors.New("malformed structured output")
recorder := newMemoryDebugRecorder() recorder := newMemoryDebugRecorder()
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
LLMClient: debugResponseLLMClient{content: []byte("plain text response"), profileID: "debug-profile"}, llmClient: debugResponseLLMClient{content: []byte("plain text response"), profileID: "debug-profile"},
Debug: recorder, Debug: recorder,
}) })
if err == nil || !strings.Contains(err.Error(), "malformed structured output") { if err == nil || !strings.Contains(err.Error(), "malformed structured output") {
@@ -1450,7 +1487,7 @@ func TestRunStopsRetryAfterConfiguredAttemptsAndRecordsAttemptCount(t *testing.T
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator)) setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
pipeline.ArtifactLanes[0].Extract.Retries = 1 pipeline.ArtifactLanes[0].Extract.Retries = 1
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1480,7 +1517,7 @@ func TestRunContextCancellationStopsRetries(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
output, err := New(newRunnerRegistries(t, modules)).Run(ctx, RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(ctx, RunInput{pipeline: pipeline})
if !errors.Is(err, context.Canceled) { if !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context.Canceled", err) t.Fatalf("Run() error = %v, want context.Canceled", err)
@@ -1494,8 +1531,8 @@ func TestRunContextCancellationStopsRetries(t *testing.T) {
} }
func TestRunRejectsConfiguredValidators(t *testing.T) { func TestRunRejectsConfiguredValidators(t *testing.T) {
_, err := New(newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{ _, err := newPreparedRunner(t, newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{
Pipeline: resolvedPipelineWithValidators("configured", "second-validator"), pipeline: resolvedPipelineWithValidators("configured", "second-validator"),
}) })
assertRunError(t, err, "extract.validators") assertRunError(t, err, "extract.validators")
} }
@@ -1508,7 +1545,7 @@ func TestRunCollectsStageWarnings(t *testing.T) {
modules.normalizers["normalize"].warnings = []contracts.Warning{{ReasonCode: "normalize-warning", Message: "normalize warning"}} modules.normalizers["normalize"].warnings = []contracts.Warning{{ReasonCode: "normalize-warning", Message: "normalize warning"}}
modules.output.warnings = []contracts.Warning{{ReasonCode: "output-warning", Message: "output warning"}} modules.output.warnings = []contracts.Warning{{ReasonCode: "output-warning", Message: "output warning"}}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1530,7 +1567,7 @@ func TestRunCollectsChunkValidatorWarnings(t *testing.T) {
pipeline := resolvedPipeline() pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageChunk, "", "chunk", resolvedValidatorForTest(validator)) setResolvedValidatorChain(t, &pipeline, StageChunk, "", "chunk", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1543,7 +1580,7 @@ func TestRunCollectsChunkValidatorWarnings(t *testing.T) {
func TestRunOutputEncoderReceivesManifestAndRawOutputs(t *testing.T) { func TestRunOutputEncoderReceivesManifestAndRawOutputs(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1595,7 +1632,7 @@ func TestRunRejectsUnsafeOutputFileNames(t *testing.T) {
{Name: test.fileName, ContentType: "application/json", Bytes: []byte(`{}`)}, {Name: test.fileName, ContentType: "application/json", Bytes: []byte(`{}`)},
} }
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
assertRunError(t, err, "output file name") assertRunError(t, err, "output file name")
if output.Manifest.ValidationStatus != "failed" { if output.Manifest.ValidationStatus != "failed" {
@@ -1609,7 +1646,7 @@ func TestRunReturnsFailedManifestWhenOutputEncoderFails(t *testing.T) {
modules := defaultRunnerModules() modules := defaultRunnerModules()
modules.output.err = errors.New("encode failed") modules.output.err = errors.New("encode failed")
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
assertRunError(t, err, "encode failed") assertRunError(t, err, "encode failed")
if output.Manifest.ValidationStatus != "failed" { if output.Manifest.ValidationStatus != "failed" {
@@ -1698,7 +1735,7 @@ func TestRunManifestIncludesPipelineAndLaneDetails(t *testing.T) {
}, },
} }
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: resolved}) output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: resolved})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1769,8 +1806,8 @@ func TestRunManifestIncludesRunTimingAndLLMProfiles(t *testing.T) {
{ID: "default", Provider: "scriptorium", Model: "model-a"}, {ID: "default", Provider: "scriptorium", Model: "model-a"},
} }
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
RunID: "run-test", RunID: "run-test",
StartedAt: startedAt, StartedAt: startedAt,
LLMProfiles: profiles, LLMProfiles: profiles,
@@ -1795,9 +1832,9 @@ func TestRunManifestIncludesRunTimingAndLLMProfiles(t *testing.T) {
} }
func TestRunManifestIncludesProfilesReportedByLLMClient(t *testing.T) { func TestRunManifestIncludesProfilesReportedByLLMClient(t *testing.T) {
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(), pipeline: resolvedPipeline(),
LLMClient: manifestReportingLLMClient{profiles: []artifacts.LLMProfileManifest{ llmClient: manifestReportingLLMClient{profiles: []artifacts.LLMProfileManifest{
{ID: "profile-b", Provider: "scriptorium", Model: "model-b"}, {ID: "profile-b", Provider: "scriptorium", Model: "model-b"},
{ID: "profile-a", Provider: "scriptorium", Model: "model-a"}, {ID: "profile-a", Provider: "scriptorium", Model: "model-a"},
{ID: "profile-b", Provider: "scriptorium", Model: "model-b"}, {ID: "profile-b", Provider: "scriptorium", Model: "model-b"},
@@ -1817,7 +1854,7 @@ func TestRunManifestIncludesProfilesReportedByLLMClient(t *testing.T) {
} }
func TestRunManifestGeneratesRunIDAndTimestamps(t *testing.T) { func TestRunManifestGeneratesRunIDAndTimestamps(t *testing.T) {
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1840,7 +1877,7 @@ func TestRunManifestIncludesExtractorMetadata(t *testing.T) {
"response_schema_name": "test_schema", "response_schema_name": "test_schema",
} }
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
} }
@@ -1871,7 +1908,7 @@ func TestRunReturnsPartialOutputWhenLaterLaneFails(t *testing.T) {
Normalize: Binding("normalize"), Normalize: Binding("normalize"),
}) })
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline}) output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
assertRunError(t, err, "extract failed") assertRunError(t, err, "extract failed")
if output.Manifest.ValidationStatus != "failed" { if output.Manifest.ValidationStatus != "failed" {

View File

@@ -115,6 +115,21 @@ func TestResolveTypedHeterogeneousLanes(t *testing.T) {
} }
} }
func TestPrepareConstructsHeterogeneousTypedLanes(t *testing.T) {
catalog := typedResolutionCatalog(t, completeTypedCatalogOptions())
resolved, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, catalog)
if err != nil {
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
}
prepared, err := Prepare(resolved, registriesFromModuleCatalog(catalog), ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
if len(prepared.ArtifactLanes) != 2 || prepared.lanes[0].typed == nil || prepared.lanes[1].typed == nil {
t.Fatalf("PreparedPipeline lanes = %#v, want two typed executors", prepared.ArtifactLanes)
}
}
func TestResolveTypedLaneRejectsIncompatibleComposition(t *testing.T) { func TestResolveTypedLaneRejectsIncompatibleComposition(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -263,17 +278,31 @@ func typedResolutionCatalog(t *testing.T, options typedCatalogOptions) ModuleCat
func mustRegisterTypedTestBase(t *testing.T, catalog ModuleCatalog) { func mustRegisterTypedTestBase(t *testing.T, catalog ModuleCatalog) {
t.Helper() t.Helper()
if err := catalog.Inputs.RegisterWithSpec(ModuleSpec{Key: "typed/input", Stage: StageInput}, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil { if err := catalog.Inputs.RegisterWithSpec(ModuleSpec{Key: "typed/input", Stage: StageInput}, func() (contracts.InputAdapter, error) {
return &runnerInputAdapter{key: "typed/input", doc: validSourceDocument()}, nil
}); err != nil {
t.Fatalf("register input: %v", err) t.Fatalf("register input: %v", err)
} }
if err := catalog.Chunkers.RegisterWithSpec(ModuleSpec{Key: "typed/chunk", Stage: StageChunk}, func() (contracts.Chunker, error) { return nil, nil }); err != nil { if err := catalog.Chunkers.RegisterWithSpec(ModuleSpec{Key: "typed/chunk", Stage: StageChunk}, func() (contracts.Chunker, error) {
return &runnerChunker{key: "typed/chunk"}, nil
}); err != nil {
t.Fatalf("register chunker: %v", err) t.Fatalf("register chunker: %v", err)
} }
if err := catalog.Outputs.RegisterWithSpec(ModuleSpec{Key: "typed/output", Stage: StageOutput}, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil { if err := catalog.Outputs.RegisterWithSpec(ModuleSpec{Key: "typed/output", Stage: StageOutput}, func() (contracts.OutputEncoder, error) {
return &runnerOutputEncoder{key: "typed/output"}, nil
}); err != nil {
t.Fatalf("register output: %v", err) t.Fatalf("register output: %v", err)
} }
} }
func registriesFromModuleCatalog(catalog ModuleCatalog) Registries {
return Registries{
Inputs: catalog.Inputs, Chunkers: catalog.Chunkers, ArtifactCodecs: catalog.ArtifactCodecs,
Extractors: catalog.Extractors, Mergers: catalog.Mergers, Normalizers: catalog.Normalizers,
Validators: catalog.Validators, ValidatorChains: catalog.ValidatorChains, Outputs: catalog.Outputs,
}
}
func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) { func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) {
t.Helper() t.Helper()
if err := RegisterArtifactCodec(registry, codec); err != nil { if err := RegisterArtifactCodec(registry, codec); err != nil {

View File

@@ -10,6 +10,7 @@ import (
) )
type LegacyRawValidatorConstructor func() (contracts.LegacyRawValidator, error) type LegacyRawValidatorConstructor func() (contracts.LegacyRawValidator, error)
type LegacyRawValidatorBuilder func(BuildRequest) (contracts.LegacyRawValidator, error)
type ValidatorSpec struct { type ValidatorSpec struct {
Key string `json:"key"` Key string `json:"key"`
@@ -32,37 +33,42 @@ const (
) )
type ValidatorRegistry struct { type ValidatorRegistry struct {
legacyConstructors map[string]LegacyRawValidatorConstructor legacyBuilders map[string]LegacyRawValidatorBuilder
legacySpecs map[string]ValidatorSpec legacyValidators map[string]OptionValidator
typedEntries map[artifactVariantKey]typedValidatorEntry legacySpecs map[string]ValidatorSpec
chunkEntries map[string]chunkValidatorEntry typedEntries map[artifactVariantKey]typedValidatorEntry
serializedEntries map[string]serializedValidatorEntry chunkEntries map[string]chunkValidatorEntry
serializedEntries map[string]serializedValidatorEntry
} }
type typedValidatorEntry struct { type typedValidatorEntry struct {
spec ValidatorSpec spec ValidatorSpec
kind contracts.ArtifactKind kind contracts.ArtifactKind
valueType reflect.Type valueType reflect.Type
constructor func() (any, error) validateOptions OptionValidator
builder func(BuildRequest) (any, error)
} }
type chunkValidatorEntry struct { type chunkValidatorEntry struct {
spec ValidatorSpec spec ValidatorSpec
constructor func() (contracts.ChunkValidator, error) validateOptions OptionValidator
builder func(BuildRequest) (contracts.ChunkValidator, error)
} }
type serializedValidatorEntry struct { type serializedValidatorEntry struct {
spec SerializedValidatorSpec spec SerializedValidatorSpec
constructor func() (contracts.SerializedValidator, error) validateOptions OptionValidator
builder func(BuildRequest) (contracts.SerializedValidator, error)
} }
func NewValidatorRegistry() *ValidatorRegistry { func NewValidatorRegistry() *ValidatorRegistry {
return &ValidatorRegistry{ return &ValidatorRegistry{
legacyConstructors: make(map[string]LegacyRawValidatorConstructor), legacyBuilders: make(map[string]LegacyRawValidatorBuilder),
legacySpecs: make(map[string]ValidatorSpec), legacyValidators: make(map[string]OptionValidator),
typedEntries: make(map[artifactVariantKey]typedValidatorEntry), legacySpecs: make(map[string]ValidatorSpec),
chunkEntries: make(map[string]chunkValidatorEntry), typedEntries: make(map[artifactVariantKey]typedValidatorEntry),
serializedEntries: make(map[string]serializedValidatorEntry), chunkEntries: make(map[string]chunkValidatorEntry),
serializedEntries: make(map[string]serializedValidatorEntry),
} }
} }
@@ -71,6 +77,15 @@ func (r *ValidatorRegistry) RegisterLegacyRaw(key string, constructor LegacyRawV
} }
func (r *ValidatorRegistry) RegisterLegacyRawWithSpec(spec ValidatorSpec, constructor LegacyRawValidatorConstructor) error { func (r *ValidatorRegistry) RegisterLegacyRawWithSpec(spec ValidatorSpec, constructor LegacyRawValidatorConstructor) error {
if constructor == nil {
return fmt.Errorf("validator constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return r.RegisterLegacyRawBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.LegacyRawValidator, error) {
return constructor()
})
}
func (r *ValidatorRegistry) RegisterLegacyRawBuilderWithSpec(spec ValidatorSpec, validateOptions OptionValidator, builder LegacyRawValidatorBuilder) error {
if r == nil { if r == nil {
return fmt.Errorf("validator registry must not be nil") return fmt.Errorf("validator registry must not be nil")
} }
@@ -78,24 +93,40 @@ func (r *ValidatorRegistry) RegisterLegacyRawWithSpec(spec ValidatorSpec, constr
if err != nil { if err != nil {
return err return err
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("validator constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("validator option validator for %q must not be nil", normalizedSpec.Key)
} }
if _, ok := r.legacyConstructors[normalizedSpec.Key]; ok { if builder == nil {
return fmt.Errorf("validator builder for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.legacyBuilders[normalizedSpec.Key]; ok {
return fmt.Errorf("legacy raw validator %q is already registered", normalizedSpec.Key) return fmt.Errorf("legacy raw validator %q is already registered", normalizedSpec.Key)
} }
if r.legacyConstructors == nil { if r.legacyBuilders == nil {
r.legacyConstructors = make(map[string]LegacyRawValidatorConstructor) r.legacyBuilders = make(map[string]LegacyRawValidatorBuilder)
}
if r.legacyValidators == nil {
r.legacyValidators = make(map[string]OptionValidator)
} }
if r.legacySpecs == nil { if r.legacySpecs == nil {
r.legacySpecs = make(map[string]ValidatorSpec) r.legacySpecs = make(map[string]ValidatorSpec)
} }
r.legacyConstructors[normalizedSpec.Key] = constructor r.legacyBuilders[normalizedSpec.Key] = builder
r.legacyValidators[normalizedSpec.Key] = validateOptions
r.legacySpecs[normalizedSpec.Key] = normalizedSpec r.legacySpecs[normalizedSpec.Key] = normalizedSpec
return nil return nil
} }
func RegisterTypedValidator[T any](registry *ValidatorRegistry, kind contracts.ArtifactKind, spec ValidatorSpec, constructor func() (contracts.TypedValidator[T], error)) error { func RegisterTypedValidator[T any](registry *ValidatorRegistry, kind contracts.ArtifactKind, spec ValidatorSpec, constructor func() (contracts.TypedValidator[T], error)) error {
if constructor == nil {
return fmt.Errorf("validator constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return RegisterTypedValidatorBuilder(registry, kind, spec, allowLegacyOptions, func(BuildRequest) (contracts.TypedValidator[T], error) {
return constructor()
})
}
func RegisterTypedValidatorBuilder[T any](registry *ValidatorRegistry, kind contracts.ArtifactKind, spec ValidatorSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.TypedValidator[T], error)) error {
if registry == nil { if registry == nil {
return fmt.Errorf("validator registry must not be nil") return fmt.Errorf("validator registry must not be nil")
} }
@@ -107,8 +138,11 @@ func RegisterTypedValidator[T any](registry *ValidatorRegistry, kind contracts.A
if kind == "" { if kind == "" {
return fmt.Errorf("typed validator %q artifact kind must not be empty", normalizedSpec.Key) return fmt.Errorf("typed validator %q artifact kind must not be empty", normalizedSpec.Key)
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("validator constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("validator option validator for %q must not be nil", normalizedSpec.Key)
}
if builder == nil {
return fmt.Errorf("validator builder for %q must not be nil", normalizedSpec.Key)
} }
key := artifactVariantKey{module: normalizedSpec.Key, kind: kind} key := artifactVariantKey{module: normalizedSpec.Key, kind: kind}
if _, ok := registry.typedEntries[key]; ok { if _, ok := registry.typedEntries[key]; ok {
@@ -118,17 +152,27 @@ func RegisterTypedValidator[T any](registry *ValidatorRegistry, kind contracts.A
registry.typedEntries = make(map[artifactVariantKey]typedValidatorEntry) registry.typedEntries = make(map[artifactVariantKey]typedValidatorEntry)
} }
registry.typedEntries[key] = typedValidatorEntry{ registry.typedEntries[key] = typedValidatorEntry{
spec: normalizedSpec, spec: normalizedSpec,
kind: kind, kind: kind,
valueType: reflect.TypeFor[T](), valueType: reflect.TypeFor[T](),
constructor: func() (any, error) { validateOptions: validateOptions,
return constructor() builder: func(request BuildRequest) (any, error) {
return builder(cloneBuildRequest(request))
}, },
} }
return nil return nil
} }
func RegisterChunkValidator(registry *ValidatorRegistry, spec ValidatorSpec, constructor func() (contracts.ChunkValidator, error)) error { func RegisterChunkValidator(registry *ValidatorRegistry, spec ValidatorSpec, constructor func() (contracts.ChunkValidator, error)) error {
if constructor == nil {
return fmt.Errorf("validator constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return RegisterChunkValidatorBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.ChunkValidator, error) {
return constructor()
})
}
func RegisterChunkValidatorBuilder(registry *ValidatorRegistry, spec ValidatorSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.ChunkValidator, error)) error {
if registry == nil { if registry == nil {
return fmt.Errorf("validator registry must not be nil") return fmt.Errorf("validator registry must not be nil")
} }
@@ -136,8 +180,11 @@ func RegisterChunkValidator(registry *ValidatorRegistry, spec ValidatorSpec, con
if err != nil { if err != nil {
return err return err
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("validator constructor for %q must not be nil", normalizedSpec.Key) return fmt.Errorf("validator option validator for %q must not be nil", normalizedSpec.Key)
}
if builder == nil {
return fmt.Errorf("validator builder for %q must not be nil", normalizedSpec.Key)
} }
if _, ok := registry.chunkEntries[normalizedSpec.Key]; ok { if _, ok := registry.chunkEntries[normalizedSpec.Key]; ok {
return fmt.Errorf("chunk validator %q is already registered", normalizedSpec.Key) return fmt.Errorf("chunk validator %q is already registered", normalizedSpec.Key)
@@ -145,11 +192,20 @@ func RegisterChunkValidator(registry *ValidatorRegistry, spec ValidatorSpec, con
if registry.chunkEntries == nil { if registry.chunkEntries == nil {
registry.chunkEntries = make(map[string]chunkValidatorEntry) registry.chunkEntries = make(map[string]chunkValidatorEntry)
} }
registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{spec: normalizedSpec, constructor: constructor} registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{spec: normalizedSpec, validateOptions: validateOptions, builder: builder}
return nil return nil
} }
func RegisterSerializedValidator(registry *ValidatorRegistry, spec SerializedValidatorSpec, constructor func() (contracts.SerializedValidator, error)) error { func RegisterSerializedValidator(registry *ValidatorRegistry, spec SerializedValidatorSpec, constructor func() (contracts.SerializedValidator, error)) error {
if constructor == nil {
return fmt.Errorf("validator constructor for %q must not be nil", strings.TrimSpace(spec.Key))
}
return RegisterSerializedValidatorBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.SerializedValidator, error) {
return constructor()
})
}
func RegisterSerializedValidatorBuilder(registry *ValidatorRegistry, spec SerializedValidatorSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.SerializedValidator, error)) error {
if registry == nil { if registry == nil {
return fmt.Errorf("validator registry must not be nil") return fmt.Errorf("validator registry must not be nil")
} }
@@ -161,8 +217,11 @@ func RegisterSerializedValidator(registry *ValidatorRegistry, spec SerializedVal
if !spec.SupportsChunks && !spec.SupportsArtifacts { if !spec.SupportsChunks && !spec.SupportsArtifacts {
return fmt.Errorf("serialized validator %q must support chunks, artifacts, or both", spec.Key) return fmt.Errorf("serialized validator %q must support chunks, artifacts, or both", spec.Key)
} }
if constructor == nil { if validateOptions == nil {
return fmt.Errorf("validator constructor for %q must not be nil", spec.Key) return fmt.Errorf("validator option validator for %q must not be nil", spec.Key)
}
if builder == nil {
return fmt.Errorf("validator builder for %q must not be nil", spec.Key)
} }
if _, ok := registry.serializedEntries[spec.Key]; ok { if _, ok := registry.serializedEntries[spec.Key]; ok {
return fmt.Errorf("serialized validator %q is already registered", spec.Key) return fmt.Errorf("serialized validator %q is already registered", spec.Key)
@@ -170,11 +229,15 @@ func RegisterSerializedValidator(registry *ValidatorRegistry, spec SerializedVal
if registry.serializedEntries == nil { if registry.serializedEntries == nil {
registry.serializedEntries = make(map[string]serializedValidatorEntry) registry.serializedEntries = make(map[string]serializedValidatorEntry)
} }
registry.serializedEntries[spec.Key] = serializedValidatorEntry{spec: spec, constructor: constructor} registry.serializedEntries[spec.Key] = serializedValidatorEntry{spec: spec, validateOptions: validateOptions, builder: builder}
return nil return nil
} }
func (r *ValidatorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawValidator, error) { func (r *ValidatorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawValidator, error) {
return r.BuildLegacyRawWithRequest(key, BuildRequest{})
}
func (r *ValidatorRegistry) BuildLegacyRawWithRequest(key string, request BuildRequest) (contracts.LegacyRawValidator, error) {
if r == nil { if r == nil {
return nil, fmt.Errorf("validator registry must not be nil") return nil, fmt.Errorf("validator registry must not be nil")
} }
@@ -182,11 +245,11 @@ func (r *ValidatorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawValid
if normalizedKey == "" { if normalizedKey == "" {
return nil, fmt.Errorf("validator key must not be empty") return nil, fmt.Errorf("validator key must not be empty")
} }
constructor, ok := r.legacyConstructors[normalizedKey] builder, ok := r.legacyBuilders[normalizedKey]
if !ok { if !ok {
return nil, fmt.Errorf("legacy raw validator %q is not registered", normalizedKey) return nil, fmt.Errorf("legacy raw validator %q is not registered", normalizedKey)
} }
validator, err := constructor() validator, err := builder(cloneBuildRequest(request))
if err != nil { if err != nil {
return nil, fmt.Errorf("build validator %q: %w", normalizedKey, err) return nil, fmt.Errorf("build validator %q: %w", normalizedKey, err)
} }
@@ -203,6 +266,37 @@ func (r *ValidatorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawValid
return validator, nil return validator, nil
} }
func (r *ValidatorRegistry) validateOptions(resolved ResolvedValidator) error {
if r == nil {
return fmt.Errorf("validator registry must not be nil")
}
key := strings.TrimSpace(resolved.Binding.Module)
var validator OptionValidator
switch resolved.Target {
case ValidatorTargetTyped:
entry, ok := r.typedEntry(key, resolved.ArtifactKind)
if ok {
validator = entry.validateOptions
}
case ValidatorTargetChunk:
entry, ok := r.chunkEntry(key)
if ok {
validator = entry.validateOptions
}
case ValidatorTargetSerialized:
entry, ok := r.serializedEntry(key)
if ok {
validator = entry.validateOptions
}
default:
validator = r.legacyValidators[key]
}
if validator == nil {
return fmt.Errorf("validator %q construction entry is not registered", key)
}
return validateRegisteredOptions(validator, resolved.Binding.Options)
}
func (r *ValidatorRegistry) Spec(key string) (ValidatorSpec, bool) { func (r *ValidatorRegistry) Spec(key string) (ValidatorSpec, bool) {
if r == nil { if r == nil {
return ValidatorSpec{}, false return ValidatorSpec{}, false

View File

@@ -26,12 +26,12 @@ func TestWalkingSkeletonFixture(t *testing.T) {
t.Fatalf("ResolvePipeline() error = %v, want nil", err) t.Fatalf("ResolvePipeline() error = %v, want nil", err)
} }
output, err := New(walkingSkeletonRegistries(t)).Run(context.Background(), RunInput{ output, err := newPreparedRunner(t, walkingSkeletonRegistries(t)).Run(context.Background(), RunInput{
Pipeline: resolved, pipeline: resolved,
SourceID: "fixture-source", SourceID: "fixture-source",
Path: "walking_skeleton_input.json", Path: "walking_skeleton_input.json",
RawInput: inputBytes, RawInput: inputBytes,
LLMClient: llmClient, llmClient: llmClient,
}) })
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)

View File

@@ -15,6 +15,16 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript" "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
) )
func runPreparedPipeline(t *testing.T, registries pipeline.Registries, resolved pipeline.ResolvedPipeline, llmClient contracts.StructuredLLMClient, input pipeline.RunInput) (pipeline.RunOutput, error) {
t.Helper()
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{LLM: llmClient})
if err != nil {
return pipeline.RunOutput{}, err
}
input.Prepared = prepared
return pipeline.New().Run(context.Background(), input)
}
func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) { func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
raw := readDNDSpellsFixture(t) raw := readDNDSpellsFixture(t)
expectedDoc := parseDNDSpellsFixture(t, raw) expectedDoc := parseDNDSpellsFixture(t, raw)
@@ -40,10 +50,8 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
}, },
} }
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{ output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline, RawInput: raw,
RawInput: raw,
LLMClient: llmClient,
}) })
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
@@ -128,10 +136,8 @@ func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
}, },
} }
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{ output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline, RawInput: raw,
RawInput: raw,
LLMClient: llmClient,
}) })
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
@@ -169,10 +175,8 @@ func TestRunnerDoesNotExtractSpellMentionedOnlyInPartyReference(t *testing.T) {
response: extractionResponse{SpellCasts: []spellCastResponse{}}, response: extractionResponse{SpellCasts: []spellCastResponse{}},
} }
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{ output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline, RawInput: raw,
RawInput: raw,
LLMClient: llmClient,
}) })
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
@@ -217,10 +221,8 @@ func TestRunnerCarriesDNDSpellCastWithInvalidSourceRefAsRawOutput(t *testing.T)
}, },
} }
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{ output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline, RawInput: raw,
RawInput: raw,
LLMClient: llmClient,
}) })
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)
@@ -285,10 +287,8 @@ func TestRunnerCarriesMalformedDNDSpellsExtractorOutput(t *testing.T) {
resolved := resolveDNDSpellsPipeline(t) resolved := resolveDNDSpellsPipeline(t)
llmClient := &fakeSpellsLLMClient{response: extractionResponse{}} llmClient := &fakeSpellsLLMClient{response: extractionResponse{}}
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{ output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline, RawInput: raw,
RawInput: raw,
LLMClient: llmClient,
}) })
if err != nil { if err != nil {
t.Fatalf("Run() error = %v, want nil", err) t.Fatalf("Run() error = %v, want nil", err)

View File

@@ -15,6 +15,16 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
) )
func runPreparedPipeline(t *testing.T, registries pipeline.Registries, resolved pipeline.ResolvedPipeline, llmClient contracts.StructuredLLMClient, input pipeline.RunInput) (pipeline.RunOutput, error) {
t.Helper()
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{LLM: llmClient})
if err != nil {
return pipeline.RunOutput{}, err
}
input.Prepared = prepared
return pipeline.New().Run(context.Background(), input)
}
func TestRunnerProcessesSeriatimInputWithFakeModules(t *testing.T) { func TestRunnerProcessesSeriatimInputWithFakeModules(t *testing.T) {
raw := readFixture(t, "testdata/valid_minimal.json") raw := readFixture(t, "testdata/valid_minimal.json")
expectedDoc, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw}) expectedDoc, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw})
@@ -28,8 +38,7 @@ func TestRunnerProcessesSeriatimInputWithFakeModules(t *testing.T) {
} }
extractor := &runnerSeriatimExtractor{} extractor := &runnerSeriatimExtractor{}
output, err := pipeline.New(seriatimRunnerRegistries(t, extractor)).Run(context.Background(), pipeline.RunInput{ output, err := runPreparedPipeline(t, seriatimRunnerRegistries(t, extractor), resolved.ResolvedPipeline, nil, pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline,
RawInput: raw, RawInput: raw,
}) })
if err != nil { if err != nil {
@@ -83,11 +92,9 @@ func TestRunnerFailsOnInvalidSeriatimInput(t *testing.T) {
t.Fatalf("Resolve() error = %v, want nil", err) t.Fatalf("Resolve() error = %v, want nil", err)
} }
output, err := pipeline.New(seriatimRunnerRegistries(t, &runnerSeriatimExtractor{})).Run(context.Background(), pipeline.RunInput{ output, err := runPreparedPipeline(t, seriatimRunnerRegistries(t, &runnerSeriatimExtractor{}), resolved.ResolvedPipeline, nil, pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline, RawInput: []byte(`{"metadata":{},"segments":[]}`),
RawInput: []byte(`{"metadata":{},"segments":[]}`), SourceID: "invalid-source",
SourceID: "invalid-source",
LLMClient: nil,
}) })
if err == nil { if err == nil {
t.Fatal("Run() error = nil, want invalid input error") t.Fatal("Run() error = nil, want invalid input error")