Remove legacy workspace and diagnostics implementation
This commit is contained in:
@@ -469,7 +469,7 @@ and CLI tests use the new public contract; and the repository-wide checks pass.
|
||||
|
||||
## Stage 4: Remove legacy workspace and diagnostics implementation
|
||||
|
||||
**Status:** Not started
|
||||
**Status:** Complete
|
||||
|
||||
### Objective
|
||||
|
||||
|
||||
@@ -1,498 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
type recordingChunkPlanStore struct {
|
||||
record pipeline.ChunkPlanRecord
|
||||
decision pipeline.ChunkPlanDecision
|
||||
loadErr error
|
||||
saveErr error
|
||||
loads int
|
||||
saves int
|
||||
}
|
||||
|
||||
func (s *recordingChunkPlanStore) Load(string) (pipeline.ChunkPlanRecord, pipeline.ChunkPlanDecision, error) {
|
||||
s.loads++
|
||||
return s.record, s.decision, s.loadErr
|
||||
}
|
||||
|
||||
func (s *recordingChunkPlanStore) Save(record pipeline.ChunkPlanRecord) error {
|
||||
s.saves++
|
||||
s.record = record
|
||||
return s.saveErr
|
||||
}
|
||||
|
||||
type recordingChunkPlanFactory struct {
|
||||
roots []string
|
||||
store *recordingChunkPlanStore
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *recordingChunkPlanFactory) build(root string) (pipeline.ChunkPlanStore, error) {
|
||||
f.roots = append(f.roots, root)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.store == nil {
|
||||
f.store = &recordingChunkPlanStore{decision: pipeline.ChunkPlanDecision{Status: pipeline.ChunkPlanMissing}}
|
||||
}
|
||||
return f.store, nil
|
||||
}
|
||||
|
||||
func TestRunChunkCachePrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileMode string
|
||||
envMode string
|
||||
flagMode string
|
||||
wantMode pipeline.ChunkCacheMode
|
||||
wantBuild bool
|
||||
}{
|
||||
{name: "file refresh", fileMode: "refresh", wantMode: pipeline.ChunkCacheRefresh, wantBuild: true},
|
||||
{name: "environment over file", fileMode: "bypass", envMode: "refresh", wantMode: pipeline.ChunkCacheRefresh, wantBuild: true},
|
||||
{name: "flag over environment", fileMode: "refresh", envMode: "auto", flagMode: "bypass", wantMode: pipeline.ChunkCacheBypass},
|
||||
{name: "explicit auto", fileMode: "bypass", envMode: "refresh", flagMode: "auto", wantMode: pipeline.ChunkCacheAuto, wantBuild: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "plans")
|
||||
configPath := writeTestConfig(t, cacheTestConfig(tc.fileMode, root, ""))
|
||||
inputPath := writeFile(t, "input.txt", "input")
|
||||
values := map[string]string{}
|
||||
if tc.envMode != "" {
|
||||
values["NOTARIUS_WORKSPACE_CHUNK_CACHE_MODE"] = tc.envMode
|
||||
}
|
||||
factory := &recordingChunkPlanFactory{}
|
||||
args := []string{"run", "example", "--config", configPath, "--input", inputPath, "--output-dir", t.TempDir()}
|
||||
if tc.flagMode != "" {
|
||||
args = append(args, "--chunk_cache", tc.flagMode)
|
||||
}
|
||||
code, stderr := runCacheCommand(t, args, cacheTestOptions(t, values, factory))
|
||||
if code != 0 {
|
||||
t.Fatalf("code = %d stderr = %q", code, stderr)
|
||||
}
|
||||
if got := len(factory.roots) > 0; got != tc.wantBuild {
|
||||
t.Fatalf("store built = %t roots = %#v, want %t", got, factory.roots, tc.wantBuild)
|
||||
}
|
||||
if !tc.wantBuild {
|
||||
return
|
||||
}
|
||||
if tc.wantMode == pipeline.ChunkCacheAuto && (factory.store.loads != 1 || factory.store.saves != 1) {
|
||||
t.Fatalf("auto calls = load %d save %d", factory.store.loads, factory.store.saves)
|
||||
}
|
||||
if tc.wantMode == pipeline.ChunkCacheRefresh && (factory.store.loads != 0 || factory.store.saves != 1) {
|
||||
t.Fatalf("refresh calls = load %d save %d", factory.store.loads, factory.store.saves)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChunkCacheInvalidValuesHaveEstablishedExitCodes(t *testing.T) {
|
||||
validConfig := writeTestConfig(t, cacheTestConfig("bypass", "", ""))
|
||||
invalidFile := writeTestConfig(t, cacheTestConfig("sometimes", "", ""))
|
||||
inputPath := writeFile(t, "input.txt", "input")
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
env map[string]string
|
||||
flag string
|
||||
want int
|
||||
}{
|
||||
{name: "flag", config: validConfig, flag: "sometimes", want: 2},
|
||||
{name: "environment", config: validConfig, env: map[string]string{"NOTARIUS_WORKSPACE_CHUNK_CACHE_MODE": "sometimes"}, want: 1},
|
||||
{name: "file", config: invalidFile, want: 1},
|
||||
{name: "flag does not mask invalid file", config: invalidFile, flag: "bypass", want: 1},
|
||||
{name: "flag does not mask invalid environment", config: validConfig, env: map[string]string{"NOTARIUS_WORKSPACE_CHUNK_CACHE_MODE": "sometimes"}, flag: "bypass", want: 1},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"run", "example", "--config", tc.config, "--input", inputPath, "--output-dir", t.TempDir()}
|
||||
if tc.flag != "" {
|
||||
args = append(args, "--chunk_cache", tc.flag)
|
||||
}
|
||||
code, _ := runCacheCommand(t, args, cacheTestOptions(t, tc.env, &recordingChunkPlanFactory{}))
|
||||
if code != tc.want {
|
||||
t.Fatalf("code = %d, want %d", code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChunkPlanRootResolution(t *testing.T) {
|
||||
t.Run("explicit file root", func(t *testing.T) {
|
||||
factory := &recordingChunkPlanFactory{}
|
||||
resolverCalls := 0
|
||||
opts := cacheTestOptions(t, nil, factory)
|
||||
opts.UserCacheDir = func() (string, error) { resolverCalls++; return "", errors.New("must not be called") }
|
||||
configPath := writeTestConfig(t, cacheTestConfig("auto", "/var/cache/notarius/chunk-plans", ""))
|
||||
code, stderr := runCacheCommand(t, cacheRunArgs(t, configPath), opts)
|
||||
if code != 0 || stderr != "" || resolverCalls != 0 || len(factory.roots) != 1 || factory.roots[0] != "/var/cache/notarius/chunk-plans" {
|
||||
t.Fatalf("code=%d stderr=%q resolver=%d roots=%#v", code, stderr, resolverCalls, factory.roots)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("environment root", func(t *testing.T) {
|
||||
factory := &recordingChunkPlanFactory{}
|
||||
environmentRoot := filepath.Join(t.TempDir(), "environment-plans")
|
||||
configPath := writeTestConfig(t, cacheTestConfig("auto", filepath.Join(t.TempDir(), "file-plans"), ""))
|
||||
code, stderr := runCacheCommand(t, cacheRunArgs(t, configPath), cacheTestOptions(t, map[string]string{"NOTARIUS_WORKSPACE_CHUNK_CACHE_DIR": environmentRoot}, factory))
|
||||
if code != 0 || stderr != "" || len(factory.roots) != 1 || factory.roots[0] != environmentRoot {
|
||||
t.Fatalf("code=%d stderr=%q roots=%#v", code, stderr, factory.roots)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("per-user default", func(t *testing.T) {
|
||||
factory := &recordingChunkPlanFactory{}
|
||||
cacheDir := filepath.Join(t.TempDir(), "user-cache")
|
||||
opts := cacheTestOptions(t, nil, factory)
|
||||
opts.UserCacheDir = func() (string, error) { return cacheDir, nil }
|
||||
configPath := writeTestConfig(t, cacheTestConfig("auto", "", ""))
|
||||
code, stderr := runCacheCommand(t, cacheRunArgs(t, configPath), opts)
|
||||
want := filepath.Join(cacheDir, "notarius", "chunk-plans")
|
||||
if code != 0 || stderr != "" || len(factory.roots) != 1 || factory.roots[0] != want {
|
||||
t.Fatalf("code=%d stderr=%q roots=%#v want=%q", code, stderr, factory.roots, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunBypassSkipsRootAndStore(t *testing.T) {
|
||||
factory := &recordingChunkPlanFactory{err: errors.New("must not build")}
|
||||
resolverCalls := 0
|
||||
opts := cacheTestOptions(t, nil, factory)
|
||||
opts.UserCacheDir = func() (string, error) { resolverCalls++; return "", errors.New("must not resolve") }
|
||||
configPath := writeTestConfig(t, cacheTestConfig("bypass", "", ""))
|
||||
code, stderr := runCacheCommand(t, cacheRunArgs(t, configPath), opts)
|
||||
if code != 0 || stderr != "" || resolverCalls != 0 || len(factory.roots) != 0 {
|
||||
t.Fatalf("code=%d stderr=%q resolver=%d roots=%#v", code, stderr, resolverCalls, factory.roots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChunkPlanSetupFailures(t *testing.T) {
|
||||
for _, mode := range []string{"auto", "refresh"} {
|
||||
t.Run(mode+" resolver", func(t *testing.T) {
|
||||
factory := &recordingChunkPlanFactory{}
|
||||
opts := cacheTestOptions(t, nil, factory)
|
||||
opts.UserCacheDir = func() (string, error) { return "", errors.New("cache unavailable") }
|
||||
configPath := writeTestConfig(t, cacheTestConfig(mode, "", ""))
|
||||
code, stderr := runCacheCommand(t, cacheRunArgs(t, configPath), opts)
|
||||
if code != 1 || !strings.Contains(stderr, "cache unavailable") || len(factory.roots) != 0 {
|
||||
t.Fatalf("code=%d stderr=%q roots=%#v", code, stderr, factory.roots)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("store", func(t *testing.T) {
|
||||
factory := &recordingChunkPlanFactory{err: errors.New("unwritable store")}
|
||||
configPath := writeTestConfig(t, cacheTestConfig("auto", t.TempDir(), ""))
|
||||
code, stderr := runCacheCommand(t, cacheRunArgs(t, configPath), cacheTestOptions(t, nil, factory))
|
||||
if code != 1 || !strings.Contains(stderr, "unwritable store") {
|
||||
t.Fatalf("code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unwritable filesystem store", func(t *testing.T) {
|
||||
root := writeFile(t, "not-a-directory", "occupied")
|
||||
configPath := writeTestConfig(t, `version: 2
|
||||
workspace:
|
||||
chunk_cache:
|
||||
mode: auto
|
||||
directory: `+root+`
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`)
|
||||
code, stderr := runCacheCommand(t, []string{"run", "dnd-session", "--config", configPath, "--input", writeSeriatimInput(t), "--output-dir", t.TempDir()}, Options{LLMClientFactory: fakeLLMFactory(newFakeRunLLMClient(false), nil), LookupEnv: mapLookup(nil)})
|
||||
if code != 1 || (!strings.Contains(stderr, "load chunk plan") && !strings.Contains(stderr, "save chunk plan")) {
|
||||
t.Fatalf("code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigCommandsDoNotResolveOrCreateChunkPlanState(t *testing.T) {
|
||||
configPath := writeTestConfig(t, cacheTestConfig("auto", "", ""))
|
||||
for _, args := range [][]string{
|
||||
{"config", "validate", "--config", configPath},
|
||||
{"pipelines", "list", "--config", configPath},
|
||||
} {
|
||||
factory := &recordingChunkPlanFactory{err: errors.New("must not build")}
|
||||
resolverCalls := 0
|
||||
opts := cacheTestOptions(t, nil, factory)
|
||||
opts.UserCacheDir = func() (string, error) { resolverCalls++; return "", errors.New("must not resolve") }
|
||||
code, stderr := runCacheCommand(t, args, opts)
|
||||
if code != 0 || stderr != "" || resolverCalls != 0 || len(factory.roots) != 0 {
|
||||
t.Fatalf("args=%v code=%d stderr=%q resolver=%d roots=%#v", args, code, stderr, resolverCalls, factory.roots)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecordsExplicitChunkCacheOverrideAndEffectiveMode(t *testing.T) {
|
||||
diagnosticsDir := t.TempDir()
|
||||
configPath := writeTestConfig(t, cacheTestConfig("bypass", "", diagnosticsDir))
|
||||
factory := &recordingChunkPlanFactory{}
|
||||
args := append(cacheRunArgs(t, configPath), "--chunk_cache", "refresh")
|
||||
code, stderr := runCacheCommand(t, args, cacheTestOptions(t, nil, factory))
|
||||
if code != 0 || stderr != "" {
|
||||
t.Fatalf("code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
runDir := onlyChildDir(t, diagnosticsDir)
|
||||
invocation := string(readFile(t, filepath.Join(runDir, diagnostics.ArtifactInvocationMetadata)))
|
||||
effective := string(readFile(t, filepath.Join(runDir, diagnostics.ArtifactEffectiveConfig)))
|
||||
if !strings.Contains(invocation, `"chunk_cache_override": "refresh"`) || !strings.Contains(effective, `"mode": "refresh"`) {
|
||||
t.Fatalf("invocation=%s effective=%s", invocation, effective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChunkPlanDiagnosticsRedactStoreDecisionReason(t *testing.T) {
|
||||
diagnosticsDir := t.TempDir()
|
||||
configPath := writeTestConfig(t, cacheTestConfig("auto", t.TempDir(), diagnosticsDir))
|
||||
factory := &recordingChunkPlanFactory{store: &recordingChunkPlanStore{
|
||||
decision: pipeline.ChunkPlanDecision{Status: pipeline.ChunkPlanInvalid, Reason: "SENTINEL_INVALID_RECORD_CONTENT"},
|
||||
}}
|
||||
code, stderr := runCacheCommand(t, cacheRunArgs(t, configPath), cacheTestOptions(t, nil, factory))
|
||||
if code != 0 || stderr != "" {
|
||||
t.Fatalf("code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
summary := string(readFile(t, filepath.Join(onlyChildDir(t, diagnosticsDir), diagnostics.ArtifactChunkPlan)))
|
||||
if strings.Contains(summary, "SENTINEL_INVALID_RECORD_CONTENT") || !strings.Contains(summary, `"lookup_reason": "stored chunk plan is invalid"`) {
|
||||
t.Fatalf("chunk plan diagnostic = %s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAutoReusesPlanAcrossIndependentInvocations(t *testing.T) {
|
||||
cacheBase := filepath.Join(t.TempDir(), "cache")
|
||||
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
||||
configPath := writeTestConfig(t, `version: 2
|
||||
workspace:
|
||||
directory: `+workspaceDir+`
|
||||
debug:
|
||||
enabled: true
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
chunk: dnd/scenes
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`)
|
||||
inputPath := writeFile(t, "source.json", `{
|
||||
"metadata": {"id": "session-alpha"},
|
||||
"segments": [
|
||||
{"id": 1, "start": 0, "end": 1, "speaker": "Aria", "text": "Aria casts Cure Wounds."},
|
||||
{"id": 2, "start": 1, "end": 2, "speaker": "Borin", "text": "Borin recovers."}
|
||||
]
|
||||
}`)
|
||||
client := newFakeRunLLMClient(false)
|
||||
opts := Options{
|
||||
LLMClientFactory: fakeLLMFactory(client, nil),
|
||||
LookupEnv: mapLookup(nil),
|
||||
UserCacheDir: func() (string, error) { return cacheBase, nil },
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
code, stderr := runCacheCommand(t, []string{"run", "dnd-session", "--config", configPath, "--input", inputPath, "--output-dir", t.TempDir()}, opts)
|
||||
if code != 0 {
|
||||
t.Fatalf("run %d code=%d stderr=%q", i+1, code, stderr)
|
||||
}
|
||||
}
|
||||
if client.calls != 3 {
|
||||
t.Fatalf("LLM calls = %d, want chunk+extract then extract-only reuse", client.calls)
|
||||
}
|
||||
planRoot := filepath.Join(cacheBase, "notarius", "chunk-plans")
|
||||
entries, err := os.ReadDir(planRoot)
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("plan root entries = %v error=%v", entries, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(planRoot, entries[0].Name(), "plan.json")); err != nil {
|
||||
t.Fatalf("plan file: %v", err)
|
||||
}
|
||||
debugRuns := childDirs(t, filepath.Join(workspaceDir, "debug"))
|
||||
if len(debugRuns) != 2 {
|
||||
t.Fatalf("debug runs = %#v", debugRuns)
|
||||
}
|
||||
attemptCounts := 0
|
||||
for _, runDir := range debugRuns {
|
||||
if _, err := os.Stat(filepath.Join(runDir, "chunk", "attempt-01.json")); err == nil {
|
||||
attemptCounts++
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if attemptCounts != 1 {
|
||||
t.Fatalf("chunk attempt files across runs = %d, want only generating run", attemptCounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkPlanReuseDependsOnlyOnSourceDigest(t *testing.T) {
|
||||
cacheRoot := filepath.Join(t.TempDir(), "plans")
|
||||
inputPath := writeSeriatimInput(t)
|
||||
referencePath := writeFile(t, "players.txt", "Alyx")
|
||||
profilePath := writeScriptoriumProfileFile(t, "chunk-profile", "http://127.0.0.1:8080/v1", "test-model")
|
||||
seedConfig := writeTestConfig(t, `version: 2
|
||||
workspace:
|
||||
chunk_cache:
|
||||
mode: auto
|
||||
directory: `+cacheRoot+`
|
||||
pipelines:
|
||||
seed:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
options:
|
||||
max_units: 1
|
||||
overlap_units: 0
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`)
|
||||
changedConfig := writeTestConfig(t, `version: 2
|
||||
scriptorium:
|
||||
profile_file: `+profilePath+`
|
||||
workspace:
|
||||
chunk_cache:
|
||||
mode: auto
|
||||
directory: `+cacheRoot+`
|
||||
pipelines:
|
||||
changed:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: dnd/scenes
|
||||
llm_profile: chunk-profile
|
||||
references:
|
||||
players: `+referencePath+`
|
||||
validators:
|
||||
- generic/always_accept
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`)
|
||||
client := newFakeRunLLMClient(false)
|
||||
opts := Options{LLMClientFactory: fakeLLMFactory(client, nil), LookupEnv: mapLookup(nil)}
|
||||
for _, run := range []struct {
|
||||
pipeline string
|
||||
config string
|
||||
}{{pipeline: "seed", config: seedConfig}, {pipeline: "changed", config: changedConfig}} {
|
||||
code, stderr := runCacheCommand(t, []string{"run", run.pipeline, "--config", run.config, "--input", inputPath, "--output-dir", t.TempDir()}, opts)
|
||||
if code != 0 {
|
||||
t.Fatalf("pipeline %q code=%d stderr=%q", run.pipeline, code, stderr)
|
||||
}
|
||||
}
|
||||
if client.calls != 2 {
|
||||
t.Fatalf("LLM calls = %d, want one extractor call per run and no changed chunker call", client.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeAndChunkCacheModesRemainIndependent(t *testing.T) {
|
||||
for _, mode := range []pipeline.ChunkCacheMode{pipeline.ChunkCacheAuto, pipeline.ChunkCacheBypass, pipeline.ChunkCacheRefresh} {
|
||||
t.Run(string(mode), func(t *testing.T) {
|
||||
workspaceDir := filepath.Join(t.TempDir(), "workspace")
|
||||
cacheRoot := filepath.Join(t.TempDir(), "plans")
|
||||
configPath := writeTestConfig(t, cacheTestConfigWithWorkspace(string(mode), cacheRoot, workspaceDir))
|
||||
factory := &recordingChunkPlanFactory{}
|
||||
args := append(cacheRunArgs(t, configPath), "--resume")
|
||||
code, stderr := runCacheCommand(t, args, cacheTestOptions(t, nil, factory))
|
||||
if code != 0 || stderr != "" {
|
||||
t.Fatalf("code=%d stderr=%q", code, stderr)
|
||||
}
|
||||
if mode == pipeline.ChunkCacheBypass {
|
||||
if len(factory.roots) != 0 {
|
||||
t.Fatalf("bypass roots = %#v", factory.roots)
|
||||
}
|
||||
} else if len(factory.roots) != 1 || factory.roots[0] != cacheRoot {
|
||||
t.Fatalf("roots = %#v, want %q", factory.roots, cacheRoot)
|
||||
}
|
||||
if entries := childDirs(t, filepath.Join(workspaceDir, "checkpoints")); len(entries) != 1 {
|
||||
t.Fatalf("checkpoint roots = %#v", entries)
|
||||
}
|
||||
if strings.HasPrefix(cacheRoot, workspaceDir+string(filepath.Separator)) || strings.HasPrefix(workspaceDir, cacheRoot+string(filepath.Separator)) {
|
||||
t.Fatalf("cache root %q and workspace root %q overlap", cacheRoot, workspaceDir)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceDirectoryDoesNotSelectChunkPlanRoot(t *testing.T) {
|
||||
cacheBase := filepath.Join(t.TempDir(), "user-cache")
|
||||
factory := &recordingChunkPlanFactory{}
|
||||
for _, workspaceDir := range []string{filepath.Join(t.TempDir(), "workspace-one"), filepath.Join(t.TempDir(), "workspace-two")} {
|
||||
configPath := writeTestConfig(t, cacheTestConfigWithWorkspace("auto", "", workspaceDir))
|
||||
opts := cacheTestOptions(t, nil, factory)
|
||||
opts.UserCacheDir = func() (string, error) { return cacheBase, nil }
|
||||
code, stderr := runCacheCommand(t, cacheRunArgs(t, configPath), opts)
|
||||
if code != 0 || stderr != "" {
|
||||
t.Fatalf("workspace=%q code=%d stderr=%q", workspaceDir, code, stderr)
|
||||
}
|
||||
}
|
||||
want := filepath.Join(cacheBase, "notarius", "chunk-plans")
|
||||
if len(factory.roots) != 2 || factory.roots[0] != want || factory.roots[1] != want {
|
||||
t.Fatalf("roots = %#v, want %q twice", factory.roots, want)
|
||||
}
|
||||
}
|
||||
|
||||
func cacheTestOptions(t *testing.T, env map[string]string, factory *recordingChunkPlanFactory) Options {
|
||||
t.Helper()
|
||||
registries := fakeExecutionRegistries(t)
|
||||
return Options{
|
||||
Catalog: catalogFromRegistries(registries),
|
||||
Registries: registries,
|
||||
LLMClientFactory: fakeLLMFactory(nil, nil),
|
||||
LookupEnv: mapLookup(env),
|
||||
UserCacheDir: func() (string, error) { return filepath.Join(t.TempDir(), "cache"), nil },
|
||||
ChunkPlanStoreFactory: factory.build,
|
||||
}
|
||||
}
|
||||
|
||||
func cacheRunArgs(t *testing.T, configPath string) []string {
|
||||
t.Helper()
|
||||
return []string{"run", "example", "--config", configPath, "--input", writeFile(t, "input.txt", "input"), "--output-dir", t.TempDir()}
|
||||
}
|
||||
|
||||
func runCacheCommand(t *testing.T, args []string, opts Options) (int, string) {
|
||||
t.Helper()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := RunWithOptions(args, &stdout, &stderr, opts)
|
||||
return code, stderr.String()
|
||||
}
|
||||
|
||||
func cacheTestConfig(mode, directory, diagnosticsDir string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("version: 2\n")
|
||||
if mode != "" || directory != "" {
|
||||
b.WriteString("workspace:\n chunk_cache:\n")
|
||||
if mode != "" {
|
||||
b.WriteString(" mode: " + mode + "\n")
|
||||
}
|
||||
if directory != "" {
|
||||
b.WriteString(" directory: " + directory + "\n")
|
||||
}
|
||||
}
|
||||
if diagnosticsDir != "" {
|
||||
b.WriteString("diagnostics:\n work_dir: " + diagnosticsDir + "\n retention: always\n")
|
||||
}
|
||||
b.WriteString("pipelines:\n example:\n input: fake/input\n artifacts:\n spells:\n extract: fake/extract\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func cacheTestConfigWithWorkspace(mode, cacheRoot, workspaceDir string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("version: 2\nworkspace:\n directory: " + workspaceDir + "\n resume:\n enabled: true\n chunk_cache:\n mode: " + mode + "\n")
|
||||
if cacheRoot != "" {
|
||||
b.WriteString(" directory: " + cacheRoot + "\n")
|
||||
}
|
||||
b.WriteString("pipelines:\n example:\n input: fake/input\n artifacts:\n spells:\n extract: fake/extract\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -1,602 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
frameworkllm "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
|
||||
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness"
|
||||
validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json"
|
||||
validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema"
|
||||
)
|
||||
|
||||
func TestProductionCompatibilitySnapshot(t *testing.T) {
|
||||
normalizedOptions, err := normalizeOptions(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeOptions() error = %v, want nil", err)
|
||||
}
|
||||
if normalizedOptions.Catalog.Inputs != normalizedOptions.Registries.Inputs ||
|
||||
normalizedOptions.Catalog.Chunkers != normalizedOptions.Registries.Chunkers ||
|
||||
normalizedOptions.Catalog.Extractors != normalizedOptions.Registries.Extractors ||
|
||||
normalizedOptions.Catalog.Mergers != normalizedOptions.Registries.Mergers ||
|
||||
normalizedOptions.Catalog.Normalizers != normalizedOptions.Registries.Normalizers ||
|
||||
normalizedOptions.Catalog.Validators != normalizedOptions.Registries.Validators ||
|
||||
normalizedOptions.Catalog.ValidatorChains != normalizedOptions.Registries.ValidatorChains ||
|
||||
normalizedOptions.Catalog.Outputs != normalizedOptions.Registries.Outputs {
|
||||
t.Fatal("production catalog and execution registries do not share one composition")
|
||||
}
|
||||
if normalizedOptions.LLMClientFactory == nil {
|
||||
t.Fatal("production LLM client factory is nil")
|
||||
}
|
||||
|
||||
registries, err := productionRegistries()
|
||||
if err != nil {
|
||||
t.Fatalf("productionRegistries() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
keySnapshots := []struct {
|
||||
name string
|
||||
got []string
|
||||
want []string
|
||||
}{
|
||||
{name: "inputs", got: registries.Inputs.RegisteredKeys(), want: []string{"seriatim"}},
|
||||
{name: "chunkers", got: registries.Chunkers.RegisteredKeys(), want: []string{"dnd/scenes", "generic"}},
|
||||
{name: "extractors", got: registries.Extractors.RegisteredKeys(), want: []string{"dnd/spells"}},
|
||||
{name: "mergers", got: registries.Mergers.RegisteredKeys(), want: []string{"appendorder"}},
|
||||
{name: "normalizers", got: registries.Normalizers.RegisteredKeys(), want: []string{"noop"}},
|
||||
{name: "validators", got: registries.Validators.RegisteredKeys(), want: []string{
|
||||
"extract/dnd/spells/shape", "extract/dnd/spells/source_refs", "extract/dnd/spells/source_relatedness",
|
||||
"generic/always_accept", "generic/always_reject", "generic/valid_json", "generic/valid_json_schema",
|
||||
}},
|
||||
{name: "outputs", got: registries.Outputs.RegisteredKeys(), want: []string{"json"}},
|
||||
}
|
||||
for _, snapshot := range keySnapshots {
|
||||
t.Run(snapshot.name, func(t *testing.T) {
|
||||
if !reflect.DeepEqual(snapshot.got, snapshot.want) {
|
||||
t.Fatalf("registered keys = %#v, want compatibility snapshot %#v", snapshot.got, snapshot.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wantChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(spellshape.Key),
|
||||
pipeline.Binding(spellsourcerefs.Key),
|
||||
pipeline.Binding(spellrelatedness.Key),
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
|
||||
t.Fatalf("spell validator chain = %#v, want compatibility snapshot %#v", got, wantChain)
|
||||
}
|
||||
|
||||
assets, err := productionPromptAssets()
|
||||
if err != nil {
|
||||
t.Fatalf("productionPromptAssets() error = %v, want nil", err)
|
||||
}
|
||||
assertAssetNames(t, assets.PromptFS, []string{
|
||||
"dnd.scenes/dnd.scenes.yaml",
|
||||
"dnd.scenes/instructions.md",
|
||||
"dnd.scenes/sharedassets/common-dnd-references.md",
|
||||
"dnd.scenes/sharedassets/common-dnd-system.md",
|
||||
"dnd.scenes/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.scenes/task.md",
|
||||
"dnd.spells/dnd.spells.yaml",
|
||||
"dnd.spells/instructions.md",
|
||||
"dnd.spells/sharedassets/common-dnd-references.md",
|
||||
"dnd.spells/sharedassets/common-dnd-system.md",
|
||||
"dnd.spells/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.spells/task.md",
|
||||
})
|
||||
assertAssetNames(t, assets.SchemaFS, []string{
|
||||
"dnd_scenes.v1.json",
|
||||
"dnd_spells_llm.v1.json",
|
||||
})
|
||||
|
||||
identitySnapshot := map[string]map[string]any{
|
||||
"scenes": sceneManifestMetadata(t),
|
||||
"spells": spellManifestMetadata(t),
|
||||
}
|
||||
for name, metadata := range identitySnapshot {
|
||||
for _, key := range []string{"prompt_id", "prompt_version", "prompt_sha256", "response_schema_key", "response_schema_id", "response_schema_name", "response_schema_version", "response_schema_sha256"} {
|
||||
if value, ok := metadata[key].(string); !ok || value == "" {
|
||||
t.Fatalf("%s metadata[%q] = %#v, want non-empty identity", name, key, metadata[key])
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := []any{
|
||||
identitySnapshot["scenes"]["prompt_id"], identitySnapshot["scenes"]["prompt_version"],
|
||||
identitySnapshot["scenes"]["response_schema_key"], identitySnapshot["scenes"]["response_schema_id"], identitySnapshot["scenes"]["response_schema_name"], identitySnapshot["scenes"]["response_schema_version"],
|
||||
}; !reflect.DeepEqual(got, []any{"dnd.scenes", "v1", "dnd_scenes", "notarius.dnd.scenes", "notarius_dnd_scenes_v1", "v1"}) {
|
||||
t.Fatalf("scene identities = %#v, want compatibility snapshot", got)
|
||||
}
|
||||
if got := []any{
|
||||
identitySnapshot["spells"]["prompt_id"], identitySnapshot["spells"]["prompt_version"],
|
||||
identitySnapshot["spells"]["response_schema_key"], identitySnapshot["spells"]["response_schema_id"], identitySnapshot["spells"]["response_schema_name"], identitySnapshot["spells"]["response_schema_version"],
|
||||
}; !reflect.DeepEqual(got, []any{"dnd.spells", "v1", "dnd_spells", "notarius.dnd.spells", "notarius_dnd_spells_v1", "v1"}) {
|
||||
t.Fatalf("spell identities = %#v, want compatibility snapshot", got)
|
||||
}
|
||||
|
||||
fileConfig, err := config.LoadFileConfig(fixturePath(t, "examples/dnd-spells.config.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFileConfig() error = %v, want nil", err)
|
||||
}
|
||||
cfg := config.Default()
|
||||
if err := cfg.ApplyFileConfig(fileConfig); err != nil {
|
||||
t.Fatalf("ApplyFileConfig() error = %v, want nil", err)
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(registries)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
}
|
||||
resolved := effective.ResolvedPipeline
|
||||
if resolved.Input.Module != "seriatim" || resolved.Chunk.Module != "generic" || resolved.Output.Module != "json" || len(resolved.ArtifactLanes) != 1 {
|
||||
t.Fatalf("resolved example = %#v, want maintained production topology", resolved)
|
||||
}
|
||||
lane := resolved.ArtifactLanes[0]
|
||||
if lane.ID != "spells" || lane.Extract.Module != "dnd/spells" || lane.Merge.Module != "appendorder" || lane.Normalize.Module != "noop" {
|
||||
t.Fatalf("resolved lane = %#v, want maintained spell lane", lane)
|
||||
}
|
||||
if got := resolvedValidatorKeys(resolved.ValidatorChains, pipeline.StageExtract, "spells", spells.Key); !reflect.DeepEqual(got, []string{
|
||||
"generic/valid_json", "generic/valid_json_schema", "extract/dnd/spells/shape", "extract/dnd/spells/source_refs", "extract/dnd/spells/source_relatedness",
|
||||
}) {
|
||||
t.Fatalf("resolved validator keys = %#v, want compatibility snapshot", got)
|
||||
}
|
||||
|
||||
productionFileConfig, err := config.LoadFileConfig(fixturePath(t, "examples/dnd-spells-production.config.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFileConfig(production example) error = %v, want nil", err)
|
||||
}
|
||||
productionConfig := config.Default()
|
||||
if err := productionConfig.ApplyFileConfig(productionFileConfig); err != nil {
|
||||
t.Fatalf("ApplyFileConfig(production example) error = %v, want nil", err)
|
||||
}
|
||||
productionEffective, err := productionConfig.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalogFromRegistries(registries)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve(production example) error = %v, want nil", err)
|
||||
}
|
||||
productionResolved := productionEffective.ResolvedPipeline
|
||||
if productionConfig.Concurrency.TotalLLM != 1 || productionConfig.Concurrency.StageWorkers["extract"] != 1 || !reflect.DeepEqual(productionResolved.Chunk.Options, map[string]any{"max_units": 50}) {
|
||||
t.Fatalf("production example concurrency/options = %#v/%#v, want compatibility snapshot", productionConfig.Concurrency, productionResolved.Chunk.Options)
|
||||
}
|
||||
bindings := productionResolved.ArtifactLanes[0].ExtractReferences.Bindings
|
||||
if len(bindings) != 2 || bindings[0].SlotName != "glossary" || bindings[0].Source != "./dnd-spells-glossary.txt" || bindings[1].SlotName != "party" || bindings[1].Source != "./dnd-spells-roster.txt" {
|
||||
t.Fatalf("production example reference bindings = %#v, want maintained glossary and party bindings", bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintainedSeriatimToDNDCompatibilityBundle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
client contracts.StructuredLLMClient
|
||||
wantStatus string
|
||||
wantLaneFile bool
|
||||
wantRejectedCount int
|
||||
}{
|
||||
{name: "approved", client: newFakeRunLLMClient(false), wantStatus: "approved", wantLaneFile: true},
|
||||
{name: "validator rejection is nonfatal", client: newFakeRunLLMClient(true), wantStatus: "rejected", wantRejectedCount: 1},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
outputDir := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session",
|
||||
"--config", fixturePath(t, "examples/dnd-spells.config.yml"),
|
||||
"--input", fixturePath(t, "examples/seriatim-minimal-transcript.json"),
|
||||
"--output-dir", outputDir,
|
||||
"--diagnostics-dir", t.TempDir(),
|
||||
}, &stdout, &stderr, Options{LLMClientFactory: fakeLLMFactory(test.client, nil)})
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
runDir := onlyChildDir(t, outputDir)
|
||||
wantFiles := []string{"index.json", "manifest.json", "rejected.json", "warnings.json"}
|
||||
if test.wantLaneFile {
|
||||
wantFiles = append(wantFiles, "lanes/spells.json")
|
||||
}
|
||||
sort.Strings(wantFiles)
|
||||
if got := relativeFileNames(t, runDir); !reflect.DeepEqual(got, wantFiles) {
|
||||
t.Fatalf("durable files = %#v, want compatibility snapshot %#v", got, wantFiles)
|
||||
}
|
||||
|
||||
var manifest artifacts.RunManifest
|
||||
readJSONFile(t, filepath.Join(runDir, "manifest.json"), &manifest)
|
||||
if manifest.PipelineID != "dnd-session" || manifest.InputModule != "seriatim" || manifest.Chunker != "generic" || manifest.OutputEncoder != "json" {
|
||||
t.Fatalf("manifest module provenance = %#v, want maintained production modules", manifest)
|
||||
}
|
||||
if len(manifest.ArtifactLanes) != 1 || manifest.ArtifactLanes[0].ID != "spells" {
|
||||
t.Fatalf("artifact lanes = %#v, want one spells lane", manifest.ArtifactLanes)
|
||||
}
|
||||
laneManifest := manifest.ArtifactLanes[0]
|
||||
if laneManifest.Extractor != "dnd/spells" || laneManifest.Merger != "appendorder" || laneManifest.Normalizer != "noop" {
|
||||
t.Fatalf("manifest lane module provenance = %#v, want maintained production modules", laneManifest)
|
||||
}
|
||||
if len(manifest.Extractors) != 0 || manifest.Merger != "" || manifest.Normalizer != "" {
|
||||
t.Fatalf("legacy top-level lane summaries = %#v/%q/%q, want empty compatibility snapshot", manifest.Extractors, manifest.Merger, manifest.Normalizer)
|
||||
}
|
||||
if manifest.ValidationStatus != test.wantStatus || len(manifest.RejectedOutputs) != test.wantRejectedCount {
|
||||
t.Fatalf("manifest outcome = status %q rejected %#v, want %q/%d", manifest.ValidationStatus, manifest.RejectedOutputs, test.wantStatus, test.wantRejectedCount)
|
||||
}
|
||||
if !reflect.DeepEqual(manifest.SourceDigests, []string{"sha256:1c98d94ae632fb10a2b56f684cd4fb1019cedb1a629e57dc0977cf4a54135be0"}) {
|
||||
t.Fatalf("source digests = %#v, want maintained fixture provenance", manifest.SourceDigests)
|
||||
}
|
||||
if got := manifestValidatorKeys(manifestValidatorChain(t, manifest, pipeline.StageExtract, "spells", spells.Key)); !reflect.DeepEqual(got, []string{
|
||||
"generic/valid_json", "generic/valid_json_schema", "extract/dnd/spells/shape", "extract/dnd/spells/source_refs", "extract/dnd/spells/source_relatedness",
|
||||
}) {
|
||||
t.Fatalf("manifest validator chain = %#v, want compatibility snapshot", got)
|
||||
}
|
||||
|
||||
var index struct {
|
||||
ManifestFile string `json:"manifest_file"`
|
||||
OutputFiles []struct {
|
||||
LaneID string `json:"lane_id"`
|
||||
MediaType string `json:"media_type"`
|
||||
File string `json:"file"`
|
||||
ModuleKey string `json:"module_key"`
|
||||
SchemaID string `json:"schema_id"`
|
||||
SchemaName string `json:"schema_name"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
} `json:"output_files"`
|
||||
RejectedFile string `json:"rejected_file"`
|
||||
WarningsFile string `json:"warnings_file"`
|
||||
}
|
||||
readJSONFile(t, filepath.Join(runDir, "index.json"), &index)
|
||||
if index.ManifestFile != "manifest.json" || index.RejectedFile != "rejected.json" || index.WarningsFile != "warnings.json" {
|
||||
t.Fatalf("output index fixed files = %#v, want compatibility snapshot", index)
|
||||
}
|
||||
if test.wantLaneFile {
|
||||
if len(index.OutputFiles) != 1 {
|
||||
t.Fatalf("output index entries = %#v, want one", index.OutputFiles)
|
||||
}
|
||||
wantOutput := struct {
|
||||
LaneID, MediaType, File, ModuleKey, SchemaID, SchemaName, SchemaVersion string
|
||||
}{"spells", "application/json", "lanes/spells.json", "noop", "notarius.dnd.spells", "notarius_dnd_spells_v1", "v1"}
|
||||
gotOutput := index.OutputFiles[0]
|
||||
got := struct {
|
||||
LaneID, MediaType, File, ModuleKey, SchemaID, SchemaName, SchemaVersion string
|
||||
}{gotOutput.LaneID, gotOutput.MediaType, gotOutput.File, gotOutput.ModuleKey, gotOutput.SchemaID, gotOutput.SchemaName, gotOutput.SchemaVersion}
|
||||
if got != wantOutput {
|
||||
t.Fatalf("output index entries = %#v, want compatibility snapshot %#v", index.OutputFiles, wantOutput)
|
||||
}
|
||||
assertJSONEqual(t, readFile(t, filepath.Join(runDir, "lanes/spells.json")), []byte(`{
|
||||
"spell_casts": [{
|
||||
"caster": "Aria",
|
||||
"spell": "Cure Wounds",
|
||||
"effect": "Heals a wounded ally.",
|
||||
"narrative_description": "Aria casts Cure Wounds.",
|
||||
"source_refs": [{"source_id": "session-alpha", "start_unit_id": 1, "end_unit_id": 1}]
|
||||
}]
|
||||
}`))
|
||||
} else if len(index.OutputFiles) != 0 {
|
||||
t.Fatalf("output index entries = %#v, want none for rejected lane", index.OutputFiles)
|
||||
}
|
||||
|
||||
var warnings struct {
|
||||
Warnings []contracts.Warning `json:"warnings"`
|
||||
}
|
||||
readJSONFile(t, filepath.Join(runDir, "warnings.json"), &warnings)
|
||||
if len(warnings.Warnings) != 0 {
|
||||
t.Fatalf("warnings = %#v, want empty compatibility snapshot", warnings.Warnings)
|
||||
}
|
||||
var rejected struct {
|
||||
Rejected []contracts.RejectedOutput `json:"rejected"`
|
||||
}
|
||||
readJSONFile(t, filepath.Join(runDir, "rejected.json"), &rejected)
|
||||
if len(rejected.Rejected) != test.wantRejectedCount {
|
||||
t.Fatalf("rejected outputs = %#v, want %d", rejected.Rejected, test.wantRejectedCount)
|
||||
}
|
||||
if test.wantRejectedCount == 1 {
|
||||
got := rejected.Rejected[0]
|
||||
if got.Stage != "extract" || got.LaneID != "spells" || got.ModuleKey != "dnd/spells" || got.ChunkID != "chunk-000001" || got.ChunkIndex != 0 || got.ValidatorName != "extract/dnd/spells/source_refs" || got.ReasonCode != "invalid_source_refs" || got.AttemptCount != 1 {
|
||||
t.Fatalf("rejection = %#v, want maintained nonfatal validator outcome", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionLLMCallersShareScheduledClient(t *testing.T) {
|
||||
underlying := newBlockingProductionLLMClient()
|
||||
scheduler, err := frameworkllm.NewScheduler(1)
|
||||
if err != nil {
|
||||
t.Fatalf("NewScheduler() error = %v, want nil", err)
|
||||
}
|
||||
client := frameworkllm.NewScheduledClient(underlying, scheduler)
|
||||
doc := &source.SourceDocument{
|
||||
ID: "session-alpha", Kind: "transcript", Format: "application/json", Digest: "sha256:source",
|
||||
Units: []source.SourceUnit{
|
||||
{ID: 1, Kind: "segment", Text: "Aria casts Cure Wounds.", Ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}},
|
||||
{ID: 2, Kind: "segment", Text: "The spell takes effect.", Ref: source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}},
|
||||
},
|
||||
}
|
||||
chunk := source.Chunk{
|
||||
ID: "session-alpha:chunk:0", SourceID: doc.ID, Index: 0, Ref: source.SourceRef{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2},
|
||||
Content: []byte(`{"scene":"Aria casts Cure Wounds."}`), MediaType: "application/json", Units: append([]source.SourceUnit(nil), doc.Units...),
|
||||
}
|
||||
|
||||
var started sync.WaitGroup
|
||||
started.Add(2)
|
||||
errs := make(chan error, 2)
|
||||
go func() {
|
||||
started.Done()
|
||||
chunker, err := scenes.New(client, scenes.Options{})
|
||||
if err == nil {
|
||||
_, err = chunker.Plan(context.Background(), contracts.ChunkRequest{Source: doc})
|
||||
}
|
||||
errs <- err
|
||||
}()
|
||||
go func() {
|
||||
started.Done()
|
||||
extractor, err := spells.New(client, spells.Options{})
|
||||
if err == nil {
|
||||
_, err = extractor.Extract(context.Background(), contracts.TypedExtractionRequest{Source: doc, Chunk: &chunk})
|
||||
}
|
||||
errs <- err
|
||||
}()
|
||||
started.Wait()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
<-underlying.entered
|
||||
underlying.release <- struct{}{}
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := <-errs; err != nil {
|
||||
t.Fatalf("production LLM caller error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
if underlying.maxActive != 1 {
|
||||
t.Fatalf("maximum concurrent provider calls = %d, want total_llm limit 1", underlying.maxActive)
|
||||
}
|
||||
sort.Strings(underlying.stageNames)
|
||||
if !reflect.DeepEqual(underlying.stageNames, []string{"dnd/scenes", "dnd/spells"}) {
|
||||
t.Fatalf("scheduled stage names = %#v, want both production LLM callers", underlying.stageNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionBundlePreservesLaneAndChunkOrder(t *testing.T) {
|
||||
configPath := writeTestConfig(t, `version: 2
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
options:
|
||||
max_units: 1
|
||||
artifacts:
|
||||
zeta:
|
||||
extract: dnd/spells
|
||||
alpha:
|
||||
extract: dnd/spells
|
||||
`)
|
||||
outputDir := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session",
|
||||
"--config", configPath,
|
||||
"--input", fixturePath(t, "examples/seriatim-minimal-transcript.json"),
|
||||
"--output-dir", outputDir,
|
||||
"--diagnostics-dir", t.TempDir(),
|
||||
}, &stdout, &stderr, Options{LLMClientFactory: fakeLLMFactory(orderingProductionLLMClient{}, nil)})
|
||||
if code != 0 {
|
||||
t.Fatalf("RunWithOptions() code = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
runDir := onlyChildDir(t, outputDir)
|
||||
var index struct {
|
||||
OutputFiles []struct {
|
||||
LaneID string `json:"lane_id"`
|
||||
} `json:"output_files"`
|
||||
}
|
||||
readJSONFile(t, filepath.Join(runDir, "index.json"), &index)
|
||||
if len(index.OutputFiles) != 2 {
|
||||
t.Fatalf("output index entries = %#v, want two lanes", index.OutputFiles)
|
||||
}
|
||||
if got := []string{index.OutputFiles[0].LaneID, index.OutputFiles[1].LaneID}; !reflect.DeepEqual(got, []string{"alpha", "zeta"}) {
|
||||
t.Fatalf("output lane order = %#v, want resolved lane order", got)
|
||||
}
|
||||
for _, laneID := range []string{"alpha", "zeta"} {
|
||||
var payload struct {
|
||||
SpellCasts []struct {
|
||||
Spell string `json:"spell"`
|
||||
SourceRefs []source.SourceRef `json:"source_refs"`
|
||||
} `json:"spell_casts"`
|
||||
}
|
||||
readJSONFile(t, filepath.Join(runDir, "lanes", laneID+".json"), &payload)
|
||||
if len(payload.SpellCasts) != 2 {
|
||||
t.Fatalf("lane %q spell casts = %#v, want one per source chunk", laneID, payload.SpellCasts)
|
||||
}
|
||||
got := []any{
|
||||
payload.SpellCasts[0].Spell, payload.SpellCasts[0].SourceRefs[0].StartUnitID,
|
||||
payload.SpellCasts[1].Spell, payload.SpellCasts[1].SourceRefs[0].StartUnitID,
|
||||
}
|
||||
if !reflect.DeepEqual(got, []any{"Cure Wounds", 1, "Shield", 2}) {
|
||||
t.Fatalf("lane %q chunk handoff order = %#v, want source chunk order", laneID, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type blockingProductionLLMClient struct {
|
||||
mu sync.Mutex
|
||||
active int
|
||||
maxActive int
|
||||
stageNames []string
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
type orderingProductionLLMClient struct{}
|
||||
|
||||
func (orderingProductionLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
material := req.Inputs["transcript"]
|
||||
unitID := 1
|
||||
spellName := "Cure Wounds"
|
||||
if strings.Contains(string(material.Content), "Shield") {
|
||||
unitID = 2
|
||||
spellName = "Shield"
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"spell_casts": []map[string]any{{
|
||||
"caster": "Aria",
|
||||
"spell": spellName,
|
||||
"effect": "Fixture effect.",
|
||||
"narrative_description": "Fixture spell cast.",
|
||||
"source_refs": []map[string]any{{
|
||||
"start_unit_id": unitID,
|
||||
"end_unit_id": unitID,
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
if err := json.Unmarshal(payload, out); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{Content: payload}, nil
|
||||
}
|
||||
|
||||
func newBlockingProductionLLMClient() *blockingProductionLLMClient {
|
||||
return &blockingProductionLLMClient{entered: make(chan struct{}, 2), release: make(chan struct{}, 2)}
|
||||
}
|
||||
|
||||
func (client *blockingProductionLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
client.mu.Lock()
|
||||
client.active++
|
||||
if client.active > client.maxActive {
|
||||
client.maxActive = client.active
|
||||
}
|
||||
client.stageNames = append(client.stageNames, req.StageName)
|
||||
client.mu.Unlock()
|
||||
client.entered <- struct{}{}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return contracts.StructuredCompletionResponse{}, ctx.Err()
|
||||
case <-client.release:
|
||||
}
|
||||
|
||||
client.mu.Lock()
|
||||
client.active--
|
||||
client.mu.Unlock()
|
||||
|
||||
var payload []byte
|
||||
switch req.StageName {
|
||||
case scenes.Key:
|
||||
payload = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2,"short_title":"Spell","primary_mode":"Narrative","main_participants":["Aria"],"summary":"Aria casts a spell.","boundary_note":"Complete source.","boundary_confidence":"High"}],"boundary_caveats":[]}`)
|
||||
case spells.Key:
|
||||
payload = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Healing","narrative_description":"Aria casts Cure Wounds.","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)
|
||||
}
|
||||
if err := json.Unmarshal(payload, out); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
return contracts.StructuredCompletionResponse{Content: payload}, nil
|
||||
}
|
||||
|
||||
func sceneManifestMetadata(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
chunker, err := scenes.New(orderingProductionLLMClient{}, scenes.Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("construct scene chunker: %v", err)
|
||||
}
|
||||
return chunker.ManifestMetadata()
|
||||
}
|
||||
|
||||
func spellManifestMetadata(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
extractor, err := spells.New(orderingProductionLLMClient{}, spells.Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("construct spell extractor: %v", err)
|
||||
}
|
||||
return extractor.ManifestMetadata()
|
||||
}
|
||||
|
||||
func assertAssetNames(t *testing.T, getFS func() (fs.FS, error), want []string) {
|
||||
t.Helper()
|
||||
fSys, err := getFS()
|
||||
if err != nil {
|
||||
t.Fatalf("asset filesystem error = %v, want nil", err)
|
||||
}
|
||||
var got []string
|
||||
if err := fs.WalkDir(fSys, ".", func(path string, entry fs.DirEntry, err error) error {
|
||||
if err == nil && !entry.IsDir() {
|
||||
got = append(got, path)
|
||||
}
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("walk assets: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("asset names = %#v, want compatibility snapshot %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedValidatorKeys(chains []pipeline.ResolvedValidatorChain, stage pipeline.ModuleStage, laneID, moduleKey string) []string {
|
||||
for _, chain := range chains {
|
||||
if chain.Stage == stage && chain.LaneID == laneID && chain.ModuleKey == moduleKey {
|
||||
keys := make([]string, 0, len(chain.Validators))
|
||||
for _, validator := range chain.Validators {
|
||||
keys = append(keys, validator.Binding.Module)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func relativeFileNames(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
var names []string
|
||||
if err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil || entry.IsDir() {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names = append(names, filepath.ToSlash(rel))
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("walk durable output: %v", err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func assertJSONEqual(t *testing.T, got, want []byte) {
|
||||
t.Helper()
|
||||
var gotValue any
|
||||
var wantValue any
|
||||
if err := json.Unmarshal(got, &gotValue); err != nil {
|
||||
t.Fatalf("unmarshal actual JSON: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(want, &wantValue); err != nil {
|
||||
t.Fatalf("unmarshal expected JSON: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotValue, wantValue) {
|
||||
t.Fatalf("JSON = %#v, want compatibility snapshot %#v", gotValue, wantValue)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestChunkCacheDefaults(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Workspace.ChunkCache.Mode != pipeline.ChunkCacheAuto || cfg.Workspace.ChunkCache.Directory != "" {
|
||||
t.Fatalf("chunk cache defaults = %#v", cfg.Workspace.ChunkCache)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkCacheFileConfiguration(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
workspace:
|
||||
chunk_cache:
|
||||
mode: refresh
|
||||
directory: " ./state/../plans "
|
||||
`)
|
||||
if cfg.Workspace.ChunkCache.Mode != pipeline.ChunkCacheRefresh {
|
||||
t.Fatalf("mode = %q", cfg.Workspace.ChunkCache.Mode)
|
||||
}
|
||||
if got, want := cfg.Workspace.ChunkCache.Directory, filepath.Clean("./state/../plans"); got != want {
|
||||
t.Fatalf("directory = %q, want %q", got, want)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkCacheEnvironmentOverridesFile(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
workspace:
|
||||
chunk_cache:
|
||||
mode: bypass
|
||||
directory: /file/plans
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := Default()
|
||||
if err := cfg.ApplyFileConfigWithLookup(fileCfg, emptyLookup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.ApplyEnvOverridesWithLookup(mapLookup(map[string]string{
|
||||
"NOTARIUS_WORKSPACE_CHUNK_CACHE_MODE": "refresh",
|
||||
"NOTARIUS_WORKSPACE_CHUNK_CACHE_DIR": " /environment/../cache/plans ",
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Workspace.ChunkCache.Mode != pipeline.ChunkCacheRefresh || cfg.Workspace.ChunkCache.Directory != filepath.Clean("/environment/../cache/plans") {
|
||||
t.Fatalf("effective chunk cache = %#v", cfg.Workspace.ChunkCache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkCacheEmptyDirectoryEnvironmentSelectsDefault(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Workspace.ChunkCache.Directory = "/file/plans"
|
||||
if err := cfg.ApplyEnvOverridesWithLookup(mapLookup(map[string]string{"NOTARIUS_WORKSPACE_CHUNK_CACHE_DIR": " \t "})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Workspace.ChunkCache.Directory != "" {
|
||||
t.Fatalf("directory = %q, want unset", cfg.Workspace.ChunkCache.Directory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkCacheRejectsInvalidSuppliedModes(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte("version: 2\nworkspace:\n chunk_cache:\n mode: sometimes\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := Default()
|
||||
if err := cfg.ApplyFileConfigWithLookup(fileCfg, emptyLookup); err == nil || !strings.Contains(err.Error(), "workspace.chunk_cache.mode") {
|
||||
t.Fatalf("file mode error = %v", err)
|
||||
}
|
||||
|
||||
cfg = Default()
|
||||
if err := cfg.ApplyEnvOverridesWithLookup(mapLookup(map[string]string{"NOTARIUS_WORKSPACE_CHUNK_CACHE_MODE": "sometimes"})); err == nil || !strings.Contains(err.Error(), "NOTARIUS_WORKSPACE_CHUNK_CACHE_MODE") {
|
||||
t.Fatalf("environment mode error = %v", err)
|
||||
}
|
||||
|
||||
cfg = Default()
|
||||
cfg.Workspace.ChunkCache.Mode = "sometimes"
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "chunk cache") {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkCacheConfigurationClonesAndRedacts(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Workspace.ChunkCache = WorkspaceChunkCacheConfig{Mode: pipeline.ChunkCacheRefresh, Directory: "/var/cache/notarius/chunk-plans"}
|
||||
cloned := cloneConfig(cfg)
|
||||
redacted := cfg.Redacted()
|
||||
if cloned.Workspace.ChunkCache != cfg.Workspace.ChunkCache || redacted.Workspace.ChunkCache != cfg.Workspace.ChunkCache {
|
||||
t.Fatalf("cloned=%#v redacted=%#v", cloned.Workspace.ChunkCache, redacted.Workspace.ChunkCache)
|
||||
}
|
||||
redacted.Workspace.ChunkCache.Directory = "/changed"
|
||||
if cfg.Workspace.ChunkCache.Directory != "/var/cache/notarius/chunk-plans" {
|
||||
t.Fatal("redacted mutation changed original")
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkCacheDirectoryValidation(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Workspace.ChunkCache.Directory = "/var/cache/notarius/chunk-plans"
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate(system root) error = %v", err)
|
||||
}
|
||||
cfg.Workspace.ChunkCache.Directory = "bad\x00path"
|
||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "NUL") {
|
||||
t.Fatalf("Validate(NUL directory) error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
)
|
||||
|
||||
func TestDefaultValues(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
if cfg.Scriptorium.ProfileDir != "" || cfg.Scriptorium.ProfileFile != "" {
|
||||
t.Fatalf("unexpected Scriptorium profile source defaults: %+v", cfg.Scriptorium)
|
||||
}
|
||||
if len(cfg.Pipelines) != 0 {
|
||||
t.Fatalf("expected no built-in pipeline profiles, got %v", cfg.Pipelines)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 1 {
|
||||
t.Fatalf("unexpected total LLM concurrency: %d", cfg.Concurrency.TotalLLM)
|
||||
}
|
||||
if got := cfg.Concurrency.StageWorkers["extract"]; got != 1 {
|
||||
t.Fatalf("unexpected extract workers: %d", got)
|
||||
}
|
||||
if cfg.Diagnostics.WorkDir != "/tmp/notarius" {
|
||||
t.Fatalf("unexpected diagnostics work dir: %q", cfg.Diagnostics.WorkDir)
|
||||
}
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionAuto {
|
||||
t.Fatalf("unexpected diagnostics retention: %q", cfg.Diagnostics.Retention)
|
||||
}
|
||||
if cfg.Workspace.Directory != "" {
|
||||
t.Fatalf("unexpected workspace directory: %q", cfg.Workspace.Directory)
|
||||
}
|
||||
if !cfg.Workspace.Diagnostics.Enabled || !cfg.DiagnosticsEnabled() {
|
||||
t.Fatalf("expected workspace diagnostics enabled by default: %+v", cfg.Workspace.Diagnostics)
|
||||
}
|
||||
if cfg.Workspace.Diagnostics.Retention != "" {
|
||||
t.Fatalf("unexpected workspace diagnostics retention: %q", cfg.Workspace.Diagnostics.Retention)
|
||||
}
|
||||
if cfg.Workspace.Resume.Enabled {
|
||||
t.Fatalf("workspace resume should be disabled by default")
|
||||
}
|
||||
if cfg.Workspace.Debug.Enabled {
|
||||
t.Fatalf("workspace debug should be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigMergesWithDefaults(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
scriptorium:
|
||||
profile_dir: ./profiles
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil {
|
||||
t.Fatalf("ApplyFileConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Scriptorium.ProfileDir != "./profiles" {
|
||||
t.Fatalf("expected Scriptorium profile dir, got %+v", cfg.Scriptorium)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 1 {
|
||||
t.Fatalf("expected default concurrency preserved, got %d", cfg.Concurrency.TotalLLM)
|
||||
}
|
||||
if got := cfg.Concurrency.StageWorkers["extract"]; got != 1 {
|
||||
t.Fatalf("expected default extract workers preserved, got %d", got)
|
||||
}
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionAuto {
|
||||
t.Fatalf("expected default diagnostics retention preserved, got %q", cfg.Diagnostics.Retention)
|
||||
}
|
||||
if _, ok := cfg.Pipelines["example"]; !ok {
|
||||
t.Fatalf("expected file pipeline to be applied")
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestResolveRejectsEmptyAndUnknownPipelineID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pipelineID string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", pipelineID: " ", want: "pipeline id"},
|
||||
{name: "unknown", pipelineID: "missing", want: "not configured"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := validConfig().Resolve(ResolveInput{PipelineID: tc.pipelineID, Catalog: fakeCatalog(t)})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMaterializesDefaultExtractWorkersFromEffectiveTotal(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Concurrency.TotalLLM = 4
|
||||
|
||||
effective, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
}
|
||||
if got := effective.Config.Concurrency.StageWorkers["extract"]; got != 4 {
|
||||
t.Fatalf("effective extract workers = %d, want total concurrency 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLaneFilteringSuccessAndFailure(t *testing.T) {
|
||||
effective, err := validConfig().Resolve(ResolveInput{
|
||||
PipelineID: " example ",
|
||||
Only: []string{" notes "},
|
||||
Catalog: fakeCatalog(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
|
||||
if effective.PipelineID != "example" {
|
||||
t.Fatalf("unexpected pipeline ID: %q", effective.PipelineID)
|
||||
}
|
||||
if len(effective.ResolvedPipeline.ArtifactLanes) != 1 || effective.ResolvedPipeline.ArtifactLanes[0].ID != "notes" {
|
||||
t.Fatalf("unexpected resolved lanes: %+v", effective.ResolvedPipeline.ArtifactLanes)
|
||||
}
|
||||
if effective.ResolvedPipeline.Digest == "" {
|
||||
t.Fatalf("expected digest")
|
||||
}
|
||||
|
||||
_, err = validConfig().Resolve(ResolveInput{
|
||||
PipelineID: "example",
|
||||
Only: []string{"missing"},
|
||||
Catalog: fakeCatalog(t),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "selected artifact lane") {
|
||||
t.Fatalf("expected invalid lane error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsesTrimmedPipelineMapKeys(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Pipelines[" example "] = cfg.Pipelines["example"]
|
||||
delete(cfg.Pipelines, "example")
|
||||
|
||||
effective, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if effective.PipelineID != "example" {
|
||||
t.Fatalf("unexpected pipeline ID: %q", effective.PipelineID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSurfacesUnknownModuleKeyThroughCatalog(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
lane := cfg.Pipelines["example"].Artifacts["events"]
|
||||
lane.Extract = pipeline.Binding("missing/extract")
|
||||
cfg.Pipelines["example"].Artifacts["events"] = lane
|
||||
|
||||
_, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing/extract") || !strings.Contains(err.Error(), "events") {
|
||||
t.Fatalf("expected unknown module error with lane context, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSurfacesMissingCapabilityThroughCatalog(t *testing.T) {
|
||||
_, err := validConfig().Resolve(ResolveInput{
|
||||
PipelineID: "example",
|
||||
Catalog: fakeCatalog(t, pipeline.ModuleSpec{
|
||||
Key: "json",
|
||||
Stage: pipeline.StageOutput,
|
||||
Requires: []string{"missing-capability"},
|
||||
}),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "missing capability") || !strings.Contains(err.Error(), "json") {
|
||||
t.Fatalf("expected missing capability error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCanBindSceneChunkerFromCatalog(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
profile := cfg.Pipelines["example"]
|
||||
profile.Chunk = pipeline.Binding("dnd/scenes")
|
||||
lane := profile.Artifacts["events"]
|
||||
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{"events": lane}
|
||||
cfg.Pipelines["example"] = profile
|
||||
|
||||
catalog := fakeCatalog(t,
|
||||
pipeline.ModuleSpec{
|
||||
Key: "fake/input",
|
||||
Stage: pipeline.StageInput,
|
||||
Provides: []string{"source.transcript"},
|
||||
},
|
||||
pipeline.ModuleSpec{
|
||||
Key: "fake/extract",
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: []string{"chunks", "source.transcript"},
|
||||
Provides: []string{"artifact"},
|
||||
},
|
||||
)
|
||||
mustRegisterChunker(t, catalog.Chunkers, pipeline.ModuleSpec{
|
||||
Key: "dnd/scenes",
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: []string{"source.transcript"},
|
||||
Provides: []string{"chunks"},
|
||||
})
|
||||
|
||||
effective, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: catalog})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v, want nil", err)
|
||||
}
|
||||
if got := effective.ResolvedPipeline.Chunk.Module; got != "dnd/scenes" {
|
||||
t.Fatalf("Chunk.Module = %q, want dnd/scenes", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDigestChangesWhenEffectiveConfigChanges(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
first, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve first: %v", err)
|
||||
}
|
||||
|
||||
lane := cfg.Pipelines["example"].Artifacts["events"]
|
||||
lane.Extract.Options = map[string]any{"temperature": 0.2}
|
||||
cfg.Pipelines["example"].Artifacts["events"] = lane
|
||||
second, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve second: %v", err)
|
||||
}
|
||||
|
||||
if first.ResolvedPipeline.Digest == second.ResolvedPipeline.Digest {
|
||||
t.Fatalf("expected digest to change, got %q", first.ResolvedPipeline.Digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLLMProfileOverrideAppliesBeforeDigest(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
profile := cfg.Pipelines["example"]
|
||||
profile.Input.LLMProfile = "input-profile"
|
||||
profile.Output.LLMProfile = "output-profile"
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.Merge.LLMProfile = "merge-profile"
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
{Module: "fake/llm-validator", LLMProfile: "validator-profile"},
|
||||
},
|
||||
}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
|
||||
base, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve base: %v", err)
|
||||
}
|
||||
effective, err := cfg.Resolve(ResolveInput{
|
||||
PipelineID: "example",
|
||||
Catalog: fakeCatalog(t),
|
||||
LLMProfileOverride: "runtime",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve override: %v", err)
|
||||
}
|
||||
|
||||
if base.ResolvedPipeline.Digest == effective.ResolvedPipeline.Digest {
|
||||
t.Fatalf("expected digest to change after LLM profile override")
|
||||
}
|
||||
for _, binding := range llmCapableBindings(effective.ResolvedPipeline) {
|
||||
if binding.LLMProfile != "runtime" {
|
||||
t.Fatalf("LLM-capable binding profile = %q, want runtime", binding.LLMProfile)
|
||||
}
|
||||
}
|
||||
if effective.ResolvedPipeline.Input.LLMProfile != "input-profile" {
|
||||
t.Fatalf("input profile = %q, want original input-profile", effective.ResolvedPipeline.Input.LLMProfile)
|
||||
}
|
||||
if effective.ResolvedPipeline.Output.LLMProfile != "output-profile" {
|
||||
t.Fatalf("output profile = %q, want original output-profile", effective.ResolvedPipeline.Output.LLMProfile)
|
||||
}
|
||||
eventLane := effective.ResolvedPipeline.ArtifactLanes[0]
|
||||
if eventLane.Merge.LLMProfile != "runtime" {
|
||||
t.Fatalf("merge profile = %q, want runtime", eventLane.Merge.LLMProfile)
|
||||
}
|
||||
validatorChain := findEffectiveValidatorChain(effective.ResolvedPipeline.ValidatorChains, pipeline.StageExtract, "events", "fake/extract")
|
||||
if validatorChain == nil || len(validatorChain.Validators) != 1 {
|
||||
t.Fatalf("validator chain = %#v, want one extract validator", effective.ResolvedPipeline.ValidatorChains)
|
||||
}
|
||||
if validatorChain.Validators[0].Binding.LLMProfile != "validator-profile" {
|
||||
t.Fatalf("validator profile = %q, want original validator-profile", validatorChain.Validators[0].Binding.LLMProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func findEffectiveValidatorChain(chains []pipeline.ResolvedValidatorChain, stage pipeline.ModuleStage, laneID string, module string) *pipeline.ResolvedValidatorChain {
|
||||
for i := range chains {
|
||||
if chains[i].Stage == stage && chains[i].LaneID == laneID && chains[i].ModuleKey == module {
|
||||
return &chains[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func llmCapableBindings(resolved pipeline.ResolvedPipeline) []pipeline.ModuleBinding {
|
||||
bindings := []pipeline.ModuleBinding{resolved.Chunk}
|
||||
for _, lane := range resolved.ArtifactLanes {
|
||||
bindings = append(bindings, lane.Extract, lane.Merge, lane.Normalize)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestApplyEnvOverridesOperationalValues(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Pipelines["example"] = pipeline.PipelineProfile{ID: "example", Input: pipeline.Binding("before")}
|
||||
|
||||
err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "3",
|
||||
"NOTARIUS_STAGE_WORKERS_EXTRACT": "2",
|
||||
"NOTARIUS_WORK_DIR": "/tmp/notarius-env",
|
||||
"NOTARIUS_DIAGNOSTICS_RETENTION": "never",
|
||||
"NOTARIUS_WORKSPACE_DIR": "/var/lib/notarius-env",
|
||||
"NOTARIUS_WORKSPACE_DIAGNOSTICS_ENABLED": "false",
|
||||
"NOTARIUS_WORKSPACE_DIAGNOSTICS_RETENTION": "always",
|
||||
"NOTARIUS_WORKSPACE_RESUME_ENABLED": "true",
|
||||
"NOTARIUS_WORKSPACE_DEBUG_ENABLED": "true",
|
||||
"NOTARIUS_PIPELINE_INPUT": "after",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyEnvOverrides: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Scriptorium.ProfileDir != "" || cfg.Scriptorium.ProfileFile != "" {
|
||||
t.Fatalf("LLM environment overrides must not change Scriptorium config: %+v", cfg.Scriptorium)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 3 {
|
||||
t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM)
|
||||
}
|
||||
if got := cfg.Concurrency.StageWorkers["extract"]; got != 2 {
|
||||
t.Fatalf("extract workers = %d, want 2", got)
|
||||
}
|
||||
if cfg.Workspace.Directory != "/var/lib/notarius-env" {
|
||||
t.Fatalf("unexpected workspace directory: %q", cfg.Workspace.Directory)
|
||||
}
|
||||
if cfg.DiagnosticsEnabled() {
|
||||
t.Fatalf("expected workspace diagnostics disabled")
|
||||
}
|
||||
if cfg.Diagnostics.WorkDir != "/var/lib/notarius-env/diagnostics" || cfg.Diagnostics.Retention != diagnostics.RetentionAlways {
|
||||
t.Fatalf("unexpected diagnostics config: %+v", cfg.Diagnostics)
|
||||
}
|
||||
if !cfg.Workspace.Resume.Enabled {
|
||||
t.Fatalf("expected workspace resume enabled")
|
||||
}
|
||||
if !cfg.Workspace.Debug.Enabled {
|
||||
t.Fatalf("expected workspace debug enabled")
|
||||
}
|
||||
if cfg.Pipelines["example"].Input.Module != "before" {
|
||||
t.Fatalf("environment overrides must not change pipeline wiring: %+v", cfg.Pipelines["example"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvOverridesRejectsInvalidIntegers(t *testing.T) {
|
||||
for _, name := range []string{"NOTARIUS_TOTAL_LLM_CONCURRENCY", "NOTARIUS_STAGE_WORKERS_EXTRACT"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{name: "many"}))
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("expected named integer error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageWorkerEnvironmentPrecedenceAndDefaulting(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
concurrency:
|
||||
total_llm: 4
|
||||
stage_workers:
|
||||
extract: 2
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML() error = %v", err)
|
||||
}
|
||||
cfg := Default()
|
||||
if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil {
|
||||
t.Fatalf("ApplyFileConfig() error = %v", err)
|
||||
}
|
||||
if err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "5",
|
||||
"NOTARIUS_STAGE_WORKERS_EXTRACT": "3",
|
||||
})); err != nil {
|
||||
t.Fatalf("ApplyEnvOverrides() error = %v", err)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 5 || cfg.Concurrency.StageWorkers["extract"] != 3 {
|
||||
t.Fatalf("effective concurrency = %#v, want total 5 and extract 3", cfg.Concurrency)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate(overridden) error = %v, want nil", err)
|
||||
}
|
||||
|
||||
defaulted := Default()
|
||||
if err := defaulted.applyEnvOverridesWithLookup(mapLookup(map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6"})); err != nil {
|
||||
t.Fatalf("ApplyEnvOverrides(defaulted) error = %v", err)
|
||||
}
|
||||
if got := defaulted.Concurrency.StageWorkers["extract"]; got != 6 {
|
||||
t.Fatalf("defaulted extract workers = %d, want effective total 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageWorkerRangeValidationUsesFinalEnvironmentTotal(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
concurrency:
|
||||
total_llm: 4
|
||||
stage_workers:
|
||||
extract: 5
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML() error = %v", err)
|
||||
}
|
||||
cfg := Default()
|
||||
if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil {
|
||||
t.Fatalf("ApplyFileConfig() error = %v", err)
|
||||
}
|
||||
if err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6"})); err != nil {
|
||||
t.Fatalf("ApplyEnvOverrides() error = %v", err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v, want final total to make extract workers valid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvOverridesRejectsInvalidBooleans(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"NOTARIUS_WORKSPACE_DIAGNOSTICS_ENABLED",
|
||||
"NOTARIUS_WORKSPACE_RESUME_ENABLED",
|
||||
"NOTARIUS_WORKSPACE_DEBUG_ENABLED",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{name: "maybe"}))
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("expected named boolean error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvOverridesLegacyDiagnosticsRemainCompatibleWithoutWorkspace(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
err := cfg.applyEnvOverridesWithLookup(mapLookup(map[string]string{
|
||||
"NOTARIUS_WORK_DIR": "/tmp/notarius-env",
|
||||
"NOTARIUS_DIAGNOSTICS_RETENTION": "never",
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyEnvOverrides: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Diagnostics.WorkDir != "/tmp/notarius-env" {
|
||||
t.Fatalf("diagnostics work dir = %q, want legacy env", cfg.Diagnostics.WorkDir)
|
||||
}
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionNever {
|
||||
t.Fatalf("diagnostics retention = %q, want legacy env", cfg.Diagnostics.Retention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromEnvUsesDefaultConfig(t *testing.T) {
|
||||
t.Setenv("NOTARIUS_TOTAL_LLM_CONCURRENCY", "2")
|
||||
|
||||
cfg, err := LoadFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFromEnv: %v", err)
|
||||
}
|
||||
if cfg.Scriptorium.ProfileDir != "" || cfg.Scriptorium.ProfileFile != "" {
|
||||
t.Fatalf("unexpected Scriptorium config from env: %+v", cfg.Scriptorium)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 2 {
|
||||
t.Fatalf("expected env concurrency override, got %+v", cfg.Concurrency)
|
||||
}
|
||||
if got := cfg.Concurrency.StageWorkers["extract"]; got != 2 {
|
||||
t.Fatalf("expected extract workers to default to total, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -1,725 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
)
|
||||
|
||||
func TestParseMinimalValidConfig(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
if fileCfg.Version != SupportedFileConfigVersion {
|
||||
t.Fatalf("unexpected version: %d", fileCfg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte("version: 2\n"), 0o644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
fileCfg, err := LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFileConfig: %v", err)
|
||||
}
|
||||
if fileCfg.Version != SupportedFileConfigVersion {
|
||||
t.Fatalf("unexpected version: %d", fileCfg.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigRejectsUnknownYAMLFields(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
unexpected: true
|
||||
`))
|
||||
if err == nil || !strings.Contains(err.Error(), "field unexpected not found") {
|
||||
t.Fatalf("expected unknown field error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigRejectsUnknownModuleBindingFields(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input:
|
||||
module: fake/input
|
||||
unexpected: true
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
`))
|
||||
if err == nil || !strings.Contains(err.Error(), "field unexpected not found") {
|
||||
t.Fatalf("expected unknown binding field error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigRejectsMissingAndUnsupportedVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
want string
|
||||
}{
|
||||
{name: "missing", data: `scriptorium: {}`, want: "version is required"},
|
||||
{name: "unsupported", data: `version: 1`, want: "unsupported config version"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(tc.data))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigRejectsStaleLLMProfiles(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
llm_profiles:
|
||||
default: {}
|
||||
`))
|
||||
if err == nil || !strings.Contains(err.Error(), "llm_profiles") {
|
||||
t.Fatalf("expected stale llm_profiles error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigScriptoriumProfileSources(t *testing.T) {
|
||||
t.Run("profile dir", func(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
scriptorium:
|
||||
profile_dir: ./profiles
|
||||
`)
|
||||
if cfg.Scriptorium.ProfileDir != "./profiles" || cfg.Scriptorium.ProfileFile != "" {
|
||||
t.Fatalf("Scriptorium = %+v, want profile_dir", cfg.Scriptorium)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("profile file", func(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
scriptorium:
|
||||
profile_file: ./profiles.yml
|
||||
`)
|
||||
if cfg.Scriptorium.ProfileFile != "./profiles.yml" || cfg.Scriptorium.ProfileDir != "" {
|
||||
t.Fatalf("Scriptorium = %+v, want profile_file", cfg.Scriptorium)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseFileConfigModuleBindingForms(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
chunk:
|
||||
module: generic
|
||||
retries: 2
|
||||
options:
|
||||
size: 10
|
||||
flags:
|
||||
- alpha
|
||||
nested:
|
||||
enabled: true
|
||||
artifacts:
|
||||
events:
|
||||
extract:
|
||||
module: fake/extract
|
||||
llm_profile: fast
|
||||
retries: 3
|
||||
options:
|
||||
temperature: 0
|
||||
merge:
|
||||
module: appendorder
|
||||
retries: 1
|
||||
normalize:
|
||||
module: noop
|
||||
output: json
|
||||
`)
|
||||
|
||||
profile := cfg.Pipelines["example"]
|
||||
if profile.Input.Module != "fake/input" {
|
||||
t.Fatalf("unexpected input binding: %+v", profile.Input)
|
||||
}
|
||||
if profile.Chunk.Module != "generic" {
|
||||
t.Fatalf("unexpected chunk binding: %+v", profile.Chunk)
|
||||
}
|
||||
if profile.Chunk.Retries != 2 {
|
||||
t.Fatalf("chunk retries = %d, want 2", profile.Chunk.Retries)
|
||||
}
|
||||
if profile.Chunk.Options["size"] != 10 {
|
||||
t.Fatalf("expected chunk options to preserve scalar, got %#v", profile.Chunk.Options)
|
||||
}
|
||||
if !reflect.DeepEqual(profile.Chunk.Options["flags"], []any{"alpha"}) {
|
||||
t.Fatalf("expected list option, got %#v", profile.Chunk.Options["flags"])
|
||||
}
|
||||
nested, ok := profile.Chunk.Options["nested"].(map[string]any)
|
||||
if !ok || nested["enabled"] != true {
|
||||
t.Fatalf("expected nested map option, got %#v", profile.Chunk.Options["nested"])
|
||||
}
|
||||
|
||||
lane := profile.Artifacts["events"]
|
||||
if lane.Extract.Module != "fake/extract" || lane.Extract.LLMProfile != "fast" {
|
||||
t.Fatalf("unexpected extract binding: %+v", lane.Extract)
|
||||
}
|
||||
if lane.Extract.Retries != 3 || lane.Merge.Retries != 1 {
|
||||
t.Fatalf("unexpected retries: extract=%d merge=%d", lane.Extract.Retries, lane.Merge.Retries)
|
||||
}
|
||||
if lane.Extract.Options["temperature"] != 0 {
|
||||
t.Fatalf("expected object options, got %#v", lane.Extract.Options)
|
||||
}
|
||||
if lane.Merge.Module != "appendorder" || lane.Normalize.Module != "noop" {
|
||||
t.Fatalf("unexpected lane defaults: %+v", lane)
|
||||
}
|
||||
if profile.Output.Module != "json" {
|
||||
t.Fatalf("unexpected output binding: %+v", profile.Output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigReferenceMaps(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
references:
|
||||
" roster ": " ./shared-roster.yml "
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
references:
|
||||
" lore ": " ./lore.md "
|
||||
`)
|
||||
|
||||
profile := cfg.Pipelines["example"]
|
||||
if !reflect.DeepEqual(profile.References, map[string]string{"roster": "./shared-roster.yml"}) {
|
||||
t.Fatalf("pipeline references = %#v, want trimmed map", profile.References)
|
||||
}
|
||||
gotLaneRefs := profile.Artifacts["events"].References
|
||||
if !reflect.DeepEqual(gotLaneRefs, map[string]string{"lore": "./lore.md"}) {
|
||||
t.Fatalf("lane references = %#v, want trimmed map", gotLaneRefs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigStageLocalReferenceMaps(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
chunk:
|
||||
module: generic
|
||||
references:
|
||||
" scene_guide ": " ./scenes.md "
|
||||
artifacts:
|
||||
events:
|
||||
extract:
|
||||
module: fake/extract
|
||||
references:
|
||||
" glossary ": " ./glossary.md "
|
||||
" roster ": " ./extract-roster.yml "
|
||||
references:
|
||||
roster: ./legacy-roster.yml
|
||||
lore: ./lore.md
|
||||
merge:
|
||||
module: appendorder
|
||||
references:
|
||||
" merge_notes ": " ./merge.md "
|
||||
normalize:
|
||||
module: noop
|
||||
references:
|
||||
" normalization_notes ": " ./normalization.md "
|
||||
`)
|
||||
|
||||
profile := cfg.Pipelines["example"]
|
||||
if !reflect.DeepEqual(profile.Chunk.References, map[string]string{"scene_guide": "./scenes.md"}) {
|
||||
t.Fatalf("chunk references = %#v, want trimmed map", profile.Chunk.References)
|
||||
}
|
||||
lane := profile.Artifacts["events"]
|
||||
if !reflect.DeepEqual(lane.References, map[string]string{"lore": "./lore.md", "roster": "./legacy-roster.yml"}) {
|
||||
t.Fatalf("lane references = %#v, want trimmed map", lane.References)
|
||||
}
|
||||
wantExtract := map[string]string{
|
||||
"glossary": "./glossary.md",
|
||||
"lore": "./lore.md",
|
||||
"roster": "./extract-roster.yml",
|
||||
}
|
||||
if !reflect.DeepEqual(lane.Extract.References, wantExtract) {
|
||||
t.Fatalf("extract references = %#v, want legacy merged with extract override %#v", lane.Extract.References, wantExtract)
|
||||
}
|
||||
if !reflect.DeepEqual(lane.Merge.References, map[string]string{"merge_notes": "./merge.md"}) {
|
||||
t.Fatalf("merge references = %#v, want trimmed map", lane.Merge.References)
|
||||
}
|
||||
if !reflect.DeepEqual(lane.Normalize.References, map[string]string{"normalization_notes": "./normalization.md"}) {
|
||||
t.Fatalf("normalize references = %#v, want trimmed map", lane.Normalize.References)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigValidatorMixedBindingForms(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
validators:
|
||||
- fake/validator
|
||||
- module: fake/llm-validator
|
||||
llm_profile: careful
|
||||
options:
|
||||
threshold: 0.7
|
||||
`)
|
||||
|
||||
validators := cfg.Pipelines["example"].Artifacts["events"].Validators
|
||||
if len(validators) != 2 {
|
||||
t.Fatalf("expected two validators, got %d", len(validators))
|
||||
}
|
||||
if validators[0].Module != "fake/validator" {
|
||||
t.Fatalf("unexpected shorthand validator: %+v", validators[0])
|
||||
}
|
||||
if validators[1].Module != "fake/llm-validator" || validators[1].LLMProfile != "careful" {
|
||||
t.Fatalf("unexpected object validator: %+v", validators[1])
|
||||
}
|
||||
if validators[1].Options["threshold"] != 0.7 {
|
||||
t.Fatalf("unexpected validator options: %#v", validators[1].Options)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFileConfigStageLocalValidatorOverrides(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
chunk:
|
||||
module: generic
|
||||
validators: []
|
||||
artifacts:
|
||||
events:
|
||||
extract:
|
||||
module: fake/extract
|
||||
validators:
|
||||
- fake/validator
|
||||
- module: fake/llm-validator
|
||||
llm_profile: careful
|
||||
options:
|
||||
threshold: 0.7
|
||||
merge:
|
||||
module: appendorder
|
||||
validators: []
|
||||
normalize:
|
||||
module: noop
|
||||
`)
|
||||
|
||||
profile := cfg.Pipelines["example"]
|
||||
if !profile.Chunk.Validators.Set || len(profile.Chunk.Validators.Validators) != 0 {
|
||||
t.Fatalf("chunk validator override = %#v, want explicit empty", profile.Chunk.Validators)
|
||||
}
|
||||
lane := profile.Artifacts["events"]
|
||||
if !lane.Extract.Validators.Set {
|
||||
t.Fatalf("extract validator override Set = false, want true")
|
||||
}
|
||||
validators := lane.Extract.Validators.Validators
|
||||
if len(validators) != 2 {
|
||||
t.Fatalf("extract validators = %#v, want two validators", validators)
|
||||
}
|
||||
if validators[0].Module != "fake/validator" {
|
||||
t.Fatalf("first validator = %#v, want fake/validator", validators[0])
|
||||
}
|
||||
if validators[1].Module != "fake/llm-validator" || validators[1].LLMProfile != "careful" {
|
||||
t.Fatalf("second validator = %#v, want LLM validator with profile", validators[1])
|
||||
}
|
||||
if validators[1].Options["threshold"] != 0.7 {
|
||||
t.Fatalf("second validator options = %#v, want threshold", validators[1].Options)
|
||||
}
|
||||
if !lane.Merge.Validators.Set || len(lane.Merge.Validators.Validators) != 0 {
|
||||
t.Fatalf("merge validator override = %#v, want explicit empty", lane.Merge.Validators)
|
||||
}
|
||||
if lane.Normalize.Validators.Set {
|
||||
t.Fatalf("normalize validator override Set = true, want omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigRejectsDuplicateTrimmedPipelineIDs(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
" example ":
|
||||
input: fake/other-input
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
||||
if err == nil || !strings.Contains(err.Error(), "pipeline id") || !strings.Contains(err.Error(), "duplicated") {
|
||||
t.Fatalf("expected duplicate pipeline ID error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigRejectsDuplicateTrimmedArtifactLaneIDs(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
" events ":
|
||||
extract: fake/other-extract
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
||||
if err == nil || !strings.Contains(err.Error(), `pipeline "example" artifact lane id`) || !strings.Contains(err.Error(), "duplicated") {
|
||||
t.Fatalf("expected duplicate artifact lane ID error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigRejectsDuplicateTrimmedReferenceSlots(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "pipeline",
|
||||
raw: `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
references:
|
||||
roster: ./first.yml
|
||||
" roster ": ./second.yml
|
||||
`,
|
||||
want: `pipeline "example" reference slot`,
|
||||
},
|
||||
{
|
||||
name: "lane",
|
||||
raw: `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
references:
|
||||
roster: ./first.yml
|
||||
" roster ": ./second.yml
|
||||
`,
|
||||
want: `pipeline "example" lane "events" reference slot`,
|
||||
},
|
||||
{
|
||||
name: "chunk",
|
||||
raw: `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
chunk:
|
||||
module: generic
|
||||
references:
|
||||
roster: ./first.yml
|
||||
" roster ": ./second.yml
|
||||
`,
|
||||
want: `pipeline "example" chunk reference slot`,
|
||||
},
|
||||
{
|
||||
name: "extract",
|
||||
raw: `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract:
|
||||
module: fake/extract
|
||||
references:
|
||||
roster: ./first.yml
|
||||
" roster ": ./second.yml
|
||||
`,
|
||||
want: `pipeline "example" lane "events" extract reference slot`,
|
||||
},
|
||||
{
|
||||
name: "normalize",
|
||||
raw: `
|
||||
version: 2
|
||||
pipelines:
|
||||
example:
|
||||
input: fake/input
|
||||
artifacts:
|
||||
events:
|
||||
extract: fake/extract
|
||||
normalize:
|
||||
module: noop
|
||||
references:
|
||||
roster: ./first.yml
|
||||
" roster ": ./second.yml
|
||||
`,
|
||||
want: `pipeline "example" lane "events" normalize reference slot`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(tc.raw))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
cfg := Default()
|
||||
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "duplicated") {
|
||||
t.Fatalf("expected duplicate reference slot error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigRejectsInvalidScriptoriumSources(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "empty profile dir", raw: "profile_dir: ' '", want: "profile_dir"},
|
||||
{name: "empty profile file", raw: "profile_file: ' '", want: "profile_file"},
|
||||
{name: "both sources", raw: "profile_dir: ./profiles\n profile_file: ./profiles.yml", want: "mutually exclusive"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(`
|
||||
version: 2
|
||||
scriptorium:
|
||||
` + tc.raw + `
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
||||
if err == nil {
|
||||
err = cfg.Validate()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigOperationalSections(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
concurrency:
|
||||
total_llm: 4
|
||||
diagnostics:
|
||||
work_dir: /tmp/notarius-test
|
||||
retention: always
|
||||
`)
|
||||
|
||||
if cfg.Concurrency.TotalLLM != 4 {
|
||||
t.Fatalf("unexpected total concurrency: %d", cfg.Concurrency.TotalLLM)
|
||||
}
|
||||
if got := cfg.Concurrency.StageWorkers["extract"]; got != 4 {
|
||||
t.Fatalf("default extract workers = %d, want total concurrency", got)
|
||||
}
|
||||
if cfg.Diagnostics.WorkDir != "/tmp/notarius-test" {
|
||||
t.Fatalf("unexpected work dir: %q", cfg.Diagnostics.WorkDir)
|
||||
}
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionAlways {
|
||||
t.Fatalf("unexpected retention: %q", cfg.Diagnostics.Retention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigStageWorkers(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
concurrency:
|
||||
total_llm: 4
|
||||
stage_workers:
|
||||
extract: 3
|
||||
`)
|
||||
|
||||
if got := cfg.Concurrency.StageWorkers["extract"]; got != 3 {
|
||||
t.Fatalf("extract workers = %d, want 3", got)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigEmptyStageWorkersDefaultsExtractToTotal(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
concurrency:
|
||||
total_llm: 4
|
||||
stage_workers: {}
|
||||
`)
|
||||
if got := cfg.Concurrency.StageWorkers["extract"]; got != 4 {
|
||||
t.Fatalf("extract workers = %d, want total concurrency 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigRejectsUnsupportedStageWorkerKeys(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", key: "' '", want: "must not be empty"},
|
||||
{name: "unknown", key: "merge", want: "not supported"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fileCfg, err := ParseFileConfigYAML([]byte("version: 2\nconcurrency:\n stage_workers:\n " + test.key + ": 1\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML() error = %v", err)
|
||||
}
|
||||
cfg := Default()
|
||||
err = cfg.applyFileConfigWithLookup(fileCfg, emptyLookup)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("ApplyFileConfig() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigWorkspaceSection(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
workspace:
|
||||
directory: /var/lib/notarius
|
||||
diagnostics:
|
||||
enabled: false
|
||||
retention: never
|
||||
resume:
|
||||
enabled: true
|
||||
debug:
|
||||
enabled: true
|
||||
diagnostics:
|
||||
work_dir: /tmp/legacy
|
||||
retention: always
|
||||
`)
|
||||
|
||||
if cfg.Workspace.Directory != "/var/lib/notarius" {
|
||||
t.Fatalf("workspace directory = %q, want /var/lib/notarius", cfg.Workspace.Directory)
|
||||
}
|
||||
if cfg.DiagnosticsEnabled() {
|
||||
t.Fatalf("expected diagnostics disabled")
|
||||
}
|
||||
if cfg.Diagnostics.WorkDir != "/var/lib/notarius/diagnostics" {
|
||||
t.Fatalf("effective diagnostics work dir = %q, want workspace diagnostics root", cfg.Diagnostics.WorkDir)
|
||||
}
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionNever {
|
||||
t.Fatalf("effective diagnostics retention = %q, want workspace override", cfg.Diagnostics.Retention)
|
||||
}
|
||||
if !cfg.Workspace.Resume.Enabled {
|
||||
t.Fatalf("expected resume enabled")
|
||||
}
|
||||
if !cfg.Workspace.Debug.Enabled {
|
||||
t.Fatalf("expected debug enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigLegacyDiagnosticsRemainCompatible(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
diagnostics:
|
||||
work_dir: /tmp/legacy
|
||||
retention: always
|
||||
`)
|
||||
|
||||
if cfg.Workspace.Directory != "" {
|
||||
t.Fatalf("workspace directory = %q, want unset", cfg.Workspace.Directory)
|
||||
}
|
||||
if !cfg.DiagnosticsEnabled() {
|
||||
t.Fatalf("expected diagnostics enabled")
|
||||
}
|
||||
if cfg.Diagnostics.WorkDir != "/tmp/legacy" {
|
||||
t.Fatalf("effective diagnostics work dir = %q, want legacy", cfg.Diagnostics.WorkDir)
|
||||
}
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionAlways {
|
||||
t.Fatalf("effective diagnostics retention = %q, want legacy", cfg.Diagnostics.Retention)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFileConfigWorkspaceRetentionOverridesLegacyRetentionOnlyWhenSet(t *testing.T) {
|
||||
t.Run("legacy retained", func(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
workspace:
|
||||
directory: /var/lib/notarius
|
||||
diagnostics:
|
||||
retention: never
|
||||
`)
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionNever {
|
||||
t.Fatalf("effective diagnostics retention = %q, want legacy", cfg.Diagnostics.Retention)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workspace overrides", func(t *testing.T) {
|
||||
cfg := parseAndApplyConfig(t, `
|
||||
version: 2
|
||||
workspace:
|
||||
directory: /var/lib/notarius
|
||||
diagnostics:
|
||||
retention: always
|
||||
diagnostics:
|
||||
retention: never
|
||||
`)
|
||||
if cfg.Diagnostics.Retention != diagnostics.RetentionAlways {
|
||||
t.Fatalf("effective diagnostics retention = %q, want workspace", cfg.Diagnostics.Retention)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func parseAndApplyConfig(t *testing.T, raw string) Config {
|
||||
t.Helper()
|
||||
fileCfg, err := ParseFileConfigYAML([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML: %v", err)
|
||||
}
|
||||
cfg := Default()
|
||||
if err := cfg.applyFileConfigWithLookup(fileCfg, emptyLookup); err != nil {
|
||||
t.Fatalf("ApplyFileConfig: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func emptyLookup(string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func mapLookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) {
|
||||
value, ok := values[key]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestRedactedConfigCopiesScriptoriumConfig(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Scriptorium.ProfileDir = "./profiles"
|
||||
cfg.Workspace.Directory = "/var/lib/notarius"
|
||||
cfg.Workspace.Resume.Enabled = true
|
||||
cfg.Concurrency.StageWorkers["extract"] = 1
|
||||
|
||||
redacted := cfg.Redacted()
|
||||
|
||||
if redacted.Scriptorium.ProfileDir != "./profiles" {
|
||||
t.Fatalf("expected Scriptorium profile source preserved, got %+v", redacted.Scriptorium)
|
||||
}
|
||||
redacted.Scriptorium.ProfileDir = "./changed"
|
||||
if cfg.Scriptorium.ProfileDir != "./profiles" {
|
||||
t.Fatalf("redaction mutated original config")
|
||||
}
|
||||
if redacted.Workspace.Directory != "/var/lib/notarius" || !redacted.Workspace.Resume.Enabled {
|
||||
t.Fatalf("expected workspace config preserved, got %+v", redacted.Workspace)
|
||||
}
|
||||
redacted.Workspace.Directory = "/changed"
|
||||
if cfg.Workspace.Directory != "/var/lib/notarius" {
|
||||
t.Fatalf("redaction mutated original workspace config")
|
||||
}
|
||||
redacted.Concurrency.StageWorkers["extract"] = 9
|
||||
if cfg.Concurrency.StageWorkers["extract"] != 1 {
|
||||
t.Fatalf("redaction aliased stage worker map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRedactedSummaryPayloadCopiesConfig(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Scriptorium.ProfileFile = "./profiles.yml"
|
||||
|
||||
payload, ok := cfg.RedactedSummaryPayload().(Config)
|
||||
if !ok {
|
||||
t.Fatalf("expected Config payload, got %T", cfg.RedactedSummaryPayload())
|
||||
}
|
||||
if payload.Scriptorium.ProfileFile != "./profiles.yml" {
|
||||
t.Fatalf("expected Scriptorium profile file preserved, got %+v", payload.Scriptorium)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigRedactedSummaryPayloadCopies(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Concurrency.TotalLLM = 4
|
||||
cfg.Concurrency.StageWorkers["extract"] = 2
|
||||
lane := cfg.Pipelines["example"].Artifacts["events"]
|
||||
lane.Extract.Options = map[string]any{"temperature": 0.2}
|
||||
lane.References = map[string]string{"roster": "./roster.yml"}
|
||||
lane.Extract.References = map[string]string{"glossary": "./glossary.md"}
|
||||
lane.Normalize.References = map[string]string{"notes": "./normalize.md"}
|
||||
cfg.Pipelines["example"].Artifacts["events"] = lane
|
||||
pipelineProfile := cfg.Pipelines["example"]
|
||||
pipelineProfile.Chunk.References = map[string]string{"scene_guide": "./scene.md"}
|
||||
cfg.Pipelines["example"] = pipelineProfile
|
||||
|
||||
effective, err := cfg.Resolve(ResolveInput{
|
||||
PipelineID: "example",
|
||||
Only: []string{"events"},
|
||||
Catalog: fakeCatalog(t,
|
||||
pipeline.ModuleSpec{
|
||||
Key: "generic",
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: []string{"source"},
|
||||
Provides: []string{"chunks"},
|
||||
ReferenceSlots: []contracts.ReferenceSlot{
|
||||
{Name: "scene_guide"},
|
||||
},
|
||||
},
|
||||
pipeline.ModuleSpec{
|
||||
Key: "fake/extract",
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: []string{"chunks"},
|
||||
Provides: []string{"artifact"},
|
||||
ReferenceSlots: []contracts.ReferenceSlot{
|
||||
{Name: "glossary"},
|
||||
{Name: "roster"},
|
||||
},
|
||||
},
|
||||
pipeline.ModuleSpec{
|
||||
Key: "noop",
|
||||
Stage: pipeline.StageNormalize,
|
||||
Requires: []string{"merged"},
|
||||
Provides: []string{"normalized"},
|
||||
ReferenceSlots: []contracts.ReferenceSlot{
|
||||
{Name: "notes"},
|
||||
},
|
||||
},
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
|
||||
payload, ok := effective.RedactedSummaryPayload().(EffectiveConfig)
|
||||
if !ok {
|
||||
t.Fatalf("expected EffectiveConfig payload, got %T", effective.RedactedSummaryPayload())
|
||||
}
|
||||
if payload.PipelineID != effective.PipelineID || payload.ResolvedPipeline.Digest != effective.ResolvedPipeline.Digest {
|
||||
t.Fatalf("expected pipeline metadata preserved, got %+v", payload)
|
||||
}
|
||||
payload.Config.Concurrency.StageWorkers["extract"] = 4
|
||||
if effective.Config.Concurrency.StageWorkers["extract"] != 2 {
|
||||
t.Fatalf("expected effective stage worker map to be copied")
|
||||
}
|
||||
|
||||
payload.Only[0] = "changed"
|
||||
if effective.Only[0] != "events" {
|
||||
t.Fatalf("expected only lanes to be copied")
|
||||
}
|
||||
payload.ResolvedPipeline.ArtifactLanes[0].Extract.Options["temperature"] = 1.0
|
||||
if effective.ResolvedPipeline.ArtifactLanes[0].Extract.Options["temperature"] != 0.2 {
|
||||
t.Fatalf("expected resolved pipeline options to be copied")
|
||||
}
|
||||
payload.ResolvedPipeline.ArtifactLanes[0].ExtractReferences.Bindings[0].Source = "./changed.yml"
|
||||
if referenceBindingSource(effective.ResolvedPipeline.ArtifactLanes[0].ExtractReferences.Bindings, "roster") != "./roster.yml" {
|
||||
t.Fatalf("expected resolved pipeline references to be copied")
|
||||
}
|
||||
payload.ResolvedPipeline.Chunk.References["scene_guide"] = "./changed-scene.md"
|
||||
if effective.ResolvedPipeline.Chunk.References["scene_guide"] != "./scene.md" {
|
||||
t.Fatalf("expected chunk references to be copied")
|
||||
}
|
||||
payload.ResolvedPipeline.ArtifactLanes[0].Extract.References["glossary"] = "./changed-glossary.md"
|
||||
if effective.ResolvedPipeline.ArtifactLanes[0].Extract.References["glossary"] != "./glossary.md" {
|
||||
t.Fatalf("expected extract references to be copied")
|
||||
}
|
||||
payload.ResolvedPipeline.ArtifactLanes[0].Normalize.References["notes"] = "./changed-normalize.md"
|
||||
if effective.ResolvedPipeline.ArtifactLanes[0].Normalize.References["notes"] != "./normalize.md" {
|
||||
t.Fatalf("expected normalize references to be copied")
|
||||
}
|
||||
|
||||
effective.ResolvedPipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = contracts.ReferenceSet{
|
||||
Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"roster": {
|
||||
Slot: contracts.ReferenceSlot{Name: "roster"},
|
||||
Items: []contracts.ReferenceItem{
|
||||
{
|
||||
SlotName: "roster",
|
||||
Content: []byte("reference content"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, ok = effective.RedactedSummaryPayload().(EffectiveConfig)
|
||||
if !ok {
|
||||
t.Fatalf("expected EffectiveConfig payload, got %T", effective.RedactedSummaryPayload())
|
||||
}
|
||||
payload.ResolvedPipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0].Content[0] = 'X'
|
||||
got := effective.ResolvedPipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0].Content
|
||||
if string(got) != "reference content" {
|
||||
t.Fatalf("expected materialized reference content to be copied, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func referenceBindingSource(bindings []pipeline.ReferenceBinding, slotName string) string {
|
||||
for _, binding := range bindings {
|
||||
if binding.SlotName == slotName {
|
||||
return binding.Source
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/diagnostics"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestValidateSuccessForValidConfig(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAllowsExplicitScriptoriumProfileIDOnBinding(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
lane := cfg.Pipelines["example"].Artifacts["events"]
|
||||
lane.Extract.LLMProfile = "scriptorium-profile"
|
||||
cfg.Pipelines["example"].Artifacts["events"] = lane
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsWhitespaceOnlyExplicitLLMProfile(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
lane := cfg.Pipelines["example"].Artifacts["events"]
|
||||
lane.Extract.LLMProfile = " "
|
||||
cfg.Pipelines["example"].Artifacts["events"] = lane
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "llm_profile") || !strings.Contains(err.Error(), "events") {
|
||||
t.Fatalf("expected llm_profile error with lane context, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidNumericFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(Config) Config
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "total concurrency",
|
||||
mutate: func(cfg Config) Config {
|
||||
cfg.Concurrency.TotalLLM = 0
|
||||
return cfg
|
||||
},
|
||||
want: "total LLM concurrency",
|
||||
},
|
||||
{
|
||||
name: "negative retries",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.Merge.Retries = -1
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: "retries",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mutate(validConfig()).Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStageWorkerBoundaries(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
workers int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "below minimum", workers: 0, wantErr: true},
|
||||
{name: "minimum", workers: 1},
|
||||
{name: "maximum", workers: 4},
|
||||
{name: "above maximum", workers: 5, wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Concurrency.TotalLLM = 4
|
||||
cfg.Concurrency.StageWorkers["extract"] = test.workers
|
||||
err := cfg.Validate()
|
||||
if test.wantErr && (err == nil || !strings.Contains(err.Error(), "stage_workers.extract")) {
|
||||
t.Fatalf("Validate() error = %v, want extract worker range error", err)
|
||||
}
|
||||
if !test.wantErr && err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownEffectiveStageWorkerKey(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Concurrency.StageWorkers["merge"] = 1
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "stage_workers key") || !strings.Contains(err.Error(), "merge") {
|
||||
t.Fatalf("Validate() error = %v, want unknown stage worker key", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMutuallyExclusiveScriptoriumProfileSources(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Scriptorium.ProfileDir = "./profiles"
|
||||
cfg.Scriptorium.ProfileFile = "./profiles.yml"
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("expected Scriptorium source conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidDiagnosticsRetention(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Diagnostics.Retention = diagnostics.RetentionMode("sometimes")
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "retention") {
|
||||
t.Fatalf("expected retention error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidWorkspaceDiagnosticsRetention(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
cfg.Workspace.Diagnostics.Retention = diagnostics.RetentionMode("sometimes")
|
||||
cfg.Workspace.Diagnostics.retentionSet = true
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), "workspace diagnostics retention") {
|
||||
t.Fatalf("expected workspace retention error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidReferenceMaps(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(Config) Config
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "empty chunk slot",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
profile.Chunk.References = map[string]string{" ": "./roster.yml"}
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "chunk", "reference slot", "empty"},
|
||||
},
|
||||
{
|
||||
name: "empty chunk source",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
profile.Chunk.References = map[string]string{"roster": " "}
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "chunk", "roster", "source", "empty"},
|
||||
},
|
||||
{
|
||||
name: "empty extract slot",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.Extract.References = map[string]string{" ": "./roster.yml"}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "events", "extract", "reference slot", "empty"},
|
||||
},
|
||||
{
|
||||
name: "empty extract source",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.Extract.References = map[string]string{"roster": " "}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "events", "extract", "roster", "source", "empty"},
|
||||
},
|
||||
{
|
||||
name: "empty normalize slot",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.Normalize.References = map[string]string{" ": "./roster.yml"}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "events", "normalize", "reference slot", "empty"},
|
||||
},
|
||||
{
|
||||
name: "empty normalize source",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.Normalize.References = map[string]string{"roster": " "}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "events", "normalize", "roster", "source", "empty"},
|
||||
},
|
||||
{
|
||||
name: "empty pipeline slot",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
profile.References = map[string]string{" ": "./roster.yml"}
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "reference slot", "empty"},
|
||||
},
|
||||
{
|
||||
name: "empty pipeline source",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
profile.References = map[string]string{"roster": " "}
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "roster", "source", "empty"},
|
||||
},
|
||||
{
|
||||
name: "empty lane slot",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.References = map[string]string{" ": "./roster.yml"}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "events", "reference slot", "empty"},
|
||||
},
|
||||
{
|
||||
name: "empty lane source",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.References = map[string]string{"roster": " "}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "events", "roster", "source", "empty"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mutate(validConfig()).Validate()
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want error")
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsReferencesOnUnsupportedBindings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(Config) Config
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "input",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
profile.Input.References = map[string]string{"roster": "./roster.yml"}
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "input", "references", "not supported"},
|
||||
},
|
||||
{
|
||||
name: "output",
|
||||
mutate: func(cfg Config) Config {
|
||||
profile := cfg.Pipelines["example"]
|
||||
profile.Output.References = map[string]string{"roster": "./roster.yml"}
|
||||
cfg.Pipelines["example"] = profile
|
||||
return cfg
|
||||
},
|
||||
want: []string{"example", "output", "references", "not supported"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mutate(validConfig()).Validate()
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want error")
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsEmptyIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(Config) Config
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "pipeline",
|
||||
mutate: func(cfg Config) Config {
|
||||
cfg.Pipelines[" "] = pipeline.PipelineProfile{}
|
||||
return cfg
|
||||
},
|
||||
want: "pipeline id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mutate(validConfig()).Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsIDsDuplicatedAfterTrimming(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(Config) Config
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "pipeline",
|
||||
mutate: func(cfg Config) Config {
|
||||
cfg.Pipelines[" example "] = cfg.Pipelines["example"]
|
||||
return cfg
|
||||
},
|
||||
want: "duplicated",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.mutate(validConfig()).Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsConfiguredValidators(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
profile := cfg.Pipelines["example"]
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.Validators = []pipeline.ModuleBinding{pipeline.Binding("fake/validator")}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil {
|
||||
t.Fatal("Validate() error = nil, want configured validators error")
|
||||
}
|
||||
for _, want := range []string{"example", "events", "validators", "extract.validators", "merge.validators", "normalize.validators"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsStageLocalValidatorOverrides(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
profile := cfg.Pipelines["example"]
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding("fake/validator"),
|
||||
{Module: "fake/llm-validator", LLMProfile: "careful", Options: map[string]any{"threshold": 0.7}},
|
||||
},
|
||||
}
|
||||
lane.Merge.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidValidatorBindings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
binding pipeline.ModuleBinding
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty module",
|
||||
binding: pipeline.ModuleBinding{},
|
||||
want: "module must not be empty",
|
||||
},
|
||||
{
|
||||
name: "references",
|
||||
binding: pipeline.ModuleBinding{Module: "fake/validator", References: map[string]string{"roster": "./roster.txt"}},
|
||||
want: "references are not supported",
|
||||
},
|
||||
{
|
||||
name: "nested validators",
|
||||
binding: pipeline.ModuleBinding{Module: "fake/validator", Validators: pipeline.ValidatorOverride{Set: true}},
|
||||
want: "nested validators are not supported",
|
||||
},
|
||||
{
|
||||
name: "retries",
|
||||
binding: pipeline.ModuleBinding{Module: "fake/validator", Retries: 1},
|
||||
want: "retries are not supported",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := validConfig()
|
||||
profile := cfg.Pipelines["example"]
|
||||
lane := profile.Artifacts["events"]
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{test.binding},
|
||||
}
|
||||
profile.Artifacts["events"] = lane
|
||||
cfg.Pipelines["example"] = profile
|
||||
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validConfig() Config {
|
||||
cfg := Default()
|
||||
cfg.Pipelines["example"] = pipeline.PipelineProfile{
|
||||
Input: pipeline.Binding("fake/input"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"events": {
|
||||
Extract: pipeline.Binding("fake/extract"),
|
||||
},
|
||||
"notes": {
|
||||
Extract: pipeline.Binding("fake/extract"),
|
||||
},
|
||||
},
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.ModuleCatalog {
|
||||
t.Helper()
|
||||
specs := map[string]pipeline.ModuleSpec{
|
||||
"fake/input": {
|
||||
Key: "fake/input",
|
||||
Stage: pipeline.StageInput,
|
||||
Provides: []string{"source"},
|
||||
},
|
||||
"generic": {
|
||||
Key: "generic",
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: []string{"source"},
|
||||
Provides: []string{"chunks"},
|
||||
},
|
||||
"fake/extract": {
|
||||
Key: "fake/extract",
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: []string{"chunks"},
|
||||
Provides: []string{"artifact"},
|
||||
},
|
||||
"appendorder": {
|
||||
Key: "appendorder",
|
||||
Stage: pipeline.StageMerge,
|
||||
Requires: []string{"artifact"},
|
||||
Provides: []string{"merged"},
|
||||
},
|
||||
"noop": {
|
||||
Key: "noop",
|
||||
Stage: pipeline.StageNormalize,
|
||||
Requires: []string{"merged"},
|
||||
Provides: []string{"normalized"},
|
||||
},
|
||||
"fake/validator": {
|
||||
Key: "fake/validator",
|
||||
Stage: pipeline.StageValidate,
|
||||
Requires: []string{"normalized"},
|
||||
Provides: []string{"validated"},
|
||||
},
|
||||
"fake/llm-validator": {
|
||||
Key: "fake/llm-validator",
|
||||
Stage: pipeline.StageValidate,
|
||||
Requires: []string{"normalized"},
|
||||
Provides: []string{"validated"},
|
||||
},
|
||||
"json": {
|
||||
Key: "json",
|
||||
Stage: pipeline.StageOutput,
|
||||
Requires: []string{"normalized"},
|
||||
},
|
||||
}
|
||||
for _, override := range overrides {
|
||||
specs[override.Key] = override
|
||||
}
|
||||
for _, key := range []string{"fake/extract", "appendorder", "noop"} {
|
||||
spec := specs[key]
|
||||
spec.ArtifactKind = fakeArtifactKind
|
||||
specs[key] = spec
|
||||
}
|
||||
|
||||
inputs := pipeline.NewInputAdapterRegistry()
|
||||
chunkers := pipeline.NewChunkerRegistry()
|
||||
extractors := pipeline.NewExtractorRegistry()
|
||||
mergers := pipeline.NewMergerRegistry()
|
||||
normalizers := pipeline.NewNormalizerRegistry()
|
||||
validators := pipeline.NewValidatorRegistry()
|
||||
outputs := pipeline.NewOutputEncoderRegistry()
|
||||
|
||||
mustRegisterInput(t, inputs, specs["fake/input"])
|
||||
mustRegisterChunker(t, chunkers, specs["generic"])
|
||||
mustRegisterExtractor(t, extractors, specs["fake/extract"])
|
||||
mustRegisterMerger(t, mergers, specs["appendorder"])
|
||||
mustRegisterNormalizer(t, normalizers, specs["noop"])
|
||||
mustRegisterValidator(t, validators, specs["fake/validator"])
|
||||
mustRegisterValidator(t, validators, specs["fake/llm-validator"])
|
||||
mustRegisterOutput(t, outputs, specs["json"])
|
||||
|
||||
codecs := pipeline.NewArtifactCodecRegistry()
|
||||
if err := pipeline.RegisterArtifactCodec(codecs, fakeArtifactCodec{}); err != nil {
|
||||
t.Fatalf("register artifact codec: %v", err)
|
||||
}
|
||||
|
||||
return pipeline.ModuleCatalog{
|
||||
Inputs: inputs,
|
||||
Chunkers: chunkers,
|
||||
ArtifactCodecs: codecs,
|
||||
Extractors: extractors,
|
||||
Mergers: mergers,
|
||||
Normalizers: normalizers,
|
||||
Validators: validators,
|
||||
ValidatorChains: pipeline.NewValidatorChainRegistry(),
|
||||
Outputs: outputs,
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register input: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register chunker: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
validateOptions := func(options map[string]any) error {
|
||||
if err := pipeline.RejectUnknownOptions(options, "temperature"); err != nil {
|
||||
return err
|
||||
}
|
||||
if value, ok := options["temperature"]; ok {
|
||||
if _, ok := value.(float64); !ok {
|
||||
return fmt.Errorf("temperature must be a number")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := pipeline.RegisterExtractorBuilder[fakeArtifact](registry, spec, validateOptions, func(pipeline.BuildRequest) (contracts.Extractor[fakeArtifact], error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register extractor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := pipeline.RegisterMerger[fakeArtifact](registry, spec, func() (contracts.Merger[fakeArtifact], error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register merger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := pipeline.RegisterNormalizer[fakeArtifact](registry, spec, func() (contracts.Normalizer[fakeArtifact], error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register normalizer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRegisterValidator(t *testing.T, registry *pipeline.ValidatorRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
executionClass := contracts.ExecutionClassDeterministic
|
||||
if spec.Key == "fake/llm-validator" {
|
||||
executionClass = contracts.ExecutionClassLLMBacked
|
||||
}
|
||||
validatorSpec := pipeline.ValidatorSpec{Key: spec.Key, ExecutionClass: executionClass}
|
||||
if err := pipeline.RegisterTypedValidator[fakeArtifact](registry, fakeArtifactKind, validatorSpec, func() (contracts.TypedValidator[fakeArtifact], error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register validator: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const fakeArtifactKind contracts.ArtifactKind = "test/artifact"
|
||||
|
||||
type fakeArtifact string
|
||||
|
||||
type fakeArtifactCodec struct{}
|
||||
|
||||
func (fakeArtifactCodec) Kind() contracts.ArtifactKind { return fakeArtifactKind }
|
||||
func (fakeArtifactCodec) Schema() contracts.ArtifactSchema {
|
||||
return contracts.ArtifactSchema{ID: "urn:notarius:test:artifact", Name: "Test artifact", Version: "1", JSONSchema: []byte(`{"type":"string"}`)}
|
||||
}
|
||||
func (fakeArtifactCodec) MediaType() string { return "application/json" }
|
||||
func (fakeArtifactCodec) EncodeCandidate(value fakeArtifact) ([]byte, error) {
|
||||
return []byte(fmt.Sprintf("%q", value)), nil
|
||||
}
|
||||
func (fakeArtifactCodec) Encode(value fakeArtifact) ([]byte, error) {
|
||||
return []byte(fmt.Sprintf("%q", value)), nil
|
||||
}
|
||||
func (fakeArtifactCodec) Decode(content []byte) (fakeArtifact, error) {
|
||||
if len(content) < 2 {
|
||||
return "", fmt.Errorf("invalid test artifact")
|
||||
}
|
||||
return fakeArtifact(content[1 : len(content)-1]), nil
|
||||
}
|
||||
|
||||
func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) {
|
||||
t.Helper()
|
||||
if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil {
|
||||
t.Fatalf("register output: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package diagnostics
|
||||
|
||||
const (
|
||||
ArtifactInvocationMetadata = "invocation.json"
|
||||
ArtifactEffectiveConfig = "effective-config.json"
|
||||
ArtifactResolvedPipeline = "resolved-pipeline.json"
|
||||
ArtifactResolvedReferences = "resolved-references.json"
|
||||
ArtifactCheckpointEvents = "checkpoint-events.json"
|
||||
ArtifactSourceDocument = "source-document.json"
|
||||
ArtifactRunManifest = "run-manifest.json"
|
||||
ArtifactChunkPlan = "chunk-plan.json"
|
||||
ArtifactRunReport = "run-report.json"
|
||||
ArtifactWarnings = "warnings.json"
|
||||
ArtifactErrorLog = "error.log"
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
package diagnostics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestArtifactNamesUseExtractionOrientedNames(t *testing.T) {
|
||||
names := []string{
|
||||
ArtifactInvocationMetadata,
|
||||
ArtifactEffectiveConfig,
|
||||
ArtifactResolvedPipeline,
|
||||
ArtifactResolvedReferences,
|
||||
ArtifactSourceDocument,
|
||||
ArtifactRunManifest,
|
||||
ArtifactChunkPlan,
|
||||
ArtifactRunReport,
|
||||
ArtifactWarnings,
|
||||
ArtifactErrorLog,
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if name == "" {
|
||||
t.Fatalf("artifact name must not be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWorkDir = "/tmp/notarius"
|
||||
maxRunDirectoryCreateAttempts = 16
|
||||
)
|
||||
|
||||
var utcNow = func() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
// RunDirectory represents a per-run diagnostics directory.
|
||||
type RunDirectory struct {
|
||||
path string
|
||||
retention RetentionMode
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
type RetentionMode string
|
||||
|
||||
const (
|
||||
RetentionAuto RetentionMode = "auto"
|
||||
RetentionAlways RetentionMode = "always"
|
||||
RetentionNever RetentionMode = "never"
|
||||
)
|
||||
|
||||
type RetentionDecisionInput struct {
|
||||
RetentionMode RetentionMode
|
||||
RunSucceeded bool
|
||||
HasWarnings bool
|
||||
}
|
||||
|
||||
type RedactedEffectiveConfigPayload interface {
|
||||
RedactedSummaryPayload() any
|
||||
}
|
||||
|
||||
// InvocationMetadata captures non-secret invocation details for diagnostics.
|
||||
type InvocationMetadata struct {
|
||||
Operation string `json:"operation"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
PipelineDigest string `json:"pipeline_digest,omitempty"`
|
||||
Resume bool `json:"resume,omitempty"`
|
||||
InputPath string `json:"input_path,omitempty"`
|
||||
ConfigPath string `json:"config_path,omitempty"`
|
||||
ConfigSource string `json:"config_source,omitempty"`
|
||||
OnlyLanes []string `json:"only_lanes,omitempty"`
|
||||
ChunkCacheOverride string `json:"chunk_cache_override,omitempty"`
|
||||
RunID string `json:"run_id"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
func ShouldRetainRunDirectory(input RetentionDecisionInput) bool {
|
||||
if !input.RunSucceeded {
|
||||
return true
|
||||
}
|
||||
|
||||
switch input.RetentionMode {
|
||||
case RetentionAlways:
|
||||
return true
|
||||
case RetentionNever:
|
||||
return false
|
||||
case RetentionAuto, "":
|
||||
return input.HasWarnings
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func NewRunDirectory(workDir string, retention RetentionMode) (*RunDirectory, error) {
|
||||
if strings.TrimSpace(workDir) == "" {
|
||||
workDir = defaultWorkDir
|
||||
}
|
||||
if retention == "" {
|
||||
retention = RetentionAuto
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create diagnostics work directory %q: %w", workDir, err)
|
||||
}
|
||||
|
||||
var lastRunPath string
|
||||
for attempt := 0; attempt < maxRunDirectoryCreateAttempts; attempt++ {
|
||||
createdAt := utcNow()
|
||||
runID := fmt.Sprintf("run-%d", createdAt.UnixNano())
|
||||
runPath := filepath.Join(workDir, runID)
|
||||
lastRunPath = runPath
|
||||
if err := os.Mkdir(runPath, 0o755); err != nil {
|
||||
if os.IsExist(err) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("create diagnostics run directory %q: %w", runPath, err)
|
||||
}
|
||||
|
||||
return &RunDirectory{
|
||||
path: runPath,
|
||||
retention: retention,
|
||||
createdAt: createdAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("create diagnostics run directory %q: exhausted unique run ID attempts", lastRunPath)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) Path() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.path
|
||||
}
|
||||
|
||||
func (r *RunDirectory) RunID() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(r.path)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteInvocationMetadata(metadata InvocationMetadata) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("run directory must not be nil")
|
||||
}
|
||||
if metadata.RunID == "" {
|
||||
metadata.RunID = r.RunID()
|
||||
}
|
||||
if metadata.StartedAt.IsZero() {
|
||||
metadata.StartedAt = r.createdAt
|
||||
}
|
||||
return r.WriteJSONArtifact(ArtifactInvocationMetadata, metadata)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteRedactedEffectiveConfig(payload RedactedEffectiveConfigPayload) error {
|
||||
if payload == nil {
|
||||
return fmt.Errorf("redacted effective config payload must not be nil")
|
||||
}
|
||||
return r.WriteJSONArtifact(ArtifactEffectiveConfig, payload.RedactedSummaryPayload())
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteResolvedPipeline(payload any) error {
|
||||
return r.WriteJSONArtifact(ArtifactResolvedPipeline, payload)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteResolvedReferences(payload any) error {
|
||||
return r.WriteJSONArtifact(ArtifactResolvedReferences, payload)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteCheckpointEvents(payload any) error {
|
||||
return r.WriteJSONArtifact(ArtifactCheckpointEvents, payload)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteSourceDocument(payload any) error {
|
||||
return r.WriteJSONArtifact(ArtifactSourceDocument, payload)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteRunManifest(manifest artifacts.RunManifest) error {
|
||||
return r.WriteJSONArtifact(ArtifactRunManifest, manifest)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteChunkPlan(summary artifacts.ChunkPlanSummary) error {
|
||||
return r.WriteJSONArtifact(ArtifactChunkPlan, summary)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteRunReport(payload any) error {
|
||||
return r.WriteJSONArtifact(ArtifactRunReport, payload)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteWarnings(warnings []contracts.Warning) error {
|
||||
return r.WriteJSONArtifact(ArtifactWarnings, warnings)
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteErrorLog(errorMessage string) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("run directory must not be nil")
|
||||
}
|
||||
path, err := r.artifactPath(ArtifactErrorLog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(path, []byte(errorMessage+"\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write diagnostics artifact %q: %w", ArtifactErrorLog, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RunDirectory) WriteJSONArtifact(name string, payload any) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("run directory must not be nil")
|
||||
}
|
||||
path, err := r.artifactPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal diagnostics artifact %q: %w", name, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := writeFileAtomic(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write diagnostics artifact %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RunDirectory) ApplyRetention(input RetentionDecisionInput) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("run directory must not be nil")
|
||||
}
|
||||
decision := input
|
||||
if decision.RetentionMode == "" {
|
||||
decision.RetentionMode = r.retention
|
||||
}
|
||||
if ShouldRetainRunDirectory(decision) {
|
||||
return nil
|
||||
}
|
||||
if err := os.RemoveAll(r.path); err != nil {
|
||||
return fmt.Errorf("remove diagnostics run directory %q: %w", r.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RunDirectory) artifactPath(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("diagnostics artifact name must not be empty")
|
||||
}
|
||||
if filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("diagnostics artifact name %q must not be absolute", name)
|
||||
}
|
||||
if name != filepath.Base(name) || strings.Contains(name, "/") || strings.Contains(name, `\`) {
|
||||
return "", fmt.Errorf("diagnostics artifact name %q must not contain path separators", name)
|
||||
}
|
||||
|
||||
runPath, err := filepath.Abs(r.path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve diagnostics run directory %q: %w", r.path, err)
|
||||
}
|
||||
artifactPath, err := filepath.Abs(filepath.Join(runPath, name))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve diagnostics artifact %q: %w", name, err)
|
||||
}
|
||||
if filepath.Dir(artifactPath) != runPath {
|
||||
return "", fmt.Errorf("diagnostics artifact name %q resolves outside run directory", name)
|
||||
}
|
||||
return artifactPath, nil
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
removeTemp := true
|
||||
defer func() {
|
||||
if removeTemp {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, path); err != nil {
|
||||
return err
|
||||
}
|
||||
removeTemp = false
|
||||
return nil
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
package diagnostics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestNewRunDirectoryCreatesRunDirectoryAndRunID(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
runDir, err := NewRunDirectory(workDir, RetentionAuto)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
|
||||
if filepath.Dir(runDir.Path()) != workDir {
|
||||
t.Fatalf("unexpected run directory parent: %q", runDir.Path())
|
||||
}
|
||||
if ok := regexp.MustCompile(`^run-\d+$`).MatchString(runDir.RunID()); !ok {
|
||||
t.Fatalf("unexpected run ID: %q", runDir.RunID())
|
||||
}
|
||||
info, err := os.Stat(runDir.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("stat run directory: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("expected run path to be a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunDirectoryRetriesOnRunIDCollision(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
first := time.Unix(0, 100).UTC()
|
||||
second := first.Add(time.Nanosecond)
|
||||
if err := os.Mkdir(filepath.Join(workDir, fmt.Sprintf("run-%d", first.UnixNano())), 0o755); err != nil {
|
||||
t.Fatalf("create existing run directory: %v", err)
|
||||
}
|
||||
restoreUTCNow := replaceUTCNow(func() func() time.Time {
|
||||
calls := 0
|
||||
return func() time.Time {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return first
|
||||
}
|
||||
return second
|
||||
}
|
||||
}())
|
||||
t.Cleanup(restoreUTCNow)
|
||||
|
||||
runDir, err := NewRunDirectory(workDir, RetentionAuto)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
|
||||
wantRunID := fmt.Sprintf("run-%d", second.UnixNano())
|
||||
if runDir.RunID() != wantRunID {
|
||||
t.Fatalf("RunID = %q, want %q", runDir.RunID(), wantRunID)
|
||||
}
|
||||
if _, err := os.Stat(runDir.Path()); err != nil {
|
||||
t.Fatalf("stat run directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunDirectoryReturnsErrorAfterRunIDCollisionsExhausted(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
collisionTime := time.Unix(0, 200).UTC()
|
||||
collisionPath := filepath.Join(workDir, fmt.Sprintf("run-%d", collisionTime.UnixNano()))
|
||||
if err := os.Mkdir(collisionPath, 0o755); err != nil {
|
||||
t.Fatalf("create existing run directory: %v", err)
|
||||
}
|
||||
restoreUTCNow := replaceUTCNow(func() time.Time {
|
||||
return collisionTime
|
||||
})
|
||||
t.Cleanup(restoreUTCNow)
|
||||
|
||||
_, err := NewRunDirectory(workDir, RetentionAuto)
|
||||
if err == nil || !strings.Contains(err.Error(), "exhausted unique run ID attempts") {
|
||||
t.Fatalf("expected exhausted collision error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRunDirectoryUsesDefaultWorkDirectory(t *testing.T) {
|
||||
runDir, err := NewRunDirectory("", RetentionAuto)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(runDir.Path())
|
||||
_ = os.Remove(defaultWorkDir)
|
||||
})
|
||||
|
||||
if filepath.Dir(runDir.Path()) != defaultWorkDir {
|
||||
t.Fatalf("expected default work directory %q, got %q", defaultWorkDir, filepath.Dir(runDir.Path()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONArtifactWritesIndentedNewlineTerminatedJSON(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteJSONArtifact("artifact.json", map[string]any{"value": "ok"}); err != nil {
|
||||
t.Fatalf("WriteJSONArtifact: %v", err)
|
||||
}
|
||||
|
||||
data := readArtifact(t, runDir, "artifact.json")
|
||||
if !strings.HasSuffix(string(data), "\n") {
|
||||
t.Fatalf("expected trailing newline, got %q", data)
|
||||
}
|
||||
if !strings.Contains(string(data), "\n \"value\": \"ok\"\n") {
|
||||
t.Fatalf("expected indented JSON, got %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONArtifactLeavesNoTemporaryFiles(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteJSONArtifact("artifact.json", map[string]any{"value": "ok"}); err != nil {
|
||||
t.Fatalf("WriteJSONArtifact: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(runDir.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("read run directory: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".tmp-") {
|
||||
t.Fatalf("temporary diagnostics file remains after success: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteInvocationMetadataFillsMissingRunIDAndStartTime(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteInvocationMetadata(InvocationMetadata{Operation: "validate"}); err != nil {
|
||||
t.Fatalf("WriteInvocationMetadata: %v", err)
|
||||
}
|
||||
|
||||
var got InvocationMetadata
|
||||
if err := json.Unmarshal(readArtifact(t, runDir, ArtifactInvocationMetadata), &got); err != nil {
|
||||
t.Fatalf("unmarshal invocation metadata: %v", err)
|
||||
}
|
||||
if got.RunID != runDir.RunID() {
|
||||
t.Fatalf("unexpected run ID: got %q want %q", got.RunID, runDir.RunID())
|
||||
}
|
||||
if got.StartedAt.IsZero() {
|
||||
t.Fatalf("expected started_at to be filled")
|
||||
}
|
||||
if got.Operation != "validate" {
|
||||
t.Fatalf("unexpected operation: %q", got.Operation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteInvocationMetadataPreservesProvidedRunIDAndStartTime(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
startedAt := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if err := runDir.WriteInvocationMetadata(InvocationMetadata{
|
||||
Operation: "validate",
|
||||
RunID: "provided",
|
||||
StartedAt: startedAt,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteInvocationMetadata: %v", err)
|
||||
}
|
||||
|
||||
var got InvocationMetadata
|
||||
if err := json.Unmarshal(readArtifact(t, runDir, ArtifactInvocationMetadata), &got); err != nil {
|
||||
t.Fatalf("unmarshal invocation metadata: %v", err)
|
||||
}
|
||||
if got.RunID != "provided" {
|
||||
t.Fatalf("unexpected run ID: %q", got.RunID)
|
||||
}
|
||||
if !got.StartedAt.Equal(startedAt) {
|
||||
t.Fatalf("unexpected started_at: %s", got.StartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteTypedArtifacts(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteRedactedEffectiveConfig(fakeRedactedEffectiveConfig{payload: map[string]any{"redacted": true}}); err != nil {
|
||||
t.Fatalf("WriteRedactedEffectiveConfig: %v", err)
|
||||
}
|
||||
if err := runDir.WriteResolvedPipeline(map[string]any{"pipeline": "test"}); err != nil {
|
||||
t.Fatalf("WriteResolvedPipeline: %v", err)
|
||||
}
|
||||
if err := runDir.WriteResolvedReferences([]artifacts.ReferenceProvenance{{LaneID: "events", SlotName: "roster"}}); err != nil {
|
||||
t.Fatalf("WriteResolvedReferences: %v", err)
|
||||
}
|
||||
if err := runDir.WriteSourceDocument(map[string]any{"source_id": "source-1"}); err != nil {
|
||||
t.Fatalf("WriteSourceDocument: %v", err)
|
||||
}
|
||||
if err := runDir.WriteRunManifest(artifacts.RunManifest{RunID: "run-1"}); err != nil {
|
||||
t.Fatalf("WriteRunManifest: %v", err)
|
||||
}
|
||||
if err := runDir.WriteChunkPlan(artifacts.ChunkPlanSummary{Mode: "auto", RequestedModule: "chunk/test", LookupStatus: "invalid", LookupReason: "stored chunk plan failed validation", ValidationStatus: "not_run", PublicationStatus: "not_published"}); err != nil {
|
||||
t.Fatalf("WriteChunkPlan: %v", err)
|
||||
}
|
||||
if err := runDir.WriteRunReport(map[string]any{"ok": true}); err != nil {
|
||||
t.Fatalf("WriteRunReport: %v", err)
|
||||
}
|
||||
if err := runDir.WriteWarnings([]contracts.Warning{{ReasonCode: "test", Message: "warning"}}); err != nil {
|
||||
t.Fatalf("WriteWarnings: %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
ArtifactEffectiveConfig,
|
||||
ArtifactResolvedPipeline,
|
||||
ArtifactResolvedReferences,
|
||||
ArtifactSourceDocument,
|
||||
ArtifactRunManifest,
|
||||
ArtifactChunkPlan,
|
||||
ArtifactRunReport,
|
||||
ArtifactWarnings,
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(runDir.Path(), name)); err != nil {
|
||||
t.Fatalf("expected artifact %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
chunkSummary, err := os.ReadFile(filepath.Join(runDir.Path(), ArtifactChunkPlan))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"units", "annotations", "source content", "prompt", "response", "raw invalid"} {
|
||||
if strings.Contains(string(chunkSummary), forbidden) {
|
||||
t.Fatalf("chunk plan summary leaked %q: %s", forbidden, chunkSummary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRedactedEffectiveConfigWritesPayloadReturnedByProvider(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteRedactedEffectiveConfig(fakeRedactedEffectiveConfig{
|
||||
payload: map[string]any{
|
||||
"api_key": "[REDACTED]",
|
||||
"model": "test-model",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteRedactedEffectiveConfig: %v", err)
|
||||
}
|
||||
|
||||
data := string(readArtifact(t, runDir, ArtifactEffectiveConfig))
|
||||
if !strings.Contains(data, `"api_key": "[REDACTED]"`) || !strings.Contains(data, `"model": "test-model"`) {
|
||||
t.Fatalf("unexpected effective config artifact: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorLogWritesPlainTextWithTrailingNewline(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.WriteErrorLog("something failed"); err != nil {
|
||||
t.Fatalf("WriteErrorLog: %v", err)
|
||||
}
|
||||
|
||||
if got := string(readArtifact(t, runDir, ArtifactErrorLog)); got != "something failed\n" {
|
||||
t.Fatalf("unexpected error log: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactPathRejectsUnsafeNames(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
tests := []string{
|
||||
"",
|
||||
" ",
|
||||
"/absolute.json",
|
||||
"nested/artifact.json",
|
||||
`nested\artifact.json`,
|
||||
"../escape.json",
|
||||
}
|
||||
|
||||
for _, name := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := runDir.WriteJSONArtifact(name, map[string]any{}); err == nil {
|
||||
t.Fatalf("expected unsafe artifact name %q to be rejected", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetainRunDirectoryDecisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input RetentionDecisionInput
|
||||
want bool
|
||||
}{
|
||||
{name: "failed auto retained", input: RetentionDecisionInput{RetentionMode: RetentionAuto, RunSucceeded: false}, want: true},
|
||||
{name: "failed always retained", input: RetentionDecisionInput{RetentionMode: RetentionAlways, RunSucceeded: false}, want: true},
|
||||
{name: "failed never retained", input: RetentionDecisionInput{RetentionMode: RetentionNever, RunSucceeded: false}, want: true},
|
||||
{name: "successful always retained", input: RetentionDecisionInput{RetentionMode: RetentionAlways, RunSucceeded: true}, want: true},
|
||||
{name: "successful never removed", input: RetentionDecisionInput{RetentionMode: RetentionNever, RunSucceeded: true}, want: false},
|
||||
{name: "successful auto without warnings removed", input: RetentionDecisionInput{RetentionMode: RetentionAuto, RunSucceeded: true}, want: false},
|
||||
{name: "successful auto with warnings retained", input: RetentionDecisionInput{RetentionMode: RetentionAuto, RunSucceeded: true, HasWarnings: true}, want: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ShouldRetainRunDirectory(tc.input); got != tc.want {
|
||||
t.Fatalf("ShouldRetainRunDirectory() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionRemovesOnlyRunDirectory(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
runDir, err := NewRunDirectory(workDir, RetentionNever)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
siblingPath := filepath.Join(workDir, "sibling")
|
||||
if err := os.WriteFile(siblingPath, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatalf("write sibling: %v", err)
|
||||
}
|
||||
|
||||
if err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true}); err != nil {
|
||||
t.Fatalf("ApplyRetention: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(runDir.Path()); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected run directory removed, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(workDir); err != nil {
|
||||
t.Fatalf("expected work directory retained: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(siblingPath); err != nil {
|
||||
t.Fatalf("expected sibling retained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRetentionKeepsRetainedRunDirectory(t *testing.T) {
|
||||
runDir := newTestRunDirectory(t)
|
||||
|
||||
if err := runDir.ApplyRetention(RetentionDecisionInput{RunSucceeded: true, HasWarnings: true}); err != nil {
|
||||
t.Fatalf("ApplyRetention: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(runDir.Path()); err != nil {
|
||||
t.Fatalf("expected run directory retained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRunDirectory(t *testing.T) *RunDirectory {
|
||||
t.Helper()
|
||||
runDir, err := NewRunDirectory(t.TempDir(), RetentionAuto)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRunDirectory: %v", err)
|
||||
}
|
||||
return runDir
|
||||
}
|
||||
|
||||
func replaceUTCNow(replacement func() time.Time) func() {
|
||||
original := utcNow
|
||||
utcNow = replacement
|
||||
return func() {
|
||||
utcNow = original
|
||||
}
|
||||
}
|
||||
|
||||
func readArtifact(t *testing.T, runDir *RunDirectory, name string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(runDir.Path(), name))
|
||||
if err != nil {
|
||||
t.Fatalf("read artifact %q: %v", name, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
type fakeRedactedEffectiveConfig struct {
|
||||
payload any
|
||||
}
|
||||
|
||||
func (f fakeRedactedEffectiveConfig) RedactedSummaryPayload() any {
|
||||
return f.payload
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func SafePath(root string, name string) (string, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("workspace root must not be empty")
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("workspace artifact name must not be empty")
|
||||
}
|
||||
if strings.Contains(name, `\`) {
|
||||
return "", fmt.Errorf("workspace artifact name %q must use slash-separated relative paths", name)
|
||||
}
|
||||
if path.IsAbs(name) || filepath.IsAbs(name) {
|
||||
return "", fmt.Errorf("workspace artifact name %q must be relative", name)
|
||||
}
|
||||
if name == "." || strings.Contains(name, "..") {
|
||||
return "", fmt.Errorf("workspace artifact name %q must not contain ..", name)
|
||||
}
|
||||
cleaned := path.Clean(name)
|
||||
if cleaned != name {
|
||||
return "", fmt.Errorf("workspace artifact name %q must be clean", name)
|
||||
}
|
||||
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve workspace root %q: %w", root, err)
|
||||
}
|
||||
target, err := filepath.Abs(filepath.Join(absRoot, filepath.FromSlash(cleaned)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve workspace artifact %q: %w", name, err)
|
||||
}
|
||||
rel, err := filepath.Rel(absRoot, target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve workspace artifact %q: %w", name, err)
|
||||
}
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("workspace artifact name %q resolves outside workspace root", name)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func WriteJSON(root string, name string, payload any) error {
|
||||
target, err := SafePath(root, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal workspace artifact %q: %w", name, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := writeFileAtomic(target, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write workspace artifact %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func WriteBytes(root string, name string, data []byte) error {
|
||||
target, err := SafePath(root, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(target, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write workspace artifact %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeFileAtomic(target string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(target)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
temp, err := os.CreateTemp(dir, "."+filepath.Base(target)+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tempPath := temp.Name()
|
||||
removeTemp := true
|
||||
defer func() {
|
||||
if removeTemp {
|
||||
_ = os.Remove(tempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := temp.Write(data); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Chmod(perm); err != nil {
|
||||
_ = temp.Close()
|
||||
return err
|
||||
}
|
||||
if err := temp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tempPath, target); err != nil {
|
||||
return err
|
||||
}
|
||||
removeTemp = false
|
||||
return nil
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafePathAcceptsCleanRelativePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
got, err := SafePath(root, "source/manifest.json")
|
||||
if err != nil {
|
||||
t.Fatalf("SafePath: %v", err)
|
||||
}
|
||||
|
||||
want := filepath.Join(root, "source", "manifest.json")
|
||||
if got != want {
|
||||
t.Fatalf("SafePath = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePathRejectsUnsafeNames(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", path: " ", want: "empty"},
|
||||
{name: "absolute", path: filepath.Join(root, "artifact.json"), want: "relative"},
|
||||
{name: "parent segment", path: "../artifact.json", want: ".."},
|
||||
{name: "embedded parent", path: "source/../artifact.json", want: ".."},
|
||||
{name: "backslash", path: `source\artifact.json`, want: "slash-separated"},
|
||||
{name: "unclean", path: "source//artifact.json", want: "clean"},
|
||||
{name: "dot", path: ".", want: ".."},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := SafePath(root, tc.path)
|
||||
if err == nil {
|
||||
t.Fatalf("SafePath returned %q, want error", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("SafePath error = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePathRejectsEmptyRoot(t *testing.T) {
|
||||
got, err := SafePath(" ", "artifact.json")
|
||||
if err == nil {
|
||||
t.Fatalf("SafePath returned %q, want error", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "root") {
|
||||
t.Fatalf("SafePath error = %v, want root error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePathDoesNotPermitEscapingRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{
|
||||
"..",
|
||||
"../outside.json",
|
||||
"nested/../../outside.json",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, err := SafePath(root, name)
|
||||
if err == nil {
|
||||
t.Fatalf("SafePath returned %q, want error", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONWritesIndentedAtomicArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
err := WriteJSON(root, "source/manifest.json", map[string]any{
|
||||
"status": "succeeded",
|
||||
"count": 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteJSON: %v", err)
|
||||
}
|
||||
|
||||
got := string(readFile(t, filepath.Join(root, "source", "manifest.json")))
|
||||
if !strings.HasSuffix(got, "\n") {
|
||||
t.Fatalf("expected trailing newline, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `"status": "succeeded"`) || !strings.Contains(got, `"count": 2`) {
|
||||
t.Fatalf("unexpected JSON: %s", got)
|
||||
}
|
||||
assertNoTempFiles(t, filepath.Join(root, "source"))
|
||||
}
|
||||
|
||||
func TestWriteBytesWritesNestedArtifact(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
if err := WriteBytes(root, "chunk/chunks.json", []byte("payload")); err != nil {
|
||||
t.Fatalf("WriteBytes: %v", err)
|
||||
}
|
||||
|
||||
got := string(readFile(t, filepath.Join(root, "chunk", "chunks.json")))
|
||||
if got != "payload" {
|
||||
t.Fatalf("bytes = %q, want payload", got)
|
||||
}
|
||||
assertNoTempFiles(t, filepath.Join(root, "chunk"))
|
||||
}
|
||||
|
||||
func TestWritersRejectUnsafePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
if err := WriteBytes(root, "../outside.json", []byte("payload")); err == nil {
|
||||
t.Fatalf("WriteBytes accepted unsafe path")
|
||||
}
|
||||
if err := WriteJSON(root, `debug\trace.json`, map[string]string{"x": "y"}); err == nil {
|
||||
t.Fatalf("WriteJSON accepted unsafe path")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "..", "outside.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("outside path stat err = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %q: %v", path, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func assertNoTempFiles(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read dir %q: %v", dir, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Name(), ".tmp-") {
|
||||
t.Fatalf("temporary file was not cleaned up: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
RootDir string
|
||||
DiagnosticsRoot string
|
||||
CheckpointsRoot string
|
||||
DebugRoot string
|
||||
DiagnosticsEnabled bool
|
||||
ResumeEnabled bool
|
||||
DebugEnabled bool
|
||||
}
|
||||
|
||||
func FromConfig(cfg config.Config) Settings {
|
||||
_ = cfg
|
||||
return Settings{}
|
||||
}
|
||||
|
||||
func (s Settings) DiagnosticsRunDirectory(runID string) (string, error) {
|
||||
if !s.DiagnosticsEnabled || strings.TrimSpace(s.DiagnosticsRoot) == "" {
|
||||
return "", nil
|
||||
}
|
||||
return safeSingleDirectory(s.DiagnosticsRoot, runID, "diagnostics run ID")
|
||||
}
|
||||
|
||||
func (s Settings) DebugRunDirectory(runID string) (string, error) {
|
||||
if !s.DebugEnabled || strings.TrimSpace(s.DebugRoot) == "" {
|
||||
return "", nil
|
||||
}
|
||||
return safeSingleDirectory(s.DebugRoot, runID, "debug run ID")
|
||||
}
|
||||
|
||||
func cleanPath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func safeSingleDirectory(root string, name string, label string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if strings.Contains(name, "/") || strings.Contains(name, `\`) {
|
||||
return "", fmt.Errorf("%s %q must be a single directory name", label, name)
|
||||
}
|
||||
return SafePath(root, name)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
//go:build legacy
|
||||
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
)
|
||||
|
||||
func TestFromConfigBuildsWorkspaceRoots(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Workspace.Directory = "/var/lib/notarius"
|
||||
cfg.Workspace.Resume.Enabled = true
|
||||
cfg.Workspace.Debug.Enabled = true
|
||||
cfg.RecomputeEffectiveDiagnostics()
|
||||
|
||||
settings := FromConfig(cfg)
|
||||
|
||||
if settings.RootDir != "/var/lib/notarius" {
|
||||
t.Fatalf("RootDir = %q, want /var/lib/notarius", settings.RootDir)
|
||||
}
|
||||
if settings.DiagnosticsRoot != "/var/lib/notarius/diagnostics" || !settings.DiagnosticsEnabled {
|
||||
t.Fatalf("diagnostics settings = %+v, want workspace diagnostics root enabled", settings)
|
||||
}
|
||||
if settings.CheckpointsRoot != "/var/lib/notarius/checkpoints" || !settings.ResumeEnabled {
|
||||
t.Fatalf("checkpoint settings = %+v, want workspace checkpoints root enabled", settings)
|
||||
}
|
||||
if settings.DebugRoot != "/var/lib/notarius/debug" || !settings.DebugEnabled {
|
||||
t.Fatalf("debug settings = %+v, want workspace debug root enabled", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromConfigKeepsLegacyDiagnosticsRootWithoutWorkspaceRoot(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Diagnostics.WorkDir = "/tmp/notarius-legacy"
|
||||
cfg.Workspace.Resume.Enabled = true
|
||||
cfg.Workspace.Debug.Enabled = true
|
||||
|
||||
settings := FromConfig(cfg)
|
||||
|
||||
if settings.RootDir != "" {
|
||||
t.Fatalf("RootDir = %q, want empty", settings.RootDir)
|
||||
}
|
||||
if settings.DiagnosticsRoot != "/tmp/notarius-legacy" || !settings.DiagnosticsEnabled {
|
||||
t.Fatalf("diagnostics settings = %+v, want legacy diagnostics root enabled", settings)
|
||||
}
|
||||
if settings.CheckpointsRoot != "" || settings.ResumeEnabled {
|
||||
t.Fatalf("checkpoint settings = %+v, want disabled empty root", settings)
|
||||
}
|
||||
if settings.DebugRoot != "" || settings.DebugEnabled {
|
||||
t.Fatalf("debug settings = %+v, want disabled empty root", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathConstructors(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
settings := Settings{
|
||||
RootDir: root,
|
||||
DiagnosticsRoot: filepath.Join(root, "diagnostics"),
|
||||
CheckpointsRoot: filepath.Join(root, "checkpoints"),
|
||||
DebugRoot: filepath.Join(root, "debug"),
|
||||
DiagnosticsEnabled: true,
|
||||
ResumeEnabled: true,
|
||||
DebugEnabled: true,
|
||||
}
|
||||
|
||||
diagnosticsDir, err := settings.DiagnosticsRunDirectory("run-123")
|
||||
if err != nil {
|
||||
t.Fatalf("DiagnosticsRunDirectory: %v", err)
|
||||
}
|
||||
if diagnosticsDir != filepath.Join(root, "diagnostics", "run-123") {
|
||||
t.Fatalf("diagnostics dir = %q", diagnosticsDir)
|
||||
}
|
||||
|
||||
debugDir, err := settings.DebugRunDirectory("run-456")
|
||||
if err != nil {
|
||||
t.Fatalf("DebugRunDirectory: %v", err)
|
||||
}
|
||||
if debugDir != filepath.Join(root, "debug", "run-456") {
|
||||
t.Fatalf("debug dir = %q", debugDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledPathConstructorsReturnEmptyPaths(t *testing.T) {
|
||||
settings := Settings{}
|
||||
|
||||
for name, call := range map[string]func() (string, error){
|
||||
"diagnostics": func() (string, error) { return settings.DiagnosticsRunDirectory("run-1") },
|
||||
"debug": func() (string, error) { return settings.DebugRunDirectory("run-1") },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, err := call()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("path = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDirectoryConstructorsRejectNestedNames(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
settings := Settings{
|
||||
DiagnosticsRoot: filepath.Join(root, "diagnostics"),
|
||||
DebugRoot: filepath.Join(root, "debug"),
|
||||
DiagnosticsEnabled: true,
|
||||
DebugEnabled: true,
|
||||
}
|
||||
|
||||
if got, err := settings.DiagnosticsRunDirectory("run-1/nested"); err == nil {
|
||||
t.Fatalf("DiagnosticsRunDirectory returned %q, want error", got)
|
||||
}
|
||||
if got, err := settings.DebugRunDirectory("run-1/nested"); err == nil {
|
||||
t.Fatalf("DebugRunDirectory returned %q, want error", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package checkpoint
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// These names and values are frozen checkpoint wire-compatibility
|
||||
// identifiers. They intentionally retain the former terminology.
|
||||
WorkspaceSchemaVersion = "notarius.workspace.v2"
|
||||
WorkspaceSchemaVersionV1 = "notarius.workspace.v1"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user