Enable explicit configuration imports and overlays
This commit is contained in:
@@ -42,9 +42,9 @@ func applicationOverrides(fs *flag.FlagSet) []configkit.Override {
|
||||
return overrides
|
||||
}
|
||||
|
||||
func loadApplicationSettings(configPath string, explicit bool, overrides []configkit.Override) (appconfig.AppSettings, []configkit.Diagnostic, error) {
|
||||
func loadApplicationSettings(configPath, overlay string, explicit bool, overrides []configkit.Override) (appconfig.AppSettings, []configkit.Diagnostic, error) {
|
||||
result, err := appconfig.Load(appconfig.LoadOptions{
|
||||
Path: configPath, Explicit: explicit, Overrides: overrides,
|
||||
Path: configPath, Overlay: overlay, Explicit: explicit, Overrides: overrides,
|
||||
})
|
||||
if err != nil {
|
||||
return appconfig.AppSettings{}, nil, err
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
type promptInspectionConfig struct {
|
||||
configPath, promptDir, promptID, promptVersion, outputPath string
|
||||
configOverlay string
|
||||
configExplicit bool
|
||||
outputFormat appformat.OutputFormat
|
||||
overrides []configkit.Override
|
||||
@@ -25,6 +26,7 @@ type promptInspectionConfig struct {
|
||||
|
||||
type profileInspectionConfig struct {
|
||||
configPath, profileDir, profileID, outputPath string
|
||||
configOverlay string
|
||||
configExplicit bool
|
||||
outputFormat appformat.OutputFormat
|
||||
overrides []configkit.Override
|
||||
@@ -127,7 +129,7 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error)
|
||||
cfg := &profileInspectionConfig{outputFormat: appformat.DefaultOutputFormat}
|
||||
fs := flag.NewFlagSet("inspect profile", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
registerConfigPathFlag(fs, &cfg.configPath)
|
||||
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
|
||||
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profiles")
|
||||
fs.StringVar(&cfg.profileID, "profile", "", "profile ID to inspect")
|
||||
rawFormat := ""
|
||||
@@ -156,7 +158,7 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error)
|
||||
}
|
||||
|
||||
func resolveAppSettingsForProfileInspection(cfg *profileInspectionConfig) (engineSettings, error) {
|
||||
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides)
|
||||
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configOverlay, cfg.configExplicit, cfg.overrides)
|
||||
if err != nil {
|
||||
return engineSettings{}, err
|
||||
}
|
||||
@@ -168,7 +170,7 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) {
|
||||
cfg := &promptInspectionConfig{outputFormat: appformat.DefaultOutputFormat}
|
||||
fs := flag.NewFlagSet("inspect prompt", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
registerConfigPathFlag(fs, &cfg.configPath)
|
||||
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
|
||||
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definitions")
|
||||
fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to inspect")
|
||||
fs.StringVar(&cfg.promptVersion, "prompt-version", "", "optional prompt version")
|
||||
@@ -198,7 +200,7 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) {
|
||||
}
|
||||
|
||||
func resolveAppSettingsForPromptInspection(cfg *promptInspectionConfig) (engineSettings, error) {
|
||||
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides)
|
||||
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configOverlay, cfg.configExplicit, cfg.overrides)
|
||||
if err != nil {
|
||||
return engineSettings{}, err
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ type runConfig struct {
|
||||
transientRetries int
|
||||
configuredTransientRetries *int
|
||||
diagnostics []configkit.Diagnostic
|
||||
configOverlay string
|
||||
|
||||
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
||||
|
||||
@@ -92,6 +93,7 @@ type serveConfig struct {
|
||||
backends []appconfig.BackendSettings
|
||||
transientRetries *int
|
||||
diagnostics []configkit.Diagnostic
|
||||
configOverlay string
|
||||
}
|
||||
|
||||
type commonCommandSettings struct {
|
||||
@@ -306,7 +308,7 @@ func parseRunArgs(args []string) (*runConfig, error) {
|
||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
registerConfigPathFlag(fs, &cfg.configPath)
|
||||
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
|
||||
registerExecutionRequestFlags(fs, cfg)
|
||||
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
|
||||
fs.DurationVar(&cfg.runTimeout, "run-timeout", 0, "whole-run execution deadline")
|
||||
@@ -363,7 +365,7 @@ func parseRenderArgs(args []string) (*renderConfig, error) {
|
||||
fs := flag.NewFlagSet("render", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
registerConfigPathFlag(fs, &cfg.configPath)
|
||||
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
|
||||
registerExecutionRequestFlags(fs, &cfg.runConfig)
|
||||
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
|
||||
|
||||
@@ -394,7 +396,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
registerConfigPathFlag(fs, &cfg.configPath)
|
||||
registerConfigSelectionFlags(fs, &cfg.configPath, &cfg.configOverlay)
|
||||
fs.StringVar(&cfg.addr, "addr", "", "HTTP listen address")
|
||||
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
|
||||
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
|
||||
@@ -411,7 +413,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
||||
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
|
||||
}
|
||||
|
||||
settings, err := resolveCommonSettings(fs, cfg.configPath)
|
||||
settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -463,7 +465,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
|
||||
return fmt.Errorf("unexpected positional args: %v", fs.Args())
|
||||
}
|
||||
|
||||
settings, err := resolveCommonSettings(fs, cfg.configPath)
|
||||
settings, err := resolveCommonSettings(fs, cfg.configPath, cfg.configOverlay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -500,7 +502,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
|
||||
func registerConfigSelectionFlags(fs *flag.FlagSet, target, overlay *string) {
|
||||
fs.StringVar(
|
||||
target,
|
||||
"config",
|
||||
@@ -511,10 +513,11 @@ func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
|
||||
appconfig.DefaultConfigPath,
|
||||
),
|
||||
)
|
||||
fs.StringVar(overlay, "config-overlay", "", "selected application configuration overlay")
|
||||
}
|
||||
|
||||
func resolveCommonSettings(fs *flag.FlagSet, configPath string) (commonCommandSettings, error) {
|
||||
settings, diagnostics, err := loadApplicationSettings(configPath, flagWasSet(fs, "config"), applicationOverrides(fs))
|
||||
func resolveCommonSettings(fs *flag.FlagSet, configPath, overlay string) (commonCommandSettings, error) {
|
||||
settings, diagnostics, err := loadApplicationSettings(configPath, overlay, flagWasSet(fs, "config"), applicationOverrides(fs))
|
||||
if err != nil {
|
||||
return commonCommandSettings{}, err
|
||||
}
|
||||
@@ -750,9 +753,9 @@ func runErrorMessage(err error) string {
|
||||
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage: scriptorium <run|render|serve|inspect> ...")
|
||||
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]")
|
||||
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]")
|
||||
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
|
||||
fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--prompt-dir DIR] [--format text|json] [--out path]")
|
||||
fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--profile-dir DIR] [--format text|json] [--out path]")
|
||||
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--execution-report path] [--timeout 10m] [--run-timeout 10m] [--transient-retries N]")
|
||||
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m] [--transient-retries N]")
|
||||
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--config-overlay NAME] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
|
||||
fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--config-overlay NAME] [--prompt-dir DIR] [--format text|json] [--out path]")
|
||||
fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--config-overlay NAME] [--profile-dir DIR] [--format text|json] [--out path]")
|
||||
}
|
||||
|
||||
@@ -1071,6 +1071,41 @@ profile_dir: %s
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCommandLoadsImportsAndOverlayWithoutChangingExecutionProfile(t *testing.T) {
|
||||
lib := newCLITestLibrary(t)
|
||||
inputPath := lib.writeInputFile(t, "transcript.md", "hello transcript")
|
||||
writePromptFile(t, lib.promptDir, "prompt.render", "local-default")
|
||||
writeProfileFile(t, lib.profileDir, "local-default", "http://127.0.0.1:1/v1", "profile-model")
|
||||
if err := os.MkdirAll(filepath.Join(lib.rootDir, "fragments"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(lib.rootDir, "overlays"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := filepath.Join(lib.rootDir, "config.yml")
|
||||
for path, body := range map[string]string{
|
||||
base: "configkit:\n imports:\n - path: fragments/sources.yml\n",
|
||||
filepath.Join(lib.rootDir, "fragments", "sources.yml"): fmt.Sprintf("prompt_dir: %q\nprofile_dir: %q\n", lib.promptDir, lib.profileDir),
|
||||
filepath.Join(lib.rootDir, "overlays", "local.yml"): "defaults:\n render_format: json\n",
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
args := []string{"--config", base, "--config-overlay", "local", "--profile", "local-default", "--prompt", "prompt.render", "--input", "transcript=" + inputPath}
|
||||
cfg, err := parseRenderArgs(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.profileID != "local-default" || cfg.outputFormat != renderformat.PreparedRunFormatJSON {
|
||||
t.Fatalf("overlay changed execution profile or output selection: %+v", cfg)
|
||||
}
|
||||
code, stdout, stderr := runCLICommand(t, renderCommand, args)
|
||||
if code != ExitOK || !strings.Contains(stdout, `"selected_profile_id": "local-default"`) {
|
||||
t.Fatalf("render did not use composed config and independent execution profile: code=%d out=%q err=%q", code, stdout, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCommandUsesConfiguredCustomBackend(t *testing.T) {
|
||||
lib := newCLITestLibrary(t)
|
||||
writePromptDefinition(t, lib.promptDir, "custom.yaml", `id: custom
|
||||
|
||||
@@ -21,8 +21,10 @@ type LoadOptions struct {
|
||||
// Load resolves one ConfigKit document and retains its inspection snapshot.
|
||||
func Load(options LoadOptions) (*configkit.Result[Document], error) {
|
||||
var source configkit.Source
|
||||
fileBacked := false
|
||||
if options.Explicit {
|
||||
source = configkit.File(options.Path)
|
||||
fileBacked = true
|
||||
} else {
|
||||
selected := ""
|
||||
for _, candidate := range defaultConfigSearchPaths {
|
||||
@@ -41,11 +43,14 @@ func Load(options LoadOptions) (*configkit.Result[Document], error) {
|
||||
source = configkit.NoSource()
|
||||
} else {
|
||||
source = configkit.File(selected)
|
||||
fileBacked = true
|
||||
}
|
||||
}
|
||||
|
||||
result, err := configkit.Load(source, configkit.Options[Document]{
|
||||
Definition: documentDefinition(),
|
||||
Imports: fileBacked,
|
||||
Overlay: options.Overlay,
|
||||
Overrides: options.Overrides,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -174,3 +174,56 @@ func TestLoadDiscoveryErrorQuotesPath(t *testing.T) {
|
||||
t.Fatalf("discovery error did not quote path safely: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadComposesExplicitImportsAndSelectedOverlay(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "fragments"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "overlays"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := filepath.Join(dir, "config.yml")
|
||||
writeApplicationFile(t, base, "configkit:\n imports:\n - glob: fragments/*.yml\nprompt_dir: ./base-prompts\nserver:\n addr: :8081\n")
|
||||
writeApplicationFile(t, filepath.Join(dir, "fragments", "a.yml"), "backends:\n alpha:\n endpoint: http://alpha.invalid/v1\n")
|
||||
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n beta:\n endpoint: http://beta.invalid/v1\n")
|
||||
writeApplicationFile(t, filepath.Join(dir, "overlays", "local.yml"), "prompt_dir: ./overlay-prompts\nserver:\n addr: :9090\n")
|
||||
|
||||
result, err := Load(LoadOptions{Path: base, Explicit: true})
|
||||
if err != nil || result.Config.PromptDir != "base-prompts" || len(result.Config.Backends) != 2 || len(result.Sources()) != 3 {
|
||||
t.Fatalf("disjoint imports did not compose: result=%+v err=%v", result, err)
|
||||
}
|
||||
result, err = Load(LoadOptions{Path: base, Explicit: true, Overlay: "local", Overrides: []configkit.Override{{
|
||||
Path: configkit.Keys("server", "addr"), Value: ":7070", Flag: "--addr",
|
||||
}}})
|
||||
if err != nil || result.Config.PromptDir != "overlay-prompts" || result.Config.Server.Addr != ":7070" || len(result.Sources()) != 5 {
|
||||
t.Fatalf("overlay or explicit override changed: result=%+v err=%v", result, err)
|
||||
}
|
||||
if _, err := Load(LoadOptions{Path: base, Explicit: true, Overlay: "missing"}); err == nil {
|
||||
t.Fatal("missing selected overlay accepted")
|
||||
}
|
||||
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n alpha:\n endpoint: http://conflict.invalid/v1\n")
|
||||
if _, err := Load(LoadOptions{Path: base, Explicit: true}); err == nil {
|
||||
t.Fatal("conflicting import accepted")
|
||||
} else {
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("conflict lost structured diagnostic: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsOverlayWithoutBase(t *testing.T) {
|
||||
useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml"))
|
||||
if _, err := Load(LoadOptions{Overlay: "local"}); err == nil {
|
||||
t.Fatal("overlay without base accepted")
|
||||
} else {
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("no-base overlay lost structured diagnostic: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := Load(LoadOptions{Overlay: "default"}); err != nil {
|
||||
t.Fatalf("default must select no overlay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user