Pass reference bindings to Notarius

This commit is contained in:
2026-08-29 15:16:06 +00:00
parent a2409a1fd1
commit 51e0e8c5d0
5 changed files with 152 additions and 19 deletions

View File

@@ -19,7 +19,7 @@ different contract.
| 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed | | 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed |
| 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed | | 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed |
| 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed | | 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed |
| 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Pending | | 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Completed |
| 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Pending | | 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Pending |
| 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Pending | | 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Pending |
| 7 | Update canonical documentation and maintained examples for the completed feature. | Pending | | 7 | Update canonical documentation and maintained examples for the completed feature. | Pending |

View File

@@ -14,7 +14,9 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return RunResult{}, err return RunResult{}, err
} }
f.Requests = append(f.Requests, req) copyRequest := req
copyRequest.References = append([]ReferenceBinding(nil), req.References...)
f.Requests = append(f.Requests, copyRequest)
if f.Err != nil { if f.Err != nil {
return RunResult{}, f.Err return RunResult{}, f.Err
} }

View File

@@ -13,6 +13,13 @@ type Runner interface {
Run(ctx context.Context, req RunRequest) (RunResult, error) Run(ctx context.Context, req RunRequest) (RunResult, error)
} }
// ReferenceBinding maps one normalized Notarius selector to an absolute
// external reference path.
type ReferenceBinding struct {
Selector string
Path string
}
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation. // RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
type RunRequest struct { type RunRequest struct {
Binary string Binary string
@@ -24,6 +31,7 @@ type RunRequest struct {
ReceiptPath string ReceiptPath string
LogPath string LogPath string
Timeout time.Duration Timeout time.Duration
References []ReferenceBinding
} }
// Receipt is the transport-neutral successful run receipt. // Receipt is the transport-neutral successful run receipt.

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
) )
@@ -47,7 +48,8 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
if r == nil || r.run == nil { if r == nil || r.run == nil {
return RunResult{}, fmt.Errorf("notarius subprocess runner is nil") return RunResult{}, fmt.Errorf("notarius subprocess runner is nil")
} }
if err := validateRunRequest(req); err != nil { references, err := validateRunRequest(req)
if err != nil {
return RunResult{}, err return RunResult{}, err
} }
@@ -56,8 +58,11 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
"--config", req.ConfigPath, "--config", req.ConfigPath,
"--input", req.InputPath, "--input", req.InputPath,
"--output-dir", req.OutputRoot, "--output-dir", req.OutputRoot,
"--json",
} }
for _, reference := range references {
args = append(args, "--reference", reference.Selector+"="+reference.Path)
}
args = append(args, "--json")
processResult, err := r.run(ctx, subprocess.RunRequest{ processResult, err := r.run(ctx, subprocess.RunRequest{
Executable: req.Binary, Executable: req.Binary,
Args: args, Args: args,
@@ -128,15 +133,15 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
return baseResult, nil return baseResult, nil
} }
func validateRunRequest(req RunRequest) error { func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
if strings.TrimSpace(req.Binary) == "" { if strings.TrimSpace(req.Binary) == "" {
return fmt.Errorf("notarius binary is required") return nil, fmt.Errorf("notarius binary is required")
} }
if strings.TrimSpace(req.PipelineID) == "" { if strings.TrimSpace(req.PipelineID) == "" {
return fmt.Errorf("notarius pipeline id is required") return nil, fmt.Errorf("notarius pipeline id is required")
} }
if req.Timeout <= 0 { if req.Timeout <= 0 {
return fmt.Errorf("notarius timeout must be positive") return nil, fmt.Errorf("notarius timeout must be positive")
} }
for label, path := range map[string]string{ for label, path := range map[string]string{
"config": req.ConfigPath, "config": req.ConfigPath,
@@ -147,34 +152,53 @@ func validateRunRequest(req RunRequest) error {
"log": req.LogPath, "log": req.LogPath,
} { } {
if strings.TrimSpace(path) == "" { if strings.TrimSpace(path) == "" {
return fmt.Errorf("notarius %s path is required", label) return nil, fmt.Errorf("notarius %s path is required", label)
} }
if !filepath.IsAbs(path) { if !filepath.IsAbs(path) {
return fmt.Errorf("notarius %s path must be absolute", label) return nil, fmt.Errorf("notarius %s path must be absolute", label)
} }
} }
if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) { if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) {
return fmt.Errorf("notarius receipt and log paths must be different") return nil, fmt.Errorf("notarius receipt and log paths must be different")
}
references := make([]ReferenceBinding, 0, len(req.References))
selectors := make(map[string]struct{}, len(req.References))
for index, binding := range req.References {
selector, err := notariusref.NormalizeSelector(binding.Selector)
if err != nil {
return nil, fmt.Errorf("notarius reference %d selector: %w", index, err)
}
if _, duplicate := selectors[selector]; duplicate {
return nil, fmt.Errorf("notarius reference selector %q is duplicated", selector)
}
selectors[selector] = struct{}{}
if strings.TrimSpace(binding.Path) == "" {
return nil, fmt.Errorf("notarius reference %q path is required", selector)
}
if !filepath.IsAbs(binding.Path) {
return nil, fmt.Errorf("notarius reference %q path must be absolute", selector)
}
references = append(references, ReferenceBinding{Selector: selector, Path: binding.Path})
} }
if err := requireRegularFile(req.ConfigPath); err != nil { if err := requireRegularFile(req.ConfigPath); err != nil {
return fmt.Errorf("validate notarius config path: %w", err) return nil, fmt.Errorf("validate notarius config path: %w", err)
} }
if err := requireRegularFile(req.InputPath); err != nil { if err := requireRegularFile(req.InputPath); err != nil {
return fmt.Errorf("validate notarius input path: %w", err) return nil, fmt.Errorf("validate notarius input path: %w", err)
} }
if err := requireDirectory(req.OutputRoot); err != nil { if err := requireDirectory(req.OutputRoot); err != nil {
return fmt.Errorf("validate notarius output root: %w", err) return nil, fmt.Errorf("validate notarius output root: %w", err)
} }
if err := requireDirectory(req.WorkingDirectory); err != nil { if err := requireDirectory(req.WorkingDirectory); err != nil {
return fmt.Errorf("validate notarius working directory: %w", err) return nil, fmt.Errorf("validate notarius working directory: %w", err)
} }
if err := validateLogDestination(req.ReceiptPath); err != nil { if err := validateLogDestination(req.ReceiptPath); err != nil {
return fmt.Errorf("validate notarius receipt path: %w", err) return nil, fmt.Errorf("validate notarius receipt path: %w", err)
} }
if err := validateLogDestination(req.LogPath); err != nil { if err := validateLogDestination(req.LogPath); err != nil {
return fmt.Errorf("validate notarius log path: %w", err) return nil, fmt.Errorf("validate notarius log path: %w", err)
} }
return nil return references, nil
} }
type receiptDocument struct { type receiptDocument struct {

View File

@@ -76,6 +76,101 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
} }
} }
func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) {
req := validRunRequest(t)
referenceRoot := t.TempDir()
req.References = []ReferenceBinding{
{Selector: " party ", Path: filepath.Join(referenceRoot, "party context=primary.json")},
{Selector: " npc-registry . extract . glossary ", Path: filepath.Join(referenceRoot, "glossary.json")},
}
originalReferences := append([]ReferenceBinding(nil), req.References...)
var captured sharedsubprocess.RunRequest
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
captured = processReq
writeValidBundleAndReceipt(t, req, false)
return sharedsubprocess.RunResult{ExitCode: 0}, nil
}}
if _, err := runner.Run(context.Background(), req); err != nil {
t.Fatalf("Run() error = %v", err)
}
wantArgs := []string{
"run", req.PipelineID,
"--config", req.ConfigPath,
"--input", req.InputPath,
"--output-dir", req.OutputRoot,
"--reference", "party=" + req.References[0].Path,
"--reference", "npc-registry.extract.glossary=" + req.References[1].Path,
"--json",
}
if !reflect.DeepEqual(captured.Args, wantArgs) {
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
}
if !reflect.DeepEqual(req.References, originalReferences) {
t.Fatalf("Run() mutated caller references = %#v, want %#v", req.References, originalReferences)
}
}
func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
tests := []struct {
name string
references func(string) []ReferenceBinding
wantErr string
}{
{
name: "invalid selector",
references: func(root string) []ReferenceBinding {
return []ReferenceBinding{{Selector: "lane.prepare.party", Path: filepath.Join(root, "party.json")}}
},
wantErr: "selector",
},
{
name: "duplicate normalized selector",
references: func(root string) []ReferenceBinding {
return []ReferenceBinding{
{Selector: "lane.party", Path: filepath.Join(root, "party.json")},
{Selector: " lane . party ", Path: filepath.Join(root, "party-2.json")},
}
},
wantErr: "duplicated",
},
{
name: "empty path",
references: func(string) []ReferenceBinding {
return []ReferenceBinding{{Selector: "party", Path: " "}}
},
wantErr: "path is required",
},
{
name: "relative path",
references: func(string) []ReferenceBinding {
return []ReferenceBinding{{Selector: "party", Path: "references/party.json"}}
},
wantErr: "path must be absolute",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := validRunRequest(t)
req.References = tt.references(t.TempDir())
started := false
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
started = true
return sharedsubprocess.RunResult{}, nil
}}
_, err := runner.Run(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Run() error = %v, want containing %q", err, tt.wantErr)
}
if started {
t.Fatal("subprocess started after request validation failure")
}
})
}
}
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) { func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
req := validRunRequest(t) req := validRunRequest(t)
writeValidBundleAndReceipt(t, req, false) writeValidBundleAndReceipt(t, req, false)
@@ -432,13 +527,17 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
} }
func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) { func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
req := RunRequest{PipelineID: "pipeline"} req := RunRequest{PipelineID: "pipeline", References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}}
want := RunResult{BundleRoot: "/bundle"} want := RunResult{BundleRoot: "/bundle"}
fake := &FakeRunner{Result: want} fake := &FakeRunner{Result: want}
got, err := fake.Run(context.Background(), req) got, err := fake.Run(context.Background(), req)
if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) { if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) {
t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests) t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests)
} }
req.References[0].Path = "/references/changed.json"
if fake.Requests[0].References[0].Path != "/references/party.json" {
t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References)
}
wantErr := errors.New("configured failure") wantErr := errors.New("configured failure")
fake.Err = wantErr fake.Err = wantErr