Remove legacy workspace and diagnostics implementation
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user