Switch CLI configuration to ConfigKit
This commit is contained in:
15
docs/cli.md
15
docs/cli.md
@@ -26,9 +26,13 @@ go run ./cmd/scriptorium render \
|
|||||||
- `scriptorium inspect prompt`: inspect one prompt definition without model execution.
|
- `scriptorium inspect prompt`: inspect one prompt definition without model execution.
|
||||||
- `scriptorium inspect profile`: inspect one effective profile without model execution.
|
- `scriptorium inspect profile`: inspect one effective profile without model execution.
|
||||||
|
|
||||||
All commands accept `--config <path>` and reject positional arguments. An
|
All commands accept `--config <path>` and reject positional arguments. `run`,
|
||||||
effective `prompt_dir` is required for every command. Supply it through the
|
`render`, `serve`, and `inspect prompt` require an effective `prompt_dir`;
|
||||||
configuration contract or the command's `--prompt-dir` flag.
|
`inspect profile` does not. Supply the directory through the configuration
|
||||||
|
contract or a supported `--prompt-dir` flag. Explicit application flags,
|
||||||
|
including empty values, override file settings. Configuration load diagnostics
|
||||||
|
go to stderr and exit with status 1; successful load warnings are printed once
|
||||||
|
before command work.
|
||||||
|
|
||||||
## `scriptorium run`
|
## `scriptorium run`
|
||||||
|
|
||||||
@@ -144,7 +148,7 @@ scriptorium render [flags]
|
|||||||
```
|
```
|
||||||
|
|
||||||
`--prompt <id>` is required. The following optional flags are supported:
|
`--prompt <id>` is required. The following optional flags are supported:
|
||||||
`--config`, `--prompt-dir`, `--profile-dir`, `--prompt-version`, `--profile`,
|
`--config`, `--prompt-dir`, `--profile-dir`, `--schema-dir`, `--prompt-version`, `--profile`,
|
||||||
`--input`, `--var`, `--out`, `--llm-base-url`,
|
`--input`, `--var`, `--out`, `--llm-base-url`,
|
||||||
`--model`, `--api-key-env`, `--temperature`, `--max-tokens`, `--top-p`,
|
`--model`, `--api-key-env`, `--temperature`, `--max-tokens`, `--top-p`,
|
||||||
`--reasoning-effort`, `--session-id`, `--timeout`, `--transient-retries`, and `--format text|json`. Their meanings match the corresponding
|
`--reasoning-effort`, `--session-id`, `--timeout`, `--transient-retries`, and `--format text|json`. Their meanings match the corresponding
|
||||||
@@ -156,8 +160,7 @@ The same session and reasoning inheritance, replacement, and clearing behavior
|
|||||||
also applies.
|
also applies.
|
||||||
`render` validates the retry override but does not generate model output or
|
`render` validates the retry override but does not generate model output or
|
||||||
include a retry field in prepared output.
|
include a retry field in prepared output.
|
||||||
`render` does not accept `--schema-dir`; configure `schema_dir` through the
|
`render` resolves profiles and schemas as part of preparation but
|
||||||
configuration file. It resolves profiles and schemas as part of preparation but
|
|
||||||
does not call an LLM.
|
does not call an LLM.
|
||||||
|
|
||||||
## `scriptorium serve`
|
## `scriptorium serve`
|
||||||
|
|||||||
@@ -25,12 +25,24 @@ The maintained [minimal configuration](../examples/config.yml) and
|
|||||||
|
|
||||||
## Application Configuration File
|
## Application Configuration File
|
||||||
|
|
||||||
Configuration is strict YAML: unknown fields are rejected. Empty string values
|
Configuration is decoded by ConfigKit v0.4.0 as strict YAML. Unknown fields,
|
||||||
do not override a prior value. Raw API-key fields are not accepted.
|
empty files, multiple YAML documents, and YAML aliases/anchors are rejected.
|
||||||
|
An explicit empty string is a value: it clears a file-provided optional path
|
||||||
|
or, for required `prompt_dir`, fails the command's prerequisite. A scalar
|
||||||
|
`null` is not an empty string and is rejected for scalar fields; nullable
|
||||||
|
fields such as `transient_retries` retain their documented meaning. Raw
|
||||||
|
API-key fields are not accepted. Imports and selected overlays are not yet
|
||||||
|
enabled for application configuration.
|
||||||
|
|
||||||
|
ConfigKit applies defaults, the selected file, then only explicitly supplied
|
||||||
|
application CLI flags before validating the result. Paths in the application
|
||||||
|
document remain relative to the process working directory, not to the file's
|
||||||
|
directory. Load diagnostics are printed safely on stderr; invalid application
|
||||||
|
configuration exits with status 1.
|
||||||
|
|
||||||
| Field | Default | Meaning |
|
| Field | Default | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `prompt_dir` | unset | Promptkit prompt-definition source directory. `run`, `render`, and `serve` require an effective value. |
|
| `prompt_dir` | unset | Promptkit prompt-definition source directory. `run`, `render`, `serve`, and `inspect prompt` require an effective value. |
|
||||||
| `profile_dir` | unset | Optional custom Promptkit profile source directory overlaid on Promptkit built-ins. |
|
| `profile_dir` | unset | Optional custom Promptkit profile source directory overlaid on Promptkit built-ins. |
|
||||||
| `schema_dir` | `.` | Promptkit schema source directory for relative schema paths. |
|
| `schema_dir` | `.` | Promptkit schema source directory for relative schema paths. |
|
||||||
| `server.addr` | `:8080` | Address used by `serve`. |
|
| `server.addr` | `:8080` | Address used by `serve`. |
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
This is the contributor entry point for Scriptorium. Scriptorium is an
|
This is the contributor entry point for Scriptorium. Scriptorium is an
|
||||||
application that consumes the public
|
application that consumes the public
|
||||||
[Promptkit v0.11.0 package](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/consumers/pkg-promptkit.md);
|
[Promptkit v0.11.0 package](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/consumers/pkg-promptkit.md);
|
||||||
framework implementation work belongs in Promptkit.
|
framework implementation work belongs in Promptkit. Application configuration
|
||||||
|
uses ConfigKit v0.4.0; keep Scriptorium's schema and source selection local.
|
||||||
|
|
||||||
## Initial Orientation
|
## Initial Orientation
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ tests.
|
|||||||
| Repository orientation or component responsibility | [Internal component overview](internal/overview.md) and [architecture policy](policy/architecture.md) |
|
| Repository orientation or component responsibility | [Internal component overview](internal/overview.md) and [architecture policy](policy/architecture.md) |
|
||||||
| CLI commands, flags, output, or exit behavior | [CLI contract](cli.md) and [adapter internals](internal/adapters.md) |
|
| CLI commands, flags, output, or exit behavior | [CLI contract](cli.md) and [adapter internals](internal/adapters.md) |
|
||||||
| HTTP routes, DTOs, limits, status mapping, or artifact policy | [HTTP API contract](api.md), [adapter internals](internal/adapters.md), and [source internals](internal/sources.md) |
|
| HTTP routes, DTOs, limits, status mapping, or artifact policy | [HTTP API contract](api.md), [adapter internals](internal/adapters.md), and [source internals](internal/sources.md) |
|
||||||
| Application configuration or precedence | [Configuration contract](config.md), [adapter internals](internal/adapters.md), and [source internals](internal/sources.md) |
|
| Application configuration or precedence | [Configuration contract](config.md), [adapter internals](internal/adapters.md), [source internals](internal/sources.md), and `internal/config` definition/load tests |
|
||||||
| Prepared-run presentation | [CLI contract](cli.md), [adapter internals](internal/adapters.md), and `internal/format` |
|
| Prepared-run presentation | [CLI contract](cli.md), [adapter internals](internal/adapters.md), and `internal/format` |
|
||||||
| Prompt, profile, schema, generation, or validation semantics | [Promptkit framework formats](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/formats.md) and the [Promptkit consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/consumers/pkg-promptkit.md) |
|
| Prompt, profile, schema, generation, or validation semantics | [Promptkit framework formats](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/formats.md) and the [Promptkit consumer guide](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/consumers/pkg-promptkit.md) |
|
||||||
| OpenAI-compatible outbound behavior or timeout layering | [Promptkit integration contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/integrations/openai-compatible-chat.md) |
|
| OpenAI-compatible outbound behavior or timeout layering | [Promptkit integration contract](https://gitea.maximumdirect.net/eric/promptkit/src/tag/v0.11.0/docs/integrations/openai-compatible-chat.md) |
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ described by its tagged
|
|||||||
- `internal/adapter/cli` resolves settings through `internal/config`,
|
- `internal/adapter/cli` resolves settings through `internal/config`,
|
||||||
constructs `promptkit.Engine`, maps CLI values to `promptkit.RunRequest`,
|
constructs `promptkit.Engine`, maps CLI values to `promptkit.RunRequest`,
|
||||||
and owns output files, summaries, and exit codes.
|
and owns output files, summaries, and exit codes.
|
||||||
|
- `internal/config` delegates application YAML decoding, default resolution,
|
||||||
|
overrides, and validation ordering to ConfigKit. The CLI supplies only
|
||||||
|
explicitly visited application flags as overrides and renders safe structured
|
||||||
|
load diagnostics on stderr.
|
||||||
- `internal/adapter/http` strictly decodes request DTOs, maps them to Promptkit
|
- `internal/adapter/http` strictly decodes request DTOs, maps them to Promptkit
|
||||||
public values, calls its adapter-owned `Runner` interface, and maps results
|
public values, calls its adapter-owned `Runner` interface, and maps results
|
||||||
and errors to HTTP DTOs.
|
and errors to HTTP DTOs.
|
||||||
@@ -53,7 +57,7 @@ not require prompt, schema, or artifact sources.
|
|||||||
with `promptkit.WithArtifactReader`, passes the engine through the HTTP
|
with `promptkit.WithArtifactReader`, passes the engine through the HTTP
|
||||||
adapter's consumer-owned `Runner` interface, and starts the server.
|
adapter's consumer-owned `Runner` interface, and starts the server.
|
||||||
|
|
||||||
All three CLI paths assemble the engine from the same resolved prompt, profile,
|
The command paths assemble the engine from the same resolved prompt, profile,
|
||||||
and schema directories plus configured custom backends. Each backend is mapped
|
and schema directories plus configured custom backends. Each backend is mapped
|
||||||
to Promptkit's public `Backend` value and registered during engine construction,
|
to Promptkit's public `Backend` value and registered during engine construction,
|
||||||
so one constructed server engine retains one immutable backend registry and its
|
so one constructed server engine retains one immutable backend registry and its
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ The [configuration reference](../config.md) owns Scriptorium's source-location
|
|||||||
fields and precedence. Maintained files under `examples/` are application
|
fields and precedence. Maintained files under `examples/` are application
|
||||||
inputs that use Promptkit's tagged formats.
|
inputs that use Promptkit's tagged formats.
|
||||||
|
|
||||||
## Native Application Definition In Preparation
|
## Application Configuration Boundary
|
||||||
|
|
||||||
`internal/config` also contains a ConfigKit-native `Document` definition. Its
|
`internal/config` contains a ConfigKit-native `Document` definition. Its
|
||||||
fresh defaults come from Scriptorium's existing default constants; its resolver
|
fresh defaults come from Scriptorium's existing default constants; its resolver
|
||||||
records only actual trimming, path cleaning, and render-format normalization.
|
records only actual trimming, path cleaning, and render-format normalization.
|
||||||
The validator reports application retry, size-limit, and format errors without
|
The validator reports application retry, size-limit, and format errors without
|
||||||
@@ -34,7 +34,7 @@ mutating the document or exposing rejected values. Application path values keep
|
|||||||
working-directory-relative meaning. ConfigKit owns strict YAML decoding and
|
working-directory-relative meaning. ConfigKit owns strict YAML decoding and
|
||||||
presence semantics, including dynamic integer values in backend parameters.
|
presence semantics, including dynamic integer values in backend parameters.
|
||||||
|
|
||||||
The new `Load` boundary selects an explicit file, the first existing implicit
|
The `Load` boundary selects an explicit file, the first existing implicit
|
||||||
configuration file, or ConfigKit's defaults-only source. Only absence advances
|
configuration file, or ConfigKit's defaults-only source. Only absence advances
|
||||||
implicit discovery. One ConfigKit load applies explicit application overrides
|
implicit discovery. One ConfigKit load applies explicit application overrides
|
||||||
before validation and returns a result containing the resolved document and
|
before validation and returns a result containing the resolved document and
|
||||||
@@ -43,9 +43,10 @@ existing runtime settings with backend IDs sorted; it does not read files or
|
|||||||
apply another merge. Imports and selected overlays remain disabled until their
|
apply another merge. Imports and selected overlays remain disabled until their
|
||||||
own user-facing integration is ready.
|
own user-facing integration is ready.
|
||||||
|
|
||||||
The executable commands still use the existing loader. The new definition is
|
Every configuration-loading command uses this boundary. The CLI maps only
|
||||||
internal preparation and is not yet an alternative user-selectable configuration
|
explicitly visited application flags to ConfigKit overrides and prints
|
||||||
mode or a change to the current [configuration contract](../config.md).
|
structured load diagnostics before engine construction. The old application
|
||||||
|
YAML decoder and merge path have been removed.
|
||||||
|
|
||||||
## Restricted HTTP Artifact Reader
|
## Restricted HTTP Artifact Reader
|
||||||
|
|
||||||
@@ -78,7 +79,8 @@ codes.
|
|||||||
|
|
||||||
Inspect:
|
Inspect:
|
||||||
|
|
||||||
- `internal/config/config_test.go`
|
- `internal/config/document_test.go`
|
||||||
|
- `internal/config/load_test.go`
|
||||||
- `internal/adapter/cli/run_test.go`
|
- `internal/adapter/cli/run_test.go`
|
||||||
- `internal/adapter/http/artifact_reader_test.go`
|
- `internal/adapter/http/artifact_reader_test.go`
|
||||||
- `internal/adapter/http/handler_test.go`
|
- `internal/adapter/http/handler_test.go`
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ validation. Scriptorium owns application configuration, executable adapters,
|
|||||||
prepared-run and definition-inspection presentation, process behavior, and HTTP
|
prepared-run and definition-inspection presentation, process behavior, and HTTP
|
||||||
deployment policy.
|
deployment policy.
|
||||||
|
|
||||||
|
Scriptorium also consumes ConfigKit v0.4.0 for application configuration
|
||||||
|
decoding, resolution, and validation flow. Scriptorium owns its application
|
||||||
|
schema, defaults, normalization, validation rules, source selection, and CLI
|
||||||
|
flag mapping; ConfigKit does not own Promptkit framework sources or HTTP
|
||||||
|
artifact containment.
|
||||||
|
|
||||||
The concrete package inventory is maintained in the
|
The concrete package inventory is maintained in the
|
||||||
[internal overview](../internal/overview.md).
|
[internal overview](../internal/overview.md).
|
||||||
|
|
||||||
@@ -54,7 +60,7 @@ cmd/scriptorium
|
|||||||
CLI and HTTP adapters, configuration, defaults, and formatting
|
CLI and HTTP adapters, configuration, defaults, and formatting
|
||||||
|
|
|
|
||||||
v
|
v
|
||||||
gitea.maximumdirect.net/eric/promptkit
|
Promptkit (execution) and ConfigKit (application configuration)
|
||||||
```
|
```
|
||||||
|
|
||||||
- Retained application packages may import Promptkit's root package.
|
- Retained application packages may import Promptkit's root package.
|
||||||
@@ -80,8 +86,8 @@ vendored Promptkit implementation, or unpublished dependency assumptions.
|
|||||||
process streams, output files, summaries, and exit codes.
|
process streams, output files, summaries, and exit codes.
|
||||||
- `internal/adapter/http` owns routes, strict JSON DTOs, size limits, response
|
- `internal/adapter/http` owns routes, strict JSON DTOs, size limits, response
|
||||||
mapping, status mapping, and the restricted artifact reader.
|
mapping, status mapping, and the restricted artifact reader.
|
||||||
- `internal/config` owns discovery and strict decoding of Scriptorium
|
- `internal/config` owns discovery and Scriptorium's application definition;
|
||||||
application configuration.
|
ConfigKit owns strict YAML decoding and the load/override pipeline.
|
||||||
- `internal/defaults` owns Scriptorium application and HTTP defaults only.
|
- `internal/defaults` owns Scriptorium application and HTTP defaults only.
|
||||||
- `internal/format` owns deterministic prepared-run and definition-inspection
|
- `internal/format` owns deterministic prepared-run and definition-inspection
|
||||||
text and JSON presentation.
|
text and JSON presentation.
|
||||||
|
|||||||
@@ -197,7 +197,10 @@ with no new user-visible mode.
|
|||||||
|
|
||||||
## Stage 3: Cut Existing Commands Over And Remove The Legacy Loader
|
## Stage 3: Cut Existing Commands Over And Remove The Legacy Loader
|
||||||
|
|
||||||
**Status: Not started.**
|
**Status: Complete.** All five command modes use the shared ConfigKit loader;
|
||||||
|
the legacy decoder and merge path are removed. CLI and configuration contracts
|
||||||
|
now describe native YAML and override behavior. Full test, race, vet, build,
|
||||||
|
and diff checks passed.
|
||||||
|
|
||||||
Primary files: `internal/adapter/cli/run.go`, `inspect.go`,
|
Primary files: `internal/adapter/cli/run.go`, `inspect.go`,
|
||||||
`internal/config/config.go`, and associated tests.
|
`internal/config/config.go`, and associated tests.
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -5,7 +5,6 @@ go 1.25.5
|
|||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ConfigKit v0.4.0
|
gitea.maximumdirect.net/eric/ConfigKit v0.4.0
|
||||||
gitea.maximumdirect.net/eric/promptkit v0.11.0
|
gitea.maximumdirect.net/eric/promptkit v0.11.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -14,4 +13,5 @@ require (
|
|||||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
71
internal/adapter/cli/application_config.go
Normal file
71
internal/adapter/cli/application_config.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// applicationOverrides keeps explicit application flags separate from invocation state.
|
||||||
|
func applicationOverrides(fs *flag.FlagSet) []configkit.Override {
|
||||||
|
var overrides []configkit.Override
|
||||||
|
fs.Visit(func(selected *flag.Flag) {
|
||||||
|
var path configkit.Path
|
||||||
|
switch selected.Name {
|
||||||
|
case "prompt-dir":
|
||||||
|
path = configkit.Keys("prompt_dir")
|
||||||
|
case "profile-dir":
|
||||||
|
path = configkit.Keys("profile_dir")
|
||||||
|
case "schema-dir":
|
||||||
|
path = configkit.Keys("schema_dir")
|
||||||
|
case "addr":
|
||||||
|
path = configkit.Keys("server", "addr")
|
||||||
|
case "artifact-root":
|
||||||
|
path = configkit.Keys("server", "artifact_root")
|
||||||
|
case "max-request-bytes":
|
||||||
|
path = configkit.Keys("server", "max_request_bytes")
|
||||||
|
case "max-artifact-bytes":
|
||||||
|
path = configkit.Keys("server", "max_artifact_bytes")
|
||||||
|
case "max-response-bytes":
|
||||||
|
path = configkit.Keys("server", "max_response_bytes")
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
overrides = append(overrides, configkit.Override{
|
||||||
|
Path: path, Value: selected.Value.(flag.Getter).Get(), Flag: "--" + selected.Name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return overrides
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadApplicationSettings(configPath string, explicit bool, overrides []configkit.Override) (appconfig.AppSettings, []configkit.Diagnostic, error) {
|
||||||
|
result, err := appconfig.Load(appconfig.LoadOptions{
|
||||||
|
Path: configPath, Explicit: explicit, Overrides: overrides,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return appconfig.AppSettings{}, nil, err
|
||||||
|
}
|
||||||
|
return appconfig.Settings(result.Config), result.Diagnostics(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeApplicationError(output io.Writer, label string, err error) {
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if errors.As(err, &loadError) {
|
||||||
|
if writeErr := configkit.WriteDiagnostics(output, loadError.Diagnostics()); writeErr != nil {
|
||||||
|
_, _ = fmt.Fprintf(output, "%s diagnostic write error: %v\n", label, writeErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(output, "%s error: %v\n", label, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeApplicationWarnings(output io.Writer, diagnostics []configkit.Diagnostic) error {
|
||||||
|
if len(diagnostics) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return configkit.WriteDiagnostics(output, diagnostics)
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
|
||||||
appformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
appformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,12 +19,16 @@ type promptInspectionConfig struct {
|
|||||||
configPath, promptDir, promptID, promptVersion, outputPath string
|
configPath, promptDir, promptID, promptVersion, outputPath string
|
||||||
configExplicit bool
|
configExplicit bool
|
||||||
outputFormat appformat.OutputFormat
|
outputFormat appformat.OutputFormat
|
||||||
|
overrides []configkit.Override
|
||||||
|
diagnostics []configkit.Diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
type profileInspectionConfig struct {
|
type profileInspectionConfig struct {
|
||||||
configPath, profileDir, profileID, outputPath string
|
configPath, profileDir, profileID, outputPath string
|
||||||
configExplicit bool
|
configExplicit bool
|
||||||
outputFormat appformat.OutputFormat
|
outputFormat appformat.OutputFormat
|
||||||
|
overrides []configkit.Override
|
||||||
|
diagnostics []configkit.Diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
// emptyPromptDefinitionFS satisfies Promptkit's engine-level prompt-source
|
// emptyPromptDefinitionFS satisfies Promptkit's engine-level prompt-source
|
||||||
@@ -50,12 +54,15 @@ func inspectCommand(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
cfg, err := parsePromptInspectionArgs(args[1:])
|
cfg, err := parsePromptInspectionArgs(args[1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "inspect parse error: %v\n", err)
|
writeApplicationError(stderr, "inspect parse", err)
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
settings, err := resolveAppSettingsForPromptInspection(cfg)
|
settings, err := resolveAppSettingsForPromptInspection(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "inspect error: %v\n", err)
|
writeApplicationError(stderr, "inspect", err)
|
||||||
|
return ExitRuntimeError
|
||||||
|
}
|
||||||
|
if err := writeApplicationWarnings(stderr, cfg.diagnostics); err != nil {
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
engine, err := newEngine(settings)
|
engine, err := newEngine(settings)
|
||||||
@@ -83,12 +90,15 @@ func inspectCommand(args []string, stdout, stderr io.Writer) int {
|
|||||||
func inspectProfileCommand(args []string, stdout, stderr io.Writer) int {
|
func inspectProfileCommand(args []string, stdout, stderr io.Writer) int {
|
||||||
cfg, err := parseProfileInspectionArgs(args)
|
cfg, err := parseProfileInspectionArgs(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "inspect parse error: %v\n", err)
|
writeApplicationError(stderr, "inspect parse", err)
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
settings, err := resolveAppSettingsForProfileInspection(cfg)
|
settings, err := resolveAppSettingsForProfileInspection(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "inspect error: %v\n", err)
|
writeApplicationError(stderr, "inspect", err)
|
||||||
|
return ExitRuntimeError
|
||||||
|
}
|
||||||
|
if err := writeApplicationWarnings(stderr, cfg.diagnostics); err != nil {
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
engine, err := newEngine(settings, promptkit.WithPromptFS(emptyPromptDefinitionFS{}, "."))
|
engine, err := newEngine(settings, promptkit.WithPromptFS(emptyPromptDefinitionFS{}, "."))
|
||||||
@@ -127,6 +137,7 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cfg.configExplicit = flagWasSet(fs, "config")
|
cfg.configExplicit = flagWasSet(fs, "config")
|
||||||
|
cfg.overrides = applicationOverrides(fs)
|
||||||
if fs.NArg() > 0 {
|
if fs.NArg() > 0 {
|
||||||
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
|
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
|
||||||
}
|
}
|
||||||
@@ -145,12 +156,11 @@ func parseProfileInspectionArgs(args []string) (*profileInspectionConfig, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveAppSettingsForProfileInspection(cfg *profileInspectionConfig) (engineSettings, error) {
|
func resolveAppSettingsForProfileInspection(cfg *profileInspectionConfig) (engineSettings, error) {
|
||||||
settings, err := resolveAppSettingsWithConfigPresence(cfg.configPath, cfg.configExplicit, appconfig.CLIOverrides{
|
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides)
|
||||||
ProfileDir: cfg.profileDir,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return engineSettings{}, err
|
return engineSettings{}, err
|
||||||
}
|
}
|
||||||
|
cfg.diagnostics = diagnostics
|
||||||
return engineSettings{profileDir: settings.ProfileDir, backends: settings.Backends, transientRetries: settings.TransientRetries}, nil
|
return engineSettings{profileDir: settings.ProfileDir, backends: settings.Backends, transientRetries: settings.TransientRetries}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +179,7 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cfg.configExplicit = flagWasSet(fs, "config")
|
cfg.configExplicit = flagWasSet(fs, "config")
|
||||||
|
cfg.overrides = applicationOverrides(fs)
|
||||||
if fs.NArg() > 0 {
|
if fs.NArg() > 0 {
|
||||||
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
|
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
|
||||||
}
|
}
|
||||||
@@ -187,12 +198,11 @@ func parsePromptInspectionArgs(args []string) (*promptInspectionConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveAppSettingsForPromptInspection(cfg *promptInspectionConfig) (engineSettings, error) {
|
func resolveAppSettingsForPromptInspection(cfg *promptInspectionConfig) (engineSettings, error) {
|
||||||
settings, err := resolveAppSettingsWithConfigPresence(cfg.configPath, cfg.configExplicit, appconfig.CLIOverrides{
|
settings, diagnostics, err := loadApplicationSettings(cfg.configPath, cfg.configExplicit, cfg.overrides)
|
||||||
PromptDir: cfg.promptDir,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return engineSettings{}, err
|
return engineSettings{}, err
|
||||||
}
|
}
|
||||||
|
cfg.diagnostics = diagnostics
|
||||||
if strings.TrimSpace(settings.PromptDir) == "" {
|
if strings.TrimSpace(settings.PromptDir) == "" {
|
||||||
return engineSettings{}, errors.New(errPromptDirRequired)
|
return engineSettings{}, errors.New(errPromptDirRequired)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
|
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
|
||||||
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
||||||
@@ -57,6 +58,7 @@ type runConfig struct {
|
|||||||
runTimeout time.Duration
|
runTimeout time.Duration
|
||||||
transientRetries int
|
transientRetries int
|
||||||
configuredTransientRetries *int
|
configuredTransientRetries *int
|
||||||
|
diagnostics []configkit.Diagnostic
|
||||||
|
|
||||||
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ type serveConfig struct {
|
|||||||
maxResponseBytes int64
|
maxResponseBytes int64
|
||||||
backends []appconfig.BackendSettings
|
backends []appconfig.BackendSettings
|
||||||
transientRetries *int
|
transientRetries *int
|
||||||
|
diagnostics []configkit.Diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
type commonCommandSettings struct {
|
type commonCommandSettings struct {
|
||||||
@@ -103,6 +106,7 @@ type commonCommandSettings struct {
|
|||||||
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
defaultRenderFormat renderformat.PreparedRunOutputFormat
|
||||||
backends []appconfig.BackendSettings
|
backends []appconfig.BackendSettings
|
||||||
transientRetries *int
|
transientRetries *int
|
||||||
|
diagnostics []configkit.Diagnostic
|
||||||
}
|
}
|
||||||
|
|
||||||
type listFlag []string
|
type listFlag []string
|
||||||
@@ -141,7 +145,10 @@ func Run(args []string, stdout, stderr io.Writer) int {
|
|||||||
func runCommand(args []string, stdout, stderr io.Writer) int {
|
func runCommand(args []string, stdout, stderr io.Writer) int {
|
||||||
cfg, err := parseRunArgs(args)
|
cfg, err := parseRunArgs(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "run parse error: %v\n", err)
|
writeApplicationError(stderr, "run parse", err)
|
||||||
|
return ExitRuntimeError
|
||||||
|
}
|
||||||
|
if err := writeApplicationWarnings(stderr, cfg.diagnostics); err != nil {
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +223,10 @@ func writeExecutionReport(path string, value *executionreport.Execution) error {
|
|||||||
func renderCommand(args []string, stdout, stderr io.Writer) int {
|
func renderCommand(args []string, stdout, stderr io.Writer) int {
|
||||||
cfg, err := parseRenderArgs(args)
|
cfg, err := parseRenderArgs(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "render parse error: %v\n", err)
|
writeApplicationError(stderr, "render parse", err)
|
||||||
|
return ExitRuntimeError
|
||||||
|
}
|
||||||
|
if err := writeApplicationWarnings(stderr, cfg.diagnostics); err != nil {
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +264,10 @@ func renderCommand(args []string, stdout, stderr io.Writer) int {
|
|||||||
func serveCommand(args []string, stderr io.Writer) int {
|
func serveCommand(args []string, stderr io.Writer) int {
|
||||||
cfg, err := parseServeArgs(args)
|
cfg, err := parseServeArgs(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "serve parse error: %v\n", err)
|
writeApplicationError(stderr, "serve parse", err)
|
||||||
|
return ExitRuntimeError
|
||||||
|
}
|
||||||
|
if err := writeApplicationWarnings(stderr, cfg.diagnostics); err != nil {
|
||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,8 +336,6 @@ func parseRunArgs(args []string) (*runConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +365,7 @@ func parseRenderArgs(args []string) (*renderConfig, error) {
|
|||||||
|
|
||||||
registerConfigPathFlag(fs, &cfg.configPath)
|
registerConfigPathFlag(fs, &cfg.configPath)
|
||||||
registerExecutionRequestFlags(fs, &cfg.runConfig)
|
registerExecutionRequestFlags(fs, &cfg.runConfig)
|
||||||
|
fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas")
|
||||||
|
|
||||||
var rawFormat string
|
var rawFormat string
|
||||||
fs.StringVar(&rawFormat, "format", "", "render output format (text|json)")
|
fs.StringVar(&rawFormat, "format", "", "render output format (text|json)")
|
||||||
@@ -399,16 +411,7 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
|
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{
|
settings, err := resolveCommonSettings(fs, cfg.configPath)
|
||||||
PromptDir: cfg.promptDirIfSet(fs),
|
|
||||||
ProfileDir: cfg.profileDirIfSet(fs),
|
|
||||||
SchemaDir: cfg.schemaDirIfSet(fs),
|
|
||||||
ServerAddr: cfg.addrIfSet(fs),
|
|
||||||
ArtifactRoot: cfg.artifactRootIfSet(fs),
|
|
||||||
MaxRequestBytes: cfg.maxRequestBytesIfSet(fs),
|
|
||||||
MaxArtifactBytes: cfg.maxArtifactBytesIfSet(fs),
|
|
||||||
MaxResponseBytes: cfg.maxResponseBytesIfSet(fs),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -423,19 +426,12 @@ func parseServeArgs(args []string) (*serveConfig, error) {
|
|||||||
cfg.maxResponseBytes = settings.maxResponseBytes
|
cfg.maxResponseBytes = settings.maxResponseBytes
|
||||||
cfg.backends = settings.backends
|
cfg.backends = settings.backends
|
||||||
cfg.transientRetries = settings.transientRetries
|
cfg.transientRetries = settings.transientRetries
|
||||||
|
cfg.diagnostics = settings.diagnostics
|
||||||
|
|
||||||
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
|
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg.promptDir = filepath.Clean(cfg.promptDir)
|
|
||||||
if strings.TrimSpace(cfg.profileDir) != "" {
|
|
||||||
cfg.profileDir = filepath.Clean(cfg.profileDir)
|
|
||||||
}
|
|
||||||
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
|
|
||||||
if strings.TrimSpace(cfg.artifactRoot) != "" {
|
|
||||||
cfg.artifactRoot = filepath.Clean(cfg.artifactRoot)
|
|
||||||
}
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,11 +463,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
|
|||||||
return fmt.Errorf("unexpected positional args: %v", fs.Args())
|
return fmt.Errorf("unexpected positional args: %v", fs.Args())
|
||||||
}
|
}
|
||||||
|
|
||||||
settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{
|
settings, err := resolveCommonSettings(fs, cfg.configPath)
|
||||||
PromptDir: cfg.promptDirIfSet(fs),
|
|
||||||
ProfileDir: cfg.profileDirIfSet(fs),
|
|
||||||
SchemaDir: cfg.schemaDirIfSet(fs),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -482,6 +474,7 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
|
|||||||
cfg.defaultRenderFormat = settings.defaultRenderFormat
|
cfg.defaultRenderFormat = settings.defaultRenderFormat
|
||||||
cfg.backends = settings.backends
|
cfg.backends = settings.backends
|
||||||
cfg.configuredTransientRetries = settings.transientRetries
|
cfg.configuredTransientRetries = settings.transientRetries
|
||||||
|
cfg.diagnostics = settings.diagnostics
|
||||||
|
|
||||||
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
|
if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -489,10 +482,6 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
|
|||||||
if strings.TrimSpace(cfg.promptID) == "" {
|
if strings.TrimSpace(cfg.promptID) == "" {
|
||||||
return errors.New("--prompt is required")
|
return errors.New("--prompt is required")
|
||||||
}
|
}
|
||||||
cfg.promptDir = filepath.Clean(cfg.promptDir)
|
|
||||||
if strings.TrimSpace(cfg.profileDir) != "" {
|
|
||||||
cfg.profileDir = filepath.Clean(cfg.profileDir)
|
|
||||||
}
|
|
||||||
if cfg.outputPath != "" {
|
if cfg.outputPath != "" {
|
||||||
cfg.outputPath = filepath.Clean(cfg.outputPath)
|
cfg.outputPath = filepath.Clean(cfg.outputPath)
|
||||||
}
|
}
|
||||||
@@ -511,83 +500,6 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *runConfig) promptDirIfSet(fs *flag.FlagSet) string {
|
|
||||||
if flagWasSet(fs, "prompt-dir") {
|
|
||||||
return c.promptDir
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *runConfig) profileDirIfSet(fs *flag.FlagSet) string {
|
|
||||||
if flagWasSet(fs, "profile-dir") {
|
|
||||||
return c.profileDir
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *runConfig) schemaDirIfSet(fs *flag.FlagSet) string {
|
|
||||||
if flagWasSet(fs, "schema-dir") {
|
|
||||||
return c.schemaDir
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *serveConfig) promptDirIfSet(fs *flag.FlagSet) string {
|
|
||||||
if flagWasSet(fs, "prompt-dir") {
|
|
||||||
return c.promptDir
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *serveConfig) profileDirIfSet(fs *flag.FlagSet) string {
|
|
||||||
if flagWasSet(fs, "profile-dir") {
|
|
||||||
return c.profileDir
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *serveConfig) schemaDirIfSet(fs *flag.FlagSet) string {
|
|
||||||
if flagWasSet(fs, "schema-dir") {
|
|
||||||
return c.schemaDir
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *serveConfig) addrIfSet(fs *flag.FlagSet) string {
|
|
||||||
if flagWasSet(fs, "addr") {
|
|
||||||
return c.addr
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *serveConfig) artifactRootIfSet(fs *flag.FlagSet) string {
|
|
||||||
if flagWasSet(fs, "artifact-root") {
|
|
||||||
return c.artifactRoot
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *serveConfig) maxRequestBytesIfSet(fs *flag.FlagSet) *int64 {
|
|
||||||
if flagWasSet(fs, "max-request-bytes") {
|
|
||||||
return &c.maxRequestBytes
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *serveConfig) maxArtifactBytesIfSet(fs *flag.FlagSet) *int64 {
|
|
||||||
if flagWasSet(fs, "max-artifact-bytes") {
|
|
||||||
return &c.maxArtifactBytes
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *serveConfig) maxResponseBytesIfSet(fs *flag.FlagSet) *int64 {
|
|
||||||
if flagWasSet(fs, "max-response-bytes") {
|
|
||||||
return &c.maxResponseBytes
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
|
func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
|
||||||
fs.StringVar(
|
fs.StringVar(
|
||||||
target,
|
target,
|
||||||
@@ -601,26 +513,8 @@ func registerConfigPathFlag(fs *flag.FlagSet, target *string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveAppSettings(fs *flag.FlagSet, configPath string, overrides appconfig.CLIOverrides) (appconfig.AppSettings, error) {
|
func resolveCommonSettings(fs *flag.FlagSet, configPath string) (commonCommandSettings, error) {
|
||||||
return resolveAppSettingsWithConfigPresence(configPath, flagWasSet(fs, "config"), overrides)
|
settings, diagnostics, err := loadApplicationSettings(configPath, flagWasSet(fs, "config"), applicationOverrides(fs))
|
||||||
}
|
|
||||||
|
|
||||||
func resolveAppSettingsWithConfigPresence(configPath string, configExplicit bool, overrides appconfig.CLIOverrides) (appconfig.AppSettings, error) {
|
|
||||||
settings, err := appconfig.LoadConfig(configPath, configExplicit)
|
|
||||||
if err != nil {
|
|
||||||
return appconfig.AppSettings{}, fmt.Errorf("application config error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
merged, err := appconfig.ApplyCLIOverrides(settings, overrides)
|
|
||||||
if err != nil {
|
|
||||||
return appconfig.AppSettings{}, fmt.Errorf("application config error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveCommonSettings(fs *flag.FlagSet, configPath string, overrides appconfig.CLIOverrides) (commonCommandSettings, error) {
|
|
||||||
settings, err := resolveAppSettings(fs, configPath, overrides)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return commonCommandSettings{}, err
|
return commonCommandSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -636,6 +530,7 @@ func resolveCommonSettings(fs *flag.FlagSet, configPath string, overrides appcon
|
|||||||
defaultRenderFormat: settings.DefaultRenderFormat,
|
defaultRenderFormat: settings.DefaultRenderFormat,
|
||||||
backends: settings.Backends,
|
backends: settings.Backends,
|
||||||
transientRetries: settings.TransientRetries,
|
transientRetries: settings.TransientRetries,
|
||||||
|
diagnostics: diagnostics,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -18,8 +17,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
"gitea.maximumdirect.net/eric/promptkit"
|
"gitea.maximumdirect.net/eric/promptkit"
|
||||||
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
||||||
)
|
)
|
||||||
@@ -65,7 +64,7 @@ func TestParseMappingsMalformed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseRunArgsRequiredFlags(t *testing.T) {
|
func TestParseRunArgsRequiredFlags(t *testing.T) {
|
||||||
configPath := writeAppConfigFile(t, "")
|
configPath := writeAppConfigFile(t, "{}\n")
|
||||||
|
|
||||||
_, err := parseRunArgs([]string{"--config", configPath, "--profile-dir", "./profiles", "--prompt", "p", "--input", "a=b"})
|
_, err := parseRunArgs([]string{"--config", configPath, "--profile-dir", "./profiles", "--prompt", "p", "--input", "a=b"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -161,7 +160,7 @@ func TestParseRunArgsRejectsRawLLMAPIKeyFlag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestParseServeArgsRequiredFlags(t *testing.T) {
|
func TestParseServeArgsRequiredFlags(t *testing.T) {
|
||||||
configPath := writeAppConfigFile(t, "")
|
configPath := writeAppConfigFile(t, "{}\n")
|
||||||
|
|
||||||
_, err := parseServeArgs([]string{"--config", configPath, "--profile-dir", "./profiles"})
|
_, err := parseServeArgs([]string{"--config", configPath, "--profile-dir", "./profiles"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -364,22 +363,9 @@ func TestParseRunArgsInvalidExplicitConfigReturnsError(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected invalid explicit config error")
|
t.Fatal("expected invalid explicit config error")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "application config error") {
|
var loadError *configkit.LoadError
|
||||||
t.Fatalf("expected application config context, got %v", err)
|
if !errors.As(err, &loadError) {
|
||||||
}
|
t.Fatalf("expected structured application config error, got %v", err)
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveAppSettingsMissingImplicitConfigDoesNotError(t *testing.T) {
|
|
||||||
fs := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
||||||
settings, err := resolveAppSettings(fs, filepath.Join(t.TempDir(), "missing.yml"), appconfig.CLIOverrides{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if settings.SchemaDir != defaults.SchemaDirDefault {
|
|
||||||
t.Fatalf("expected built-in schema dir, got %q", settings.SchemaDir)
|
|
||||||
}
|
|
||||||
if settings.ServerAddr != defaults.HTTPAddrDefault {
|
|
||||||
t.Fatalf("expected built-in server addr, got %q", settings.ServerAddr)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +399,85 @@ schema_dir: ./from-config/schemas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplicationFlagsPreserveExplicitEmptyValues(t *testing.T) {
|
||||||
|
configPath := writeAppConfigFile(t, `prompt_dir: ./configured/prompts
|
||||||
|
profile_dir: ./configured/profiles
|
||||||
|
schema_dir: ./configured/schemas
|
||||||
|
server:
|
||||||
|
addr: ":9090"
|
||||||
|
artifact_root: ./configured/artifacts
|
||||||
|
`)
|
||||||
|
if _, err := parseRunArgs([]string{"--config", configPath, "--prompt", "p", "--prompt-dir="}); err == nil || !strings.Contains(err.Error(), "prompt directory is required") {
|
||||||
|
t.Fatalf("explicit empty prompt directory restored lower value: %v", err)
|
||||||
|
}
|
||||||
|
render, err := parseRenderArgs([]string{"--config", configPath, "--prompt", "p", "--profile-dir=", "--schema-dir="})
|
||||||
|
if err != nil || render.profileDir != "" || render.schemaDir != "" {
|
||||||
|
t.Fatalf("render did not preserve explicit empty sources: cfg=%+v err=%v", render, err)
|
||||||
|
}
|
||||||
|
serve, err := parseServeArgs([]string{"--config", configPath, "--addr=", "--artifact-root="})
|
||||||
|
if err != nil || serve.addr != "" || serve.artifactRoot != "" {
|
||||||
|
t.Fatalf("serve did not preserve explicit empty settings: cfg=%+v err=%v", serve, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt, err := parsePromptInspectionArgs([]string{"--config", configPath, "--prompt", "p", "--prompt-dir="})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := resolveAppSettingsForPromptInspection(prompt); err == nil || !strings.Contains(err.Error(), "prompt directory is required") {
|
||||||
|
t.Fatalf("prompt inspection restored lower directory: %v", err)
|
||||||
|
}
|
||||||
|
profile, err := parseProfileInspectionArgs([]string{"--config", configPath, "--profile", "p", "--profile-dir="})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
settings, err := resolveAppSettingsForProfileInspection(profile)
|
||||||
|
if err != nil || settings.profileDir != "" {
|
||||||
|
t.Fatalf("profile inspection required a source or restored lower value: settings=%+v err=%v", settings, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplicationLoadUsesNativeYAMLAndSafeDiagnostics(t *testing.T) {
|
||||||
|
for _, body := range []string{
|
||||||
|
"",
|
||||||
|
"server: null\n",
|
||||||
|
"server:\n addr: null\n",
|
||||||
|
"server:\n unknown: 1\n",
|
||||||
|
"prompt_dir: &alias ./prompts\n",
|
||||||
|
"prompt_dir: ./prompts\n---\nprofile_dir: ./profiles\n",
|
||||||
|
} {
|
||||||
|
path := writeAppConfigFile(t, body)
|
||||||
|
_, err := parseRunArgs([]string{"--config", path, "--prompt-dir", "./prompts", "--prompt", "p"})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("native YAML failure %q lost structured diagnostics: %v", body, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejected = "sensitive-synthetic-marker"
|
||||||
|
path := writeAppConfigFile(t, "defaults:\n render_format: "+rejected+"\n")
|
||||||
|
code, stdout, stderr := runCLICommand(t, renderCommand, []string{"--config", path, "--prompt-dir", "./prompts", "--prompt", "p"})
|
||||||
|
if code != ExitRuntimeError || stdout != "" || !strings.Contains(stderr, "validate.error") || strings.Contains(stderr, rejected) {
|
||||||
|
t.Fatalf("unsafe or misclassified load diagnostic: code=%d stdout=%q stderr=%q", code, stdout, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingDiagnosticWriter struct{}
|
||||||
|
|
||||||
|
func (failingDiagnosticWriter) Write([]byte) (int, error) {
|
||||||
|
return 0, errors.New("diagnostic sink unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplicationDiagnosticWriterFailure(t *testing.T) {
|
||||||
|
diagnostics := []configkit.Diagnostic{{Code: "test.error", Message: "safe diagnostic"}}
|
||||||
|
if err := writeApplicationWarnings(failingDiagnosticWriter{}, diagnostics); err == nil {
|
||||||
|
t.Fatal("expected diagnostic writer failure")
|
||||||
|
}
|
||||||
|
path := writeAppConfigFile(t, "unknown_field: secret-value\n")
|
||||||
|
if code := runCommand([]string{"--config", path, "--prompt", "sample"}, io.Discard, failingDiagnosticWriter{}); code != ExitRuntimeError {
|
||||||
|
t.Fatalf("diagnostic writer failure must retain runtime error exit status, got %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseRenderArgsWithExplicitConfigLoadsDirectoriesAndFormat(t *testing.T) {
|
func TestParseRenderArgsWithExplicitConfigLoadsDirectoriesAndFormat(t *testing.T) {
|
||||||
configPath := writeAppConfigFile(t, `
|
configPath := writeAppConfigFile(t, `
|
||||||
prompt_dir: ./from-config/prompts
|
prompt_dir: ./from-config/prompts
|
||||||
@@ -1533,7 +1598,7 @@ func TestExecutionReportPathValidationAndPreInvocationFailure(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if code != ExitRuntimeError || stdout != "" || !strings.Contains(stderr, "run parse error") || string(data) != "old report" {
|
if code != ExitRuntimeError || stdout != "" || !strings.Contains(stderr, "source.missing") || string(data) != "old report" {
|
||||||
t.Fatalf("pre-invocation failure changed report: code=%d stdout=%q stderr=%q report=%q", code, stdout, stderr, data)
|
t.Fatalf("pre-invocation failure changed report: code=%d stdout=%q stderr=%q report=%q", code, stdout, stderr, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
|
||||||
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
DefaultConfigPath = "/etc/scriptorium/config.yml"
|
|
||||||
DefaultConfigPathLocal = "/usr/local/etc/scriptorium/config.yml"
|
|
||||||
)
|
|
||||||
|
|
||||||
var defaultConfigSearchPaths = []string{
|
|
||||||
DefaultConfigPathLocal,
|
|
||||||
DefaultConfigPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrConfigNotFound = errors.New("config file not found")
|
|
||||||
ErrInvalidConfigYAML = errors.New("invalid config YAML")
|
|
||||||
ErrInvalidConfig = errors.New("invalid config")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config is the on-disk YAML shape for application-level settings.
|
|
||||||
type Config struct {
|
|
||||||
PromptDir string `yaml:"prompt_dir"`
|
|
||||||
ProfileDir string `yaml:"profile_dir"`
|
|
||||||
SchemaDir string `yaml:"schema_dir"`
|
|
||||||
TransientRetries *strictInteger `yaml:"transient_retries"`
|
|
||||||
Server ServerConfig `yaml:"server"`
|
|
||||||
Defaults DefaultsConfig `yaml:"defaults"`
|
|
||||||
Backends map[string]BackendConfig `yaml:"backends"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackendConfig struct {
|
|
||||||
Endpoint string `yaml:"endpoint"`
|
|
||||||
APIKeyEnv string `yaml:"api_key_env"`
|
|
||||||
ExtraParams map[string]any `yaml:"extra_params"`
|
|
||||||
ConcurrencyLimit int `yaml:"concurrency_limit"`
|
|
||||||
QueueCapacity *int `yaml:"queue_capacity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type strictInteger int
|
|
||||||
|
|
||||||
func (value *strictInteger) UnmarshalYAML(node *yaml.Node) error {
|
|
||||||
if node.Tag != "!!int" {
|
|
||||||
return fmt.Errorf("expected integer, got %s", node.Tag)
|
|
||||||
}
|
|
||||||
var decoded int
|
|
||||||
if err := node.Decode(&decoded); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*value = strictInteger(decoded)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type BackendSettings struct {
|
|
||||||
ID string
|
|
||||||
Endpoint string
|
|
||||||
APIKeyEnv string
|
|
||||||
ExtraParams map[string]any
|
|
||||||
ConcurrencyLimit int
|
|
||||||
QueueCapacity *int
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerConfig struct {
|
|
||||||
Addr string `yaml:"addr"`
|
|
||||||
ArtifactRoot string `yaml:"artifact_root"`
|
|
||||||
MaxRequestBytes *int64 `yaml:"max_request_bytes"`
|
|
||||||
MaxArtifactBytes *int64 `yaml:"max_artifact_bytes"`
|
|
||||||
MaxResponseBytes *int64 `yaml:"max_response_bytes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type DefaultsConfig struct {
|
|
||||||
RenderFormat string `yaml:"render_format"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppSettings is the resolved application settings used by adapters.
|
|
||||||
type AppSettings struct {
|
|
||||||
PromptDir string
|
|
||||||
ProfileDir string
|
|
||||||
SchemaDir string
|
|
||||||
ServerAddr string
|
|
||||||
ArtifactRoot string
|
|
||||||
MaxRequestBytes int64
|
|
||||||
MaxArtifactBytes int64
|
|
||||||
MaxResponseBytes int64
|
|
||||||
DefaultRenderFormat renderformat.PreparedRunOutputFormat
|
|
||||||
Backends []BackendSettings
|
|
||||||
TransientRetries *int
|
|
||||||
}
|
|
||||||
|
|
||||||
// CLIOverrides can be applied after config load to enforce precedence.
|
|
||||||
type CLIOverrides struct {
|
|
||||||
PromptDir string
|
|
||||||
ProfileDir string
|
|
||||||
SchemaDir string
|
|
||||||
ServerAddr string
|
|
||||||
ArtifactRoot string
|
|
||||||
MaxRequestBytes *int64
|
|
||||||
MaxArtifactBytes *int64
|
|
||||||
MaxResponseBytes *int64
|
|
||||||
RenderFormat string
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuiltInDefaults returns compile-time application defaults.
|
|
||||||
func BuiltInDefaults() AppSettings {
|
|
||||||
return AppSettings{
|
|
||||||
SchemaDir: defaults.SchemaDirDefault,
|
|
||||||
ServerAddr: defaults.HTTPAddrDefault,
|
|
||||||
MaxRequestBytes: defaults.HTTPMaxRequestBytesDefault,
|
|
||||||
MaxArtifactBytes: defaults.HTTPMaxArtifactBytesDefault,
|
|
||||||
MaxResponseBytes: defaults.HTTPMaxResponseBytesDefault,
|
|
||||||
DefaultRenderFormat: renderformat.DefaultPreparedRunOutputFormat,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadConfig loads and resolves app settings from YAML.
|
|
||||||
//
|
|
||||||
// If explicit is false and the file does not exist, built-in defaults are returned.
|
|
||||||
// If explicit is true, missing file is an error.
|
|
||||||
func LoadConfig(path string, explicit bool) (AppSettings, error) {
|
|
||||||
resolved := BuiltInDefaults()
|
|
||||||
configPath := strings.TrimSpace(path)
|
|
||||||
if configPath == "" {
|
|
||||||
if explicit {
|
|
||||||
configPath = DefaultConfigPath
|
|
||||||
} else {
|
|
||||||
for _, candidate := range defaultConfigSearchPaths {
|
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
|
||||||
configPath = candidate
|
|
||||||
break
|
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return AppSettings{}, fmt.Errorf("failed to stat config file %q: %w", candidate, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if configPath == "" {
|
|
||||||
if explicit {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: %s", ErrConfigNotFound, DefaultConfigPath)
|
|
||||||
}
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
configPath = filepath.Clean(configPath)
|
|
||||||
|
|
||||||
raw, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
if explicit {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: %s", ErrConfigNotFound, configPath)
|
|
||||||
}
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
return AppSettings{}, fmt.Errorf("failed to read config file %q: %w", configPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := decodeConfig(raw)
|
|
||||||
if err != nil {
|
|
||||||
return AppSettings{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved, err = applyConfig(resolved, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return AppSettings{}, err
|
|
||||||
}
|
|
||||||
return resolved, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyCLIOverrides applies CLI-provided overrides over resolved config settings.
|
|
||||||
func ApplyCLIOverrides(base AppSettings, overrides CLIOverrides) (AppSettings, error) {
|
|
||||||
out := base
|
|
||||||
|
|
||||||
if v := strings.TrimSpace(overrides.PromptDir); v != "" {
|
|
||||||
out.PromptDir = filepath.Clean(v)
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(overrides.ProfileDir); v != "" {
|
|
||||||
out.ProfileDir = filepath.Clean(v)
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(overrides.SchemaDir); v != "" {
|
|
||||||
out.SchemaDir = filepath.Clean(v)
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(overrides.ServerAddr); v != "" {
|
|
||||||
out.ServerAddr = v
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(overrides.ArtifactRoot); v != "" {
|
|
||||||
out.ArtifactRoot = filepath.Clean(v)
|
|
||||||
}
|
|
||||||
if overrides.MaxRequestBytes != nil {
|
|
||||||
if *overrides.MaxRequestBytes < 0 {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: server.max_request_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
out.MaxRequestBytes = *overrides.MaxRequestBytes
|
|
||||||
}
|
|
||||||
if overrides.MaxArtifactBytes != nil {
|
|
||||||
if *overrides.MaxArtifactBytes < 0 {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: server.max_artifact_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
out.MaxArtifactBytes = *overrides.MaxArtifactBytes
|
|
||||||
}
|
|
||||||
if overrides.MaxResponseBytes != nil {
|
|
||||||
if *overrides.MaxResponseBytes < 0 {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: server.max_response_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
out.MaxResponseBytes = *overrides.MaxResponseBytes
|
|
||||||
}
|
|
||||||
if rawFormat := strings.TrimSpace(overrides.RenderFormat); rawFormat != "" {
|
|
||||||
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
|
||||||
if err != nil {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: defaults.render_format: %v", ErrInvalidConfig, err)
|
|
||||||
}
|
|
||||||
out.DefaultRenderFormat = parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeConfig(raw []byte) (Config, error) {
|
|
||||||
var cfg Config
|
|
||||||
decoder := yaml.NewDecoder(bytes.NewReader(raw))
|
|
||||||
decoder.KnownFields(true)
|
|
||||||
if err := decoder.Decode(&cfg); err != nil {
|
|
||||||
if errors.Is(err, io.EOF) {
|
|
||||||
return Config{}, nil
|
|
||||||
}
|
|
||||||
return Config{}, fmt.Errorf("%w: %v", ErrInvalidConfigYAML, err)
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyConfig(base AppSettings, cfg Config) (AppSettings, error) {
|
|
||||||
out := base
|
|
||||||
if cfg.TransientRetries != nil {
|
|
||||||
if *cfg.TransientRetries < 0 {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: transient_retries must be greater than or equal to 0", ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
value := int(*cfg.TransientRetries)
|
|
||||||
out.TransientRetries = &value
|
|
||||||
}
|
|
||||||
|
|
||||||
if v := strings.TrimSpace(cfg.PromptDir); v != "" {
|
|
||||||
out.PromptDir = filepath.Clean(v)
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(cfg.ProfileDir); v != "" {
|
|
||||||
out.ProfileDir = filepath.Clean(v)
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(cfg.SchemaDir); v != "" {
|
|
||||||
out.SchemaDir = filepath.Clean(v)
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(cfg.Server.Addr); v != "" {
|
|
||||||
out.ServerAddr = v
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(cfg.Server.ArtifactRoot); v != "" {
|
|
||||||
out.ArtifactRoot = filepath.Clean(v)
|
|
||||||
}
|
|
||||||
if cfg.Server.MaxRequestBytes != nil {
|
|
||||||
if *cfg.Server.MaxRequestBytes < 0 {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: server.max_request_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
out.MaxRequestBytes = *cfg.Server.MaxRequestBytes
|
|
||||||
}
|
|
||||||
if cfg.Server.MaxArtifactBytes != nil {
|
|
||||||
if *cfg.Server.MaxArtifactBytes < 0 {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: server.max_artifact_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
out.MaxArtifactBytes = *cfg.Server.MaxArtifactBytes
|
|
||||||
}
|
|
||||||
if cfg.Server.MaxResponseBytes != nil {
|
|
||||||
if *cfg.Server.MaxResponseBytes < 0 {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: server.max_response_bytes must be greater than or equal to 0", ErrInvalidConfig)
|
|
||||||
}
|
|
||||||
out.MaxResponseBytes = *cfg.Server.MaxResponseBytes
|
|
||||||
}
|
|
||||||
if rawFormat := strings.TrimSpace(cfg.Defaults.RenderFormat); rawFormat != "" {
|
|
||||||
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
|
|
||||||
if err != nil {
|
|
||||||
return AppSettings{}, fmt.Errorf("%w: defaults.render_format: %v", ErrInvalidConfig, err)
|
|
||||||
}
|
|
||||||
out.DefaultRenderFormat = parsed
|
|
||||||
}
|
|
||||||
if len(cfg.Backends) > 0 {
|
|
||||||
ids := make([]string, 0, len(cfg.Backends))
|
|
||||||
for id := range cfg.Backends {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
sort.Strings(ids)
|
|
||||||
out.Backends = make([]BackendSettings, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
backend := cfg.Backends[id]
|
|
||||||
out.Backends = append(out.Backends, BackendSettings{
|
|
||||||
ID: id,
|
|
||||||
Endpoint: backend.Endpoint,
|
|
||||||
APIKeyEnv: backend.APIKeyEnv,
|
|
||||||
ExtraParams: backend.ExtraParams,
|
|
||||||
ConcurrencyLimit: backend.ConcurrencyLimit,
|
|
||||||
QueueCapacity: backend.QueueCapacity,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
@@ -1,461 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
|
||||||
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoadConfigMissingImplicitPathUsesBuiltInDefaults(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
missing := filepath.Join(tmp, "missing.yml")
|
|
||||||
|
|
||||||
got, err := LoadConfig(missing, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := BuiltInDefaults()
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("unexpected settings: got=%+v want=%+v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuiltInDefaultsIncludeHTTPSizeLimits(t *testing.T) {
|
|
||||||
got := BuiltInDefaults()
|
|
||||||
|
|
||||||
if got.MaxRequestBytes != defaults.HTTPMaxRequestBytesDefault {
|
|
||||||
t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes)
|
|
||||||
}
|
|
||||||
if got.MaxArtifactBytes != defaults.HTTPMaxArtifactBytesDefault {
|
|
||||||
t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes)
|
|
||||||
}
|
|
||||||
if got.MaxResponseBytes != defaults.HTTPMaxResponseBytesDefault {
|
|
||||||
t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigMissingExplicitPathReturnsError(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
missing := filepath.Join(tmp, "missing.yml")
|
|
||||||
|
|
||||||
_, err := LoadConfig(missing, true)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error for missing explicit config")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrConfigNotFound) {
|
|
||||||
t.Fatalf("expected ErrConfigNotFound, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigInvalidYAMLReturnsError(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", "prompt_dir: [")
|
|
||||||
|
|
||||||
_, err := LoadConfig(path, true)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected invalid YAML error")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrInvalidConfigYAML) {
|
|
||||||
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigInvalidYAMLImplicitPathReturnsError(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", "prompt_dir: [")
|
|
||||||
|
|
||||||
_, err := LoadConfig(path, false)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected invalid YAML error")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrInvalidConfigYAML) {
|
|
||||||
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigUnknownFieldReturnsError(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", "unknown_field: true\n")
|
|
||||||
|
|
||||||
_, err := LoadConfig(path, true)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected unknown field error")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrInvalidConfigYAML) {
|
|
||||||
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigAPIKeyFieldIsRejectedAsUnknown(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", "api_key: secret\n")
|
|
||||||
|
|
||||||
_, err := LoadConfig(path, true)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected unknown field error for api_key")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrInvalidConfigYAML) {
|
|
||||||
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigBackendsRetainsResolvedSettingsInSortedOrder(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", `
|
|
||||||
backends:
|
|
||||||
zebra:
|
|
||||||
endpoint: https://zebra.example/v1
|
|
||||||
api_key_env: ZEBRA_API_KEY
|
|
||||||
extra_params:
|
|
||||||
provider_option: enabled
|
|
||||||
nested:
|
|
||||||
enabled: true
|
|
||||||
attempts: 2
|
|
||||||
concurrency_limit: 2
|
|
||||||
alpha:
|
|
||||||
endpoint: http://alpha.example/v1
|
|
||||||
queue_capacity: 0
|
|
||||||
`)
|
|
||||||
|
|
||||||
got, err := LoadConfig(path, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(got.Backends) != 2 {
|
|
||||||
t.Fatalf("expected two backends, got %#v", got.Backends)
|
|
||||||
}
|
|
||||||
if got.Backends[0].ID != "alpha" || got.Backends[1].ID != "zebra" {
|
|
||||||
t.Fatalf("expected sorted backend IDs, got %#v", got.Backends)
|
|
||||||
}
|
|
||||||
if got.Backends[0].QueueCapacity == nil || *got.Backends[0].QueueCapacity != 0 {
|
|
||||||
t.Fatalf("expected explicit zero queue capacity, got %#v", got.Backends[0].QueueCapacity)
|
|
||||||
}
|
|
||||||
if got.Backends[1].QueueCapacity != nil {
|
|
||||||
t.Fatalf("expected omitted queue capacity to remain nil, got %#v", got.Backends[1].QueueCapacity)
|
|
||||||
}
|
|
||||||
wantParams := map[string]any{
|
|
||||||
"provider_option": "enabled",
|
|
||||||
"nested": map[string]any{
|
|
||||||
"enabled": true,
|
|
||||||
"attempts": 2,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got.Backends[1].ExtraParams, wantParams) {
|
|
||||||
t.Fatalf("unexpected extra params: got=%#v want=%#v", got.Backends[1].ExtraParams, wantParams)
|
|
||||||
}
|
|
||||||
if got.Backends[1].Endpoint != "https://zebra.example/v1" || got.Backends[1].APIKeyEnv != "ZEBRA_API_KEY" || got.Backends[1].ConcurrencyLimit != 2 {
|
|
||||||
t.Fatalf("unexpected zebra backend: %#v", got.Backends[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigRejectsUnknownOrSecretBackendFields(t *testing.T) {
|
|
||||||
for name, body := range map[string]string{
|
|
||||||
"unknown": "backends:\n local:\n endpoint: http://localhost:11434/v1\n unexpected: value\n",
|
|
||||||
"secret": "backends:\n local:\n endpoint: http://localhost:11434/v1\n api_key: secret\n",
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", body)
|
|
||||||
_, err := LoadConfig(path, true)
|
|
||||||
if !errors.Is(err, ErrInvalidConfigYAML) {
|
|
||||||
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigRejectsInvalidBackendFieldTypes(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", "backends:\n local:\n endpoint: http://localhost:11434/v1\n queue_capacity: not-a-number\n")
|
|
||||||
|
|
||||||
_, err := LoadConfig(path, true)
|
|
||||||
if !errors.Is(err, ErrInvalidConfigYAML) {
|
|
||||||
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigValidConfigSetsDirectoriesAndServerAddr(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", `
|
|
||||||
prompt_dir: ./prompts
|
|
||||||
profile_dir: ./profiles
|
|
||||||
schema_dir: ./schemas
|
|
||||||
server:
|
|
||||||
addr: 127.0.0.1:9090
|
|
||||||
artifact_root: ./artifacts
|
|
||||||
max_request_bytes: 1024
|
|
||||||
max_artifact_bytes: 2048
|
|
||||||
max_response_bytes: 4096
|
|
||||||
defaults:
|
|
||||||
render_format: json
|
|
||||||
`)
|
|
||||||
|
|
||||||
got, err := LoadConfig(path, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.PromptDir != filepath.Clean("./prompts") {
|
|
||||||
t.Fatalf("unexpected prompt_dir: %q", got.PromptDir)
|
|
||||||
}
|
|
||||||
if got.ProfileDir != filepath.Clean("./profiles") {
|
|
||||||
t.Fatalf("unexpected profile_dir: %q", got.ProfileDir)
|
|
||||||
}
|
|
||||||
if got.SchemaDir != filepath.Clean("./schemas") {
|
|
||||||
t.Fatalf("unexpected schema_dir: %q", got.SchemaDir)
|
|
||||||
}
|
|
||||||
if got.ServerAddr != "127.0.0.1:9090" {
|
|
||||||
t.Fatalf("unexpected server.addr: %q", got.ServerAddr)
|
|
||||||
}
|
|
||||||
if got.ArtifactRoot != filepath.Clean("./artifacts") {
|
|
||||||
t.Fatalf("unexpected server.artifact_root: %q", got.ArtifactRoot)
|
|
||||||
}
|
|
||||||
if got.MaxRequestBytes != 1024 {
|
|
||||||
t.Fatalf("unexpected server.max_request_bytes: %d", got.MaxRequestBytes)
|
|
||||||
}
|
|
||||||
if got.MaxArtifactBytes != 2048 {
|
|
||||||
t.Fatalf("unexpected server.max_artifact_bytes: %d", got.MaxArtifactBytes)
|
|
||||||
}
|
|
||||||
if got.MaxResponseBytes != 4096 {
|
|
||||||
t.Fatalf("unexpected server.max_response_bytes: %d", got.MaxResponseBytes)
|
|
||||||
}
|
|
||||||
if got.DefaultRenderFormat != renderformat.PreparedRunFormatJSON {
|
|
||||||
t.Fatalf("unexpected defaults.render_format: %q", got.DefaultRenderFormat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigAcceptsZeroHTTPSizeLimits(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", `
|
|
||||||
server:
|
|
||||||
max_request_bytes: 0
|
|
||||||
max_artifact_bytes: 0
|
|
||||||
max_response_bytes: 0
|
|
||||||
`)
|
|
||||||
|
|
||||||
got, err := LoadConfig(path, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if got.MaxRequestBytes != 0 || got.MaxArtifactBytes != 0 || got.MaxResponseBytes != 0 {
|
|
||||||
t.Fatalf("expected zero limits to be preserved, got request=%d artifact=%d response=%d", got.MaxRequestBytes, got.MaxArtifactBytes, got.MaxResponseBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigPreservesTransientRetryPresence(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
want *int
|
|
||||||
}{
|
|
||||||
{name: "omitted", body: "prompt_dir: ./prompts\n"},
|
|
||||||
{name: "null", body: "transient_retries: null\n"},
|
|
||||||
{name: "zero", body: "transient_retries: 0\n", want: intPointer(0)},
|
|
||||||
{name: "positive", body: "transient_retries: 3\n", want: intPointer(3)},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
settings, err := LoadConfig(writeConfigFile(t, "config.yml", tc.body), true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(settings.TransientRetries, tc.want) {
|
|
||||||
t.Fatalf("retry setting: got %v, want %v", settings.TransientRetries, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for _, tc := range []struct {
|
|
||||||
body string
|
|
||||||
want error
|
|
||||||
}{
|
|
||||||
{body: "transient_retries: -1\n", want: ErrInvalidConfig},
|
|
||||||
{body: "transient_retries: wrong\n", want: ErrInvalidConfigYAML},
|
|
||||||
{body: "transient_retries: 1.5\n", want: ErrInvalidConfigYAML},
|
|
||||||
} {
|
|
||||||
_, err := LoadConfig(writeConfigFile(t, "config.yml", tc.body), true)
|
|
||||||
if !errors.Is(err, tc.want) {
|
|
||||||
t.Fatalf("body %q: expected %v, got %v", tc.body, tc.want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func intPointer(value int) *int { return &value }
|
|
||||||
|
|
||||||
func TestLoadConfigRejectsNegativeHTTPSizeLimits(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
body string
|
|
||||||
}{
|
|
||||||
{name: "request", body: "server:\n max_request_bytes: -1\n"},
|
|
||||||
{name: "artifact", body: "server:\n max_artifact_bytes: -1\n"},
|
|
||||||
{name: "response", body: "server:\n max_response_bytes: -1\n"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", tc.body)
|
|
||||||
_, err := LoadConfig(path, true)
|
|
||||||
if !errors.Is(err, ErrInvalidConfig) {
|
|
||||||
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigEmptyFileResolvesToBuiltInDefaults(t *testing.T) {
|
|
||||||
path := writeConfigFile(t, "config.yml", "")
|
|
||||||
|
|
||||||
got, err := LoadConfig(path, true)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
want := BuiltInDefaults()
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("unexpected settings: got=%+v want=%+v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigImplicitSearchPrefersUsrLocalEtcOverEtc(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
localPath := filepath.Join(tmp, "usr-local.yml")
|
|
||||||
etcPath := filepath.Join(tmp, "etc.yml")
|
|
||||||
|
|
||||||
if err := os.WriteFile(localPath, []byte("prompt_dir: ./from-usr-local\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(etcPath, []byte("prompt_dir: ./from-etc\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
orig := defaultConfigSearchPaths
|
|
||||||
defaultConfigSearchPaths = []string{localPath, etcPath}
|
|
||||||
t.Cleanup(func() { defaultConfigSearchPaths = orig })
|
|
||||||
|
|
||||||
got, err := LoadConfig("", false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if got.PromptDir != filepath.Clean("./from-usr-local") {
|
|
||||||
t.Fatalf("expected usr-local config to win, got prompt_dir=%q", got.PromptDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadConfigImplicitSearchFallsBackToEtcWhenUsrLocalMissing(t *testing.T) {
|
|
||||||
tmp := t.TempDir()
|
|
||||||
missingLocal := filepath.Join(tmp, "missing-local.yml")
|
|
||||||
etcPath := filepath.Join(tmp, "etc.yml")
|
|
||||||
|
|
||||||
if err := os.WriteFile(etcPath, []byte("prompt_dir: ./from-etc\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
orig := defaultConfigSearchPaths
|
|
||||||
defaultConfigSearchPaths = []string{missingLocal, etcPath}
|
|
||||||
t.Cleanup(func() { defaultConfigSearchPaths = orig })
|
|
||||||
|
|
||||||
got, err := LoadConfig("", false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if got.PromptDir != filepath.Clean("./from-etc") {
|
|
||||||
t.Fatalf("expected etc fallback config, got prompt_dir=%q", got.PromptDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) {
|
|
||||||
base := AppSettings{
|
|
||||||
PromptDir: "/from/config/prompts",
|
|
||||||
ProfileDir: "/from/config/profiles",
|
|
||||||
SchemaDir: "/from/config/schemas",
|
|
||||||
ServerAddr: ":1234",
|
|
||||||
ArtifactRoot: "/from/config/artifacts",
|
|
||||||
MaxRequestBytes: 111,
|
|
||||||
MaxArtifactBytes: 222,
|
|
||||||
MaxResponseBytes: 333,
|
|
||||||
DefaultRenderFormat: renderformat.PreparedRunFormatJSON,
|
|
||||||
}
|
|
||||||
maxRequestBytes := int64(0)
|
|
||||||
maxArtifactBytes := int64(444)
|
|
||||||
maxResponseBytes := int64(555)
|
|
||||||
|
|
||||||
got, err := ApplyCLIOverrides(base, CLIOverrides{
|
|
||||||
PromptDir: "./prompts-cli",
|
|
||||||
ProfileDir: "./profiles-cli",
|
|
||||||
SchemaDir: "./schemas-cli",
|
|
||||||
ServerAddr: ":8081",
|
|
||||||
ArtifactRoot: "./artifacts-cli",
|
|
||||||
MaxRequestBytes: &maxRequestBytes,
|
|
||||||
MaxArtifactBytes: &maxArtifactBytes,
|
|
||||||
MaxResponseBytes: &maxResponseBytes,
|
|
||||||
RenderFormat: "text",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.PromptDir != filepath.Clean("./prompts-cli") {
|
|
||||||
t.Fatalf("unexpected prompt dir: %q", got.PromptDir)
|
|
||||||
}
|
|
||||||
if got.ProfileDir != filepath.Clean("./profiles-cli") {
|
|
||||||
t.Fatalf("unexpected profile dir: %q", got.ProfileDir)
|
|
||||||
}
|
|
||||||
if got.SchemaDir != filepath.Clean("./schemas-cli") {
|
|
||||||
t.Fatalf("unexpected schema dir: %q", got.SchemaDir)
|
|
||||||
}
|
|
||||||
if got.ServerAddr != ":8081" {
|
|
||||||
t.Fatalf("unexpected server addr: %q", got.ServerAddr)
|
|
||||||
}
|
|
||||||
if got.ArtifactRoot != filepath.Clean("./artifacts-cli") {
|
|
||||||
t.Fatalf("unexpected artifact root: %q", got.ArtifactRoot)
|
|
||||||
}
|
|
||||||
if got.MaxRequestBytes != 0 {
|
|
||||||
t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes)
|
|
||||||
}
|
|
||||||
if got.MaxArtifactBytes != 444 {
|
|
||||||
t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes)
|
|
||||||
}
|
|
||||||
if got.MaxResponseBytes != 555 {
|
|
||||||
t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes)
|
|
||||||
}
|
|
||||||
if got.DefaultRenderFormat != renderformat.PreparedRunFormatText {
|
|
||||||
t.Fatalf("unexpected render format: %q", got.DefaultRenderFormat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyCLIOverridesRejectsNegativeHTTPSizeLimits(t *testing.T) {
|
|
||||||
negative := int64(-1)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
overrides CLIOverrides
|
|
||||||
}{
|
|
||||||
{name: "request", overrides: CLIOverrides{MaxRequestBytes: &negative}},
|
|
||||||
{name: "artifact", overrides: CLIOverrides{MaxArtifactBytes: &negative}},
|
|
||||||
{name: "response", overrides: CLIOverrides{MaxResponseBytes: &negative}},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
_, err := ApplyCLIOverrides(BuiltInDefaults(), tc.overrides)
|
|
||||||
if !errors.Is(err, ErrInvalidConfig) {
|
|
||||||
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyCLIOverridesInvalidRenderFormatReturnsError(t *testing.T) {
|
|
||||||
_, err := ApplyCLIOverrides(BuiltInDefaults(), CLIOverrides{RenderFormat: "yaml"})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected invalid render format error")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, ErrInvalidConfig) {
|
|
||||||
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeConfigFile(t *testing.T, name, content string) string {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join(t.TempDir(), name)
|
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
||||||
t.Fatalf("failed to write config file: %v", err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@ type LoadOptions struct {
|
|||||||
Overrides []configkit.Override
|
Overrides []configkit.Override
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load resolves one ConfigKit document without changing the executable's legacy loader.
|
// Load resolves one ConfigKit document and retains its inspection snapshot.
|
||||||
func Load(options LoadOptions) (*configkit.Result[Document], error) {
|
func Load(options LoadOptions) (*configkit.Result[Document], error) {
|
||||||
var source configkit.Source
|
var source configkit.Source
|
||||||
if options.Explicit {
|
if options.Explicit {
|
||||||
|
|||||||
37
internal/config/settings.go
Normal file
37
internal/config/settings.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultConfigPath = "/etc/scriptorium/config.yml"
|
||||||
|
DefaultConfigPathLocal = "/usr/local/etc/scriptorium/config.yml"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultConfigSearchPaths = []string{
|
||||||
|
DefaultConfigPathLocal,
|
||||||
|
DefaultConfigPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppSettings holds the validated application values used by executable adapters.
|
||||||
|
type AppSettings struct {
|
||||||
|
PromptDir string
|
||||||
|
ProfileDir string
|
||||||
|
SchemaDir string
|
||||||
|
ServerAddr string
|
||||||
|
ArtifactRoot string
|
||||||
|
MaxRequestBytes int64
|
||||||
|
MaxArtifactBytes int64
|
||||||
|
MaxResponseBytes int64
|
||||||
|
DefaultRenderFormat renderformat.PreparedRunOutputFormat
|
||||||
|
Backends []BackendSettings
|
||||||
|
TransientRetries *int
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackendSettings struct {
|
||||||
|
ID string
|
||||||
|
Endpoint string
|
||||||
|
APIKeyEnv string
|
||||||
|
ExtraParams map[string]any
|
||||||
|
ConcurrencyLimit int
|
||||||
|
QueueCapacity *int
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user