Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed4623bc41 | |||
| 3ce198197d | |||
| 0ac8072a9a | |||
| 3ec54b8c72 | |||
| 47b3e5b55b | |||
| 99d1a0b926 | |||
| 884a62e8a6 | |||
| f7a40afc7a | |||
| a4f37ff427 | |||
| 2b97ef3722 | |||
| 63a86c43f4 | |||
| 92432232ef | |||
| d31ba5a410 | |||
| f0225e937c | |||
| 9e40c729e1 | |||
| 0e6ba414b3 | |||
| 10a2e89965 | |||
| f8e3e651e6 | |||
| 1558c9ccae | |||
| b68435128b | |||
| 5fb454a714 | |||
| 79cf9f1139 | |||
| 9bb203476d | |||
| 0726786016 | |||
| 204caf9d24 | |||
| 85663f5bc9 | |||
| f51b9bb046 | |||
| dd905c4daa | |||
| c3307ab5fb | |||
| 1484f51f48 | |||
| 51d21676c1 | |||
| c2806a1615 | |||
| 078f661e3a | |||
| adc60aa588 | |||
| 8d9eb5cc40 | |||
| 563acce012 | |||
| 49c359feb8 | |||
| 38202d4472 | |||
| 6d07dafc9f | |||
| 6c66e18c4b | |||
| 4048996652 | |||
| 470450e509 | |||
| 7e78c22fec | |||
| 610eaefb27 | |||
| 4bc33773fa | |||
| b74e31e1cc | |||
| 2f1e4407db | |||
| 4ccb4a73ca | |||
| 145baac393 | |||
| be59bff690 | |||
| 14b4fee214 | |||
| 7f84fc5d1e | |||
| a8b47f6185 | |||
| 5b62a8d60d | |||
| 0d4d71b9dc | |||
| 979d8049b1 | |||
| abd405015b | |||
| fddf78dc80 | |||
| 3136c26d4e | |||
| 4b84b4223d | |||
| 7a0e2cb11b | |||
| 74ab9218ff | |||
| da99857fd0 | |||
| a520cfdcc8 | |||
| 7febccf550 | |||
| 74977b4064 | |||
| 172e2d6a4f | |||
| 5c520dc0d4 | |||
| 6d18a3126e | |||
| 1e222a4db3 | |||
| 6a15052d77 | |||
| 2e69c14898 | |||
| dbda2a0161 | |||
| 2ee64f2eb1 | |||
| 8afe9427ca | |||
| e0f19833a1 | |||
| ca1b694aa9 | |||
| 9331373245 | |||
| ec072dc163 | |||
| f7ce1dcd56 | |||
| df275874e3 | |||
| 1da34ab5cd | |||
| c0fbc14f2d | |||
| 2d20900cbf | |||
| 7997f81f47 | |||
| 6d748d65d3 | |||
| 49324d3f35 | |||
| c98b4ef4ef | |||
| c8bae3a4a3 | |||
| ccbd7ee820 | |||
| e9c4873030 | |||
| 57719b7d81 | |||
| 62b1f9b0ae | |||
| 6c7a45d8d0 | |||
| 82d75e7beb | |||
| 61095e69ce | |||
| 2cfca710ec | |||
| 9679117e94 | |||
| d67a0389e0 | |||
| 9f84446509 | |||
| ec9b0e919d | |||
| 9bb7c1f4e3 | |||
| 3c092fbe34 | |||
| 3a7e2e5175 | |||
| 45b27ad562 | |||
| 1e418d6929 | |||
| 39b2665d8b | |||
| ba858cc997 | |||
| 0d57422b61 | |||
| b7d989f61f | |||
| 6813340f51 | |||
| eb788f17b8 | |||
| ebd3f415d3 | |||
| f15d8d8ce0 | |||
| 6c7bfd5e31 | |||
| 7c782a2603 | |||
| f1eef5b5e9 | |||
| f14583321d | |||
| 04283e99d4 | |||
| 7284228bec | |||
| da74d7ad18 | |||
| af5fbc1c4f | |||
| 5e4d2016e8 | |||
| 545aa6893b | |||
| 98139f7e8b | |||
| f8fa0a2623 | |||
| 9af773491b | |||
| 2a656f0f11 | |||
| aec35a2d9b | |||
| 23dc4e2078 | |||
| c812fe3655 | |||
| 3da97ca50c | |||
| 4c203d8588 | |||
| fcb5f825e1 | |||
| 4c57ace2f6 | |||
| dde7f76ecb | |||
| a102db36af | |||
| b5b1d22011 | |||
| c91599ef36 | |||
| 257f10c9fb | |||
| fb9a4d14f4 | |||
| c4435b76c4 | |||
| 61000a9466 | |||
| 7e4ceb3d48 | |||
| 4e991fa21d | |||
| f86b17045d | |||
| f302488075 | |||
| 8c1171478d | |||
| 7ee637803d | |||
| 4d6086fefb | |||
| 82cb53e107 | |||
| b97b12da7f | |||
| 49ea747b17 | |||
| 3b5e9db41f |
@@ -2,74 +2,26 @@ when:
|
|||||||
- event: tag
|
- event: tag
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
validate:
|
validate-release:
|
||||||
image: golang:1.25
|
image: golang:1.26.7
|
||||||
commands:
|
commands:
|
||||||
- go test ./...
|
- ./scripts/check-release-candidate.sh "$CI_COMMIT_TAG"
|
||||||
- go test -race ./...
|
|
||||||
- go vet ./...
|
|
||||||
- go build ./...
|
|
||||||
- go test ./internal/doccheck
|
|
||||||
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
|
||||||
|
|
||||||
cross-build:
|
|
||||||
image: golang:1.25
|
|
||||||
depends_on: validate
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
set -eu
|
|
||||||
output_dir="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$output_dir"' EXIT
|
|
||||||
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
|
|
||||||
goos="${target%/*}"
|
|
||||||
goarch="${target#*/}"
|
|
||||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio
|
|
||||||
done
|
|
||||||
|
|
||||||
build-release-assets:
|
build-release-assets:
|
||||||
image: golang:1.25
|
image: golang:1.26.7
|
||||||
depends_on: [validate, cross-build]
|
depends_on:
|
||||||
|
- validate-release
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
set -eu
|
set -eu
|
||||||
|
case "$PWD" in
|
||||||
version="$CI_COMMIT_TAG"
|
/*) ;;
|
||||||
dist="dist"
|
*) echo "release workspace must have an absolute path" >&2; exit 1 ;;
|
||||||
pkg="gitea.maximumdirect.net/eric/narratio/cmd/narratio"
|
esac
|
||||||
|
./scripts/build-release-assets.sh "$CI_COMMIT_TAG" "$PWD/dist"
|
||||||
rm -rf "$dist"
|
|
||||||
mkdir -p "$dist"
|
|
||||||
|
|
||||||
build_binary() {
|
|
||||||
goos="$1"
|
|
||||||
goarch="$2"
|
|
||||||
suffix="$3"
|
|
||||||
output="$dist/narratio-$version-$goos-$goarch$suffix"
|
|
||||||
|
|
||||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
|
|
||||||
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=$version" \
|
|
||||||
-o "$output" "$pkg"
|
|
||||||
}
|
|
||||||
|
|
||||||
build_binary linux amd64 ""
|
|
||||||
build_binary linux arm64 ""
|
|
||||||
build_binary darwin amd64 ""
|
|
||||||
build_binary darwin arm64 ""
|
|
||||||
build_binary windows amd64 ".exe"
|
|
||||||
build_binary windows arm64 ".exe"
|
|
||||||
|
|
||||||
smoke_binary="$dist/narratio-version-smoke"
|
|
||||||
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=$version" \
|
|
||||||
-o "$smoke_binary" "$pkg"
|
|
||||||
reported_version="$("$smoke_binary" version)"
|
|
||||||
rm -f "$smoke_binary"
|
|
||||||
if [ "$reported_version" != "narratio $version" ]; then
|
|
||||||
echo "release binary reported unexpected version: $reported_version" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
image: woodpeckerci/plugin-release
|
image: woodpeckerci/plugin-release:0.3.1
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-release-assets
|
- build-release-assets
|
||||||
settings:
|
settings:
|
||||||
@@ -77,6 +29,8 @@ steps:
|
|||||||
from_secret: GITEA_RELEASE_TOKEN
|
from_secret: GITEA_RELEASE_TOKEN
|
||||||
files:
|
files:
|
||||||
- dist/narratio-*
|
- dist/narratio-*
|
||||||
|
title: Narratio ${CI_COMMIT_TAG}
|
||||||
|
note: docs/releases/${CI_COMMIT_TAG}.md
|
||||||
checksum: sha256
|
checksum: sha256
|
||||||
checksum-file: SHA256SUMS
|
checksum-file: SHA256SUMS
|
||||||
checksum-flatten: true
|
checksum-flatten: true
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
shuffled-race-tests:
|
shuffled-race-tests:
|
||||||
image: golang:1.25
|
image: golang:1.26
|
||||||
commands:
|
commands:
|
||||||
- go test -race -shuffle=on -count=3 ./...
|
- go test -race -shuffle=on -count=3 ./...
|
||||||
|
|||||||
@@ -3,44 +3,44 @@ when:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
tests:
|
tests:
|
||||||
image: golang:1.25
|
image: golang:1.26
|
||||||
commands:
|
commands:
|
||||||
- go test ./...
|
- go test ./...
|
||||||
|
|
||||||
race-tests:
|
race-tests:
|
||||||
image: golang:1.25
|
image: golang:1.26
|
||||||
depends_on: tests
|
depends_on: tests
|
||||||
commands:
|
commands:
|
||||||
- go test -race ./...
|
- go test -race ./...
|
||||||
|
|
||||||
static-analysis:
|
static-analysis:
|
||||||
image: golang:1.25
|
image: golang:1.26
|
||||||
depends_on: tests
|
depends_on: tests
|
||||||
commands:
|
commands:
|
||||||
- go vet ./...
|
- go vet ./...
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: golang:1.25
|
image: golang:1.26
|
||||||
depends_on: tests
|
depends_on: tests
|
||||||
commands:
|
commands:
|
||||||
- go build ./...
|
- go build ./...
|
||||||
|
|
||||||
documentation-and-examples:
|
documentation-and-examples:
|
||||||
image: golang:1.25
|
image: golang:1.26
|
||||||
depends_on: tests
|
depends_on: tests
|
||||||
commands:
|
commands:
|
||||||
- go test ./internal/doccheck
|
- go test ./internal/doccheck
|
||||||
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||||
|
|
||||||
cross-build:
|
cross-build:
|
||||||
image: golang:1.25
|
image: golang:1.26
|
||||||
depends_on: [race-tests, static-analysis, build, documentation-and-examples]
|
depends_on: [race-tests, static-analysis, build, documentation-and-examples]
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
set -eu
|
set -eu
|
||||||
output_dir="$(mktemp -d)"
|
output_dir="$(mktemp -d)"
|
||||||
trap 'rm -rf "$output_dir"' EXIT
|
trap 'rm -rf "$output_dir"' EXIT
|
||||||
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
|
for target in linux/amd64 linux/arm64 darwin/arm64; do
|
||||||
goos="${target%/*}"
|
goos="${target%/*}"
|
||||||
goarch="${target#*/}"
|
goarch="${target#*/}"
|
||||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio
|
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ This requires resolvable `pipeline.yml`, `campaign.yml`, and concrete
|
|||||||
- [Integration contracts](docs/integrations/) — external tools, formats, and
|
- [Integration contracts](docs/integrations/) — external tools, formats, and
|
||||||
compatibility expectations.
|
compatibility expectations.
|
||||||
- [Maintained examples](examples/README.md) — complete copyable configuration
|
- [Maintained examples](examples/README.md) — complete copyable configuration
|
||||||
and input files.
|
and input files, including the production/testing split bundle.
|
||||||
|
|
||||||
## Maintainer Documentation
|
## Maintainer Documentation
|
||||||
|
|
||||||
|
|||||||
128
docs/cli.md
128
docs/cli.md
@@ -20,6 +20,7 @@ Top-level commands:
|
|||||||
- `publish <session_id>`: force-run publish.
|
- `publish <session_id>`: force-run publish.
|
||||||
- `clean <session_id>` or `clean --all`: remove local work/spool state.
|
- `clean <session_id>` or `clean --all`: remove local work/spool state.
|
||||||
- `session <subcommand>`: session helper commands.
|
- `session <subcommand>`: session helper commands.
|
||||||
|
- `config <subcommand>`: validate, display, source-trace, catalog, or compare resolved pipeline configuration.
|
||||||
|
|
||||||
Session subcommands:
|
Session subcommands:
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ Most session-aware commands accept:
|
|||||||
- `--session <session.yml>`
|
- `--session <session.yml>`
|
||||||
- `--session-id <session_id>`
|
- `--session-id <session_id>`
|
||||||
- `--previous-session-id <session_id>`
|
- `--previous-session-id <session_id>`
|
||||||
|
- `--profile <name>`
|
||||||
|
- `--secrets-dir <directory>`
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@@ -51,6 +54,13 @@ Rules:
|
|||||||
- if both positional `<session_id>` and `--session-id` are provided, values must match.
|
- if both positional `<session_id>` and `--session-id` are provided, values must match.
|
||||||
- `--previous-session-id` is a strict expectation: the selected session file
|
- `--previous-session-id` is a strict expectation: the selected session file
|
||||||
must contain the same `previous_session_id`.
|
must contain the same `previous_session_id`.
|
||||||
|
- `--profile` selects `profiles/<name>.yml` relative to the pipeline root. It
|
||||||
|
may be supplied once; omitted, empty, and the exact name `default` select the
|
||||||
|
base configuration, while an unknown named profile fails. The same selection
|
||||||
|
applies to all common-flag commands, including `regenerate-artifacts`.
|
||||||
|
- `--secrets-dir` selects ConfigKit credential-file bootstrap explicitly. A
|
||||||
|
relative value uses caller CWD. Without the flag, a present `secrets/`
|
||||||
|
directory beside the resolved root pipeline is used; an absent one is skipped.
|
||||||
- `clean --all` cannot be combined with campaign/session selectors.
|
- `clean --all` cannot be combined with campaign/session selectors.
|
||||||
- notification delivery is currently limited to the configured `noop` mode; see
|
- notification delivery is currently limited to the configured `noop` mode; see
|
||||||
the [configuration reference](./config.md#notifications).
|
the [configuration reference](./config.md#notifications).
|
||||||
@@ -72,6 +82,76 @@ Commands with additional positionals keep their command-specific order:
|
|||||||
|
|
||||||
## Command Reference
|
## Command Reference
|
||||||
|
|
||||||
|
### `config validate`, `config show`, `config sources`, `config artifacts`, and `config diff`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||||
|
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||||
|
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||||
|
narratio config artifacts [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||||
|
narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>]
|
||||||
|
```
|
||||||
|
|
||||||
|
These commands resolve the selected profile, defaults, ordinary paths, and—if
|
||||||
|
a campaign is selected—the campaign-owned party. They neither discover or load
|
||||||
|
a session nor create a workspace, manifest, run, lock, adapter, or remote
|
||||||
|
connection. Like every pipeline-loading command, they bootstrap the selected or
|
||||||
|
conventional secrets directory before resolving configuration; `config show`
|
||||||
|
never includes bootstrap reports or secret values in its YAML output.
|
||||||
|
|
||||||
|
Campaign selection is optional for a pipeline without party-driven artifact
|
||||||
|
families. A pipeline with `scriptorium.artifact_families` needs a selected or
|
||||||
|
configured default campaign so Narratio can expand its concrete artifacts and
|
||||||
|
publish rules. `--campaign` and `--campaign-file` remain mutually exclusive.
|
||||||
|
Session, range, force, and artifact-execution flags are not accepted.
|
||||||
|
|
||||||
|
`config validate` writes a concise root-path, selected-profile (or `none`), and
|
||||||
|
effective-digest summary after successful complete validation. `config show`
|
||||||
|
writes one deterministic, secret-free YAML document containing defaulted and
|
||||||
|
expanded concrete configuration. It omits ConfigKit directives, artifact
|
||||||
|
family declarations, and runtime provenance.
|
||||||
|
|
||||||
|
`config sources` reports the same fully validated resolution without printing
|
||||||
|
effective values. Its header identifies the root, ordered imports, selected
|
||||||
|
profile and overlay, selected campaign, party source, and digest. The
|
||||||
|
remaining tab-separated records are sorted as `path`, `role`, and `source`.
|
||||||
|
Roles distinguish root, import, profile, centralized default, campaign, party,
|
||||||
|
and generated family ownership. A generated party member has
|
||||||
|
one family record and one party record at the same logical path. The output
|
||||||
|
never reads or prints secret values.
|
||||||
|
|
||||||
|
`config artifacts` prints the concrete catalog used by analysis, ordered by
|
||||||
|
artifact key. Its tab-separated header is `key`, `source`, `definitions`,
|
||||||
|
`enabled`, `output`, `dependencies`, `family`, `character`, `family_source`, and
|
||||||
|
`party_source`. Definitions and effective dependencies are sorted comma-separated
|
||||||
|
lists, and control characters in fields are escaped. Family-member rows identify
|
||||||
|
their family, character, defining family file, and party file. An empty catalog
|
||||||
|
prints only the header. Ordinary artifacts can be inspected without a campaign;
|
||||||
|
party-driven families retain the campaign requirement. See
|
||||||
|
[Scriptorium Artifact Entries](./config.md#scriptorium-artifact-entries) for the
|
||||||
|
configuration layout.
|
||||||
|
|
||||||
|
`config diff` resolves both supplied profile names independently and compares
|
||||||
|
their fully resolved, secret-free effective mappings. It does
|
||||||
|
not accept `--profile`; the two positional names must be distinct, declared
|
||||||
|
profiles. When party-driven families are present, both profiles must resolve to
|
||||||
|
the same selected campaign and party. Use `--campaign-file` if profile-specific
|
||||||
|
campaign configuration would otherwise select different files.
|
||||||
|
|
||||||
|
Equal profiles print `no differences`. Otherwise, sorted tab-separated records
|
||||||
|
use one of these forms, with compact JSON values:
|
||||||
|
|
||||||
|
```text
|
||||||
|
added <path> <right-value>
|
||||||
|
removed <path> <left-value>
|
||||||
|
changed <path> <left-value> <right-value>
|
||||||
|
```
|
||||||
|
|
||||||
|
Mappings are flattened to their logical field paths; lists remain one atomic
|
||||||
|
value. The command compares defaulted concrete artifacts and publish rules, not
|
||||||
|
profile names, source-file layout, or formatting. It succeeds when differences
|
||||||
|
are found, making it suitable for review and migration checks.
|
||||||
|
|
||||||
### `version`
|
### `version`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -91,7 +171,7 @@ Behavior:
|
|||||||
|
|
||||||
- evaluates one inclusive contiguous range of the canonical stage order;
|
- evaluates one inclusive contiguous range of the canonical stage order;
|
||||||
- defaults an omitted `--from` to `prepare` and an omitted `--through` to
|
- defaults an omitted `--from` to `prepare` and an omitted `--through` to
|
||||||
`notify`, so omitting both retains full-pipeline behavior;
|
`cleanup`, so omitting both retains full-pipeline behavior;
|
||||||
- rejects unknown endpoints and a `--from` endpoint after `--through`;
|
- rejects unknown endpoints and a `--from` endpoint after `--through`;
|
||||||
- runs `render` before `extract`; an omitted or disabled Notarius
|
- runs `render` before `extract`; an omitted or disabled Notarius
|
||||||
configuration records an explicit `notarius_disabled` self-skip;
|
configuration records an explicit `notarius_disabled` self-skip;
|
||||||
@@ -101,7 +181,10 @@ Behavior:
|
|||||||
- rejects repeated `--from`, `--through`, or `--force` options, including
|
- rejects repeated `--from`, `--through`, or `--force` options, including
|
||||||
`--name=value` spellings;
|
`--name=value` spellings;
|
||||||
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- writes session and run manifests.
|
- writes the session manifest as the sole durable progress ledger. Each
|
||||||
|
invocation also creates its run-local directory and returns an immediate
|
||||||
|
command summary; neither creates another durable progress record.
|
||||||
|
- reports the resolved profile (or `none`) and effective configuration digest.
|
||||||
|
|
||||||
When `--artifacts` is present, the selected range must contain `analyze` or
|
When `--artifacts` is present, the selected range must contain `analyze` or
|
||||||
`publish`. Either consumer is sufficient, including a one-stage range.
|
`publish`. Either consumer is sufficient, including a one-stage range.
|
||||||
@@ -148,6 +231,7 @@ Valid stage names:
|
|||||||
- `analyze`
|
- `analyze`
|
||||||
- `publish`
|
- `publish`
|
||||||
- `notify`
|
- `notify`
|
||||||
|
- `cleanup`
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@@ -177,6 +261,10 @@ Equivalent to:
|
|||||||
narratio run-stage publish <session_id> --force [...common config flags]
|
narratio run-stage publish <session_id> --force [...common config flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This command selects only `publish`; it does not invoke cleanup. Use a normal
|
||||||
|
full run to include configured cleanup, or `run-stage cleanup` to retry cleanup
|
||||||
|
from valid publication evidence.
|
||||||
|
|
||||||
### `clean`
|
### `clean`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -190,8 +278,8 @@ Behavior:
|
|||||||
- `--all` removes all local session work and spool state;
|
- `--all` removes all local session work and spool state;
|
||||||
- cache remains unless `--clear-cache` is provided.
|
- cache remains unless `--clear-cache` is provided.
|
||||||
|
|
||||||
See [Operations: Cleanup](./operations.md#cleanup) for deletion scope and
|
See [Operations: Cleanup](./operations.md#cleanup) for manual and pipeline-stage
|
||||||
post-publish cleanup behavior.
|
deletion scope.
|
||||||
|
|
||||||
### `session plan`
|
### `session plan`
|
||||||
|
|
||||||
@@ -203,6 +291,8 @@ Uses the same inclusive bounds, endpoint validation, force scope, and artifact
|
|||||||
selection contract as `run`. It validates config and prints run/skip decisions
|
selection contract as `run`. It validates config and prints run/skip decisions
|
||||||
for selected stages only without creating the local workdir or changing the
|
for selected stages only without creating the local workdir or changing the
|
||||||
manifest. Resume-capable selected stages are checked against durable evidence.
|
manifest. Resume-capable selected stages are checked against durable evidence.
|
||||||
|
The output includes the resolved profile (or `none`) and effective configuration
|
||||||
|
digest without writing provenance or any manifest state.
|
||||||
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
|
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
|
||||||
execution order, and reusable current artifacts with concise reasons. These
|
execution order, and reusable current artifacts with concise reasons. These
|
||||||
artifact decisions come from the same reconciliation and work planner used by
|
artifact decisions come from the same reconciliation and work planner used by
|
||||||
@@ -214,7 +304,8 @@ execution; the preview does not predict output identities.
|
|||||||
narratio session validate <session_id> [...common config flags]
|
narratio session validate <session_id> [...common config flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
Read-only preflight checks for config validity, required inputs, audio mode, previous-session requirements, publish outputs, and effective locks.
|
Read-only preflight checks for config validity, required inputs, audio mode,
|
||||||
|
previous-session requirements, publish outputs, and independent remote locks.
|
||||||
|
|
||||||
### `session status`
|
### `session status`
|
||||||
|
|
||||||
@@ -223,7 +314,12 @@ narratio session status <session_id> [...common config flags]
|
|||||||
```
|
```
|
||||||
|
|
||||||
Prints local manifest state and, when storage is available, status for the
|
Prints local manifest state and, when storage is available, status for the
|
||||||
pointer-selected remote commit and its declared published outputs.
|
pointer-selected remote commit and its declared published outputs. Current
|
||||||
|
publish metadata reports `published`, `carried_locked`, `locked_unpublished`,
|
||||||
|
`optional_missing`, and `unselected`; carried output lines include the run
|
||||||
|
derived from their exact declared key, the key itself, and its declared
|
||||||
|
destination. A separately displayed `dest` is the destination requested by the
|
||||||
|
current configuration and may differ while an older declaration is locked.
|
||||||
|
|
||||||
### `session init`
|
### `session init`
|
||||||
|
|
||||||
@@ -242,6 +338,7 @@ Options:
|
|||||||
|
|
||||||
- `--config <pipeline.yml>`
|
- `--config <pipeline.yml>`
|
||||||
- `--campaign <id>` or `--campaign-file <campaign.yml>`
|
- `--campaign <id>` or `--campaign-file <campaign.yml>`
|
||||||
|
- `--secrets-dir <directory>`
|
||||||
- `--previous-session-id <id>`
|
- `--previous-session-id <id>`
|
||||||
- `--date <YYYY-MM-DD>`
|
- `--date <YYYY-MM-DD>`
|
||||||
- `--title <text>`
|
- `--title <text>`
|
||||||
@@ -281,7 +378,8 @@ narratio session artifacts <session_id> [--remote] [...common config flags]
|
|||||||
Lists effective built-in, configured Scriptorium, and configured extraction
|
Lists effective built-in, configured Scriptorium, and configured extraction
|
||||||
sources; reports planned, available, unavailable, and published state without
|
sources; reports planned, available, unavailable, and published state without
|
||||||
reading payload bodies; and includes publish rules, lock state, and optional
|
reading payload bodies; and includes publish rules, lock state, and optional
|
||||||
remote published-state availability.
|
remote published-state availability. Remote inspection uses exact commit
|
||||||
|
declarations and displays the five current publish outcomes when recorded.
|
||||||
|
|
||||||
### `session locks`
|
### `session locks`
|
||||||
|
|
||||||
@@ -293,15 +391,23 @@ narratio session locks remove <session_id> <source> [...common config flags]
|
|||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- list mode reports the effective merge of static and remote locks;
|
- list mode validates and reports independent remote locks sorted by source;
|
||||||
- add/remove mutate only remote locks;
|
- add creates one lock and reports an existing lock unless `--force` is used;
|
||||||
- static locks from pipeline config cannot be removed by CLI commands.
|
- `--force` performs one generation-matched replacement and reports a
|
||||||
|
concurrent change for operator retry; and
|
||||||
|
- remove is idempotent and reports when the source was not locked.
|
||||||
|
|
||||||
See [Operations: Publish Locks](./operations.md#publish-locks) for lock storage
|
See [Operations: Publish Locks](./operations.md#publish-locks) for lock storage
|
||||||
and precedence.
|
and publication behavior.
|
||||||
|
|
||||||
## `--artifacts` Selection Rules
|
## `--artifacts` Selection Rules
|
||||||
|
|
||||||
|
An artifact-family key selects all of its concrete character members. A
|
||||||
|
concrete generated key selects only that member; mixed family and concrete
|
||||||
|
selection is deduplicated and executed as concrete keys. The resulting plan
|
||||||
|
and command output identify both the concrete key and, where applicable, its
|
||||||
|
family and character ID.
|
||||||
|
|
||||||
- accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`;
|
- accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`;
|
||||||
- repeatable and comma-separated values are combined, surrounding whitespace
|
- repeatable and comma-separated values are combined, surrounding whitespace
|
||||||
is removed, and duplicate names are collapsed;
|
is removed, and duplicate names are collapsed;
|
||||||
|
|||||||
240
docs/config.md
240
docs/config.md
@@ -42,6 +42,55 @@ The downloaded remote session file is command-scoped: Narratio removes it after
|
|||||||
the command finishes and records only the remote object provenance alongside
|
the command finishes and records only the remote object provenance alongside
|
||||||
the durable copied session input.
|
the durable copied session input.
|
||||||
|
|
||||||
|
### Read-only effective pipeline inspection
|
||||||
|
|
||||||
|
`narratio config validate`, `narratio config show`, `narratio config sources`,
|
||||||
|
and `narratio config artifacts` use the same `--config`, `--campaign`,
|
||||||
|
`--campaign-file`, and
|
||||||
|
`--profile` selection rules as pipeline commands, but do not select, discover,
|
||||||
|
or load a session. They resolve configured typed credential references as part
|
||||||
|
of the ordinary ConfigKit load but expose only their environment-variable names;
|
||||||
|
they do not create runtime state.
|
||||||
|
`narratio config diff <left-profile> <right-profile>` uses the same pipeline and
|
||||||
|
campaign selectors, resolves each named profile with an independent ConfigKit
|
||||||
|
load, and does not accept a separate `--profile` flag.
|
||||||
|
|
||||||
|
Campaign selection is optional only when the resolved pipeline has no
|
||||||
|
`scriptorium.artifact_families`. When families are declared, Narratio selects a
|
||||||
|
campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`,
|
||||||
|
then parses the campaign-owned party and expands concrete artifacts and any
|
||||||
|
family publish rules before validation. `config validate` prints the resulting
|
||||||
|
root, profile, and effective digest. `config show` emits the normalized
|
||||||
|
effective pipeline YAML, with defaults and concrete expansion included but
|
||||||
|
ConfigKit directives and family declarations omitted. This review projection is
|
||||||
|
not promised to be a reloadable authoring document. `config sources` prints a stable
|
||||||
|
source projection instead of effective values: root/import/profile/default
|
||||||
|
ownership plus campaign/party and generated-family records. Canonical derived
|
||||||
|
players trace to the party. `config artifacts` lists the concrete artifact map,
|
||||||
|
effective dependencies, and defining sources. The [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
|
||||||
|
owns command syntax and output conventions.
|
||||||
|
|
||||||
|
`config diff` compares normalized field values rather than YAML text or source
|
||||||
|
ownership. It emits sorted `added`, `removed`, and `changed` records, uses
|
||||||
|
compact deterministic JSON values, treats lists atomically, and reports `no
|
||||||
|
differences` when the complete effective configurations are equal. Concrete
|
||||||
|
family members and generated publish rules participate after expansion; moving
|
||||||
|
an equal value between eligible root/import sources does not create a
|
||||||
|
difference.
|
||||||
|
|
||||||
|
### Migrating to the maintained bundle
|
||||||
|
|
||||||
|
Use the [production/testing bundle](../examples/production-testing/pipeline.yml)
|
||||||
|
as the complete copyable migration reference. Keep production settings in the
|
||||||
|
base, split them into explicit additive imports, and place testing differences
|
||||||
|
in the fixed `profiles/testing.yml` overlay. Put ordinary artifacts and families
|
||||||
|
in the recommended one-definition files shown by that bundle. Convert campaign
|
||||||
|
rosters to [canonical party input](integrations/party.md), then express character
|
||||||
|
work as families. Inspect the result with `config validate`, `config show`,
|
||||||
|
`config sources`, and `config artifacts`; use `config diff default testing` to
|
||||||
|
review the overlay before running a session. Unversioned party documents are
|
||||||
|
rejected.
|
||||||
|
|
||||||
### Identity segments
|
### Identity segments
|
||||||
|
|
||||||
Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous
|
Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous
|
||||||
@@ -53,21 +102,95 @@ remote state with an unsafe legacy identity must be migrated before use.
|
|||||||
|
|
||||||
## Validation and Merge Rules
|
## Validation and Merge Rules
|
||||||
|
|
||||||
- YAML decode is strict (`KnownFields(true)`) and accepts exactly one document:
|
- ConfigKit performs strict typed YAML decoding and accepts exactly one document;
|
||||||
unknown fields or trailing documents fail load.
|
unknown fields, duplicate keys, and trailing documents fail load.
|
||||||
|
- A pipeline file may explicitly import additive YAML fragments through the
|
||||||
|
root-only `configkit.imports` list. Imported files contribute fields to one
|
||||||
|
logical pipeline document; they do not override fields supplied by the root
|
||||||
|
or another import.
|
||||||
|
- An omitted profile, an empty profile option, and the exact name `default`
|
||||||
|
select the base configuration. Any other name selects
|
||||||
|
`profiles/<name>.yml` relative to the pipeline root.
|
||||||
|
- Campaign, session, and canonical-party documents are strict single-source
|
||||||
|
ConfigKit schemas. They do not enable imports, profiles, or `configkit`
|
||||||
|
directives.
|
||||||
- Configured timeout and retry-delay durations must be positive. An omitted
|
- Configured timeout and retry-delay durations must be positive. An omitted
|
||||||
artifact timeout continues to inherit its configured Scriptorium timeout.
|
artifact timeout continues to inherit its configured Scriptorium timeout.
|
||||||
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
|
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
|
||||||
|
- Session and party inputs inspected before typed loading are limited to 8 MiB.
|
||||||
- Pipeline defaults are applied before validation.
|
- Pipeline defaults are applied before validation.
|
||||||
|
- Defaults apply only to absent fields. Explicit empty strings, zero values,
|
||||||
|
`false`, and `null` retain ConfigKit's typed meaning and are then checked by
|
||||||
|
Narratio domain validation; they are not silently replaced by defaults.
|
||||||
|
Explicit null optional stage sections stay absent, while required sections
|
||||||
|
fail validation.
|
||||||
- Campaign and session identities must agree.
|
- Campaign and session identities must agree.
|
||||||
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
||||||
`players_file`, `party_file`) and the optional `spell_catalog_file` resolve
|
`party_file`) are campaign-owned; the first three may be overridden by the
|
||||||
from session overrides when provided, otherwise from campaign defaults. An
|
session. The optional `spell_catalog_file` also inherits a campaign value
|
||||||
empty or omitted session spell-catalog value inherits the campaign value.
|
when omitted by the session.
|
||||||
|
- `party_file` is parsed when pipeline and campaign configuration are combined.
|
||||||
|
It must be a versioned [canonical party](integrations/party.md), is
|
||||||
|
campaign-owned and derives the players input internally. `players_file` and
|
||||||
|
session `party_file` are unsupported configuration keys.
|
||||||
- Exactly one audio mode must be configured in session input:
|
- Exactly one audio mode must be configured in session input:
|
||||||
- local (`audio_dir` or `audio_files`), or
|
- local (`audio_dir` or `audio_files`), or
|
||||||
- S3 (`audio_s3.prefix`).
|
- S3 (`audio_s3.prefix`).
|
||||||
|
|
||||||
|
### Pipeline imports and profiles
|
||||||
|
|
||||||
|
Large pipeline configurations may be split into explicitly named fragments and
|
||||||
|
may use fixed-path profile overlays:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: config/storage.yml
|
||||||
|
- path: config/integrations.yaml
|
||||||
|
|
||||||
|
campaigns:
|
||||||
|
root: /usr/local/share/narratio/campaigns
|
||||||
|
```
|
||||||
|
|
||||||
|
The maintained [production/testing bundle](../examples/production-testing/pipeline.yml)
|
||||||
|
is a complete copyable example of this structure, including canonical-party
|
||||||
|
artifact families.
|
||||||
|
|
||||||
|
Imports are resolved relative to the directory containing the root pipeline
|
||||||
|
file and are loaded in declaration order. Narratio does not scan directories or
|
||||||
|
infer fragments. Each import must be a confined regular `.yml` or `.yaml` file:
|
||||||
|
absolute paths, traversal, symlinks, directories, duplicate files, and an
|
||||||
|
import of the root pipeline itself are rejected. Imported files cannot declare
|
||||||
|
another `configkit` control section.
|
||||||
|
|
||||||
|
Base merging is additive. A map may be extended by multiple files when every
|
||||||
|
leaf is distinct, but a scalar, list, or map/list/scalar kind cannot be claimed
|
||||||
|
more than once, even when the repeated values are identical. Conflict errors
|
||||||
|
name the full field path and every source that claimed it. Narratio additionally
|
||||||
|
rejects an artifact or artifact-family key declared by more than one base file,
|
||||||
|
even when those files contribute disjoint children.
|
||||||
|
|
||||||
|
Profile names are case-sensitive and use ConfigKit's safe fixed-path naming
|
||||||
|
rules. Only the selected overlay is opened; a malformed unselected overlay does
|
||||||
|
not affect a base or different-profile load. Maps merge recursively, overlay
|
||||||
|
scalars replace base scalars, and overlay lists replace base lists completely.
|
||||||
|
Explicit `false`, zero, empty strings, empty containers, and null values remain
|
||||||
|
explicit inputs and are not refilled by defaults. Profiles cannot inherit,
|
||||||
|
stack, import files, or declare another ConfigKit control section.
|
||||||
|
|
||||||
|
During ConfigKit loading, Narratio applies fresh typed defaults, recorded
|
||||||
|
entry-level defaults and normalization, read-only file-local validation, and
|
||||||
|
ordinary path resolution before computing a deterministic effective
|
||||||
|
configuration digest. The digest represents the normalized, secret-free
|
||||||
|
runtime pipeline mapping; it excludes ConfigKit directives, source
|
||||||
|
provenance, profile identity, and raw environment secret values. Equivalent
|
||||||
|
effective mappings therefore have the same digest regardless of how fields are
|
||||||
|
split among the root and imports.
|
||||||
|
|
||||||
|
An imported field has the same meaning it would have in a monolithic root
|
||||||
|
pipeline. In particular, ordinary relative pipeline paths continue to resolve
|
||||||
|
from the root pipeline directory, not from the importing fragment's directory.
|
||||||
|
|
||||||
## Minimal Working Configuration
|
## Minimal Working Configuration
|
||||||
|
|
||||||
`pipeline.yml`
|
`pipeline.yml`
|
||||||
@@ -88,7 +211,6 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -103,15 +225,25 @@ inputs:
|
|||||||
## Secrets Handling
|
## Secrets Handling
|
||||||
|
|
||||||
- Do not place raw secrets in YAML.
|
- Do not place raw secrets in YAML.
|
||||||
- Use env var names in config (for example `pipeline.audita.llm_api_key_env`).
|
- Credential fields contain environment-variable names, represented internally
|
||||||
- Optionally load credential files from `pipeline.secrets.env_dir`. Each valid
|
as ConfigKit typed secret references (for example
|
||||||
environment-variable filename supplies one value; trailing CR/LF is removed.
|
`pipeline.audita.llm_api_key: AUDITA_LLM_API_KEY`). A configured reference
|
||||||
|
must resolve to a nonempty environment value during load.
|
||||||
|
- Every command that loads a pipeline accepts `--secrets-dir <path>`. When the
|
||||||
|
flag is omitted, Narratio uses a present `secrets/` directory beside the
|
||||||
|
resolved root `pipeline.yml`; an absent conventional directory is ignored.
|
||||||
|
Explicit relative paths are resolved from the caller's working directory,
|
||||||
|
and a missing or empty explicit selection is an error.
|
||||||
|
- Each immediate regular file whose name is a valid environment-variable name
|
||||||
|
supplies one value. ConfigKit removes trailing CR/LF, permits a symlink as the
|
||||||
|
selected directory, rejects symlink entries, and limits input to 1 MiB per
|
||||||
|
file, 8 MiB total, and 1024 entries.
|
||||||
- An existing process environment value takes precedence over a credential file.
|
- An existing process environment value takes precedence over a credential file.
|
||||||
- Credential directories and files must not be symlinks and must be regular,
|
- On POSIX, operators must provision the directory with no group/other access
|
||||||
bounded files (at most 8 KiB per value). On POSIX, provision the directory
|
(normally `0700`) and files with no group/other access (normally `0600`);
|
||||||
with no group/other access (normally `0700`) and files with no group/other
|
ConfigKit validates type and bounds but does not enforce permission modes.
|
||||||
access (normally `0600`).
|
- Bootstrap happens once per command, before any configuration load or adapter
|
||||||
- Commands that need storage/auth load filesystem secrets before constructing adapters.
|
construction. Safe reports contain filenames, paths, and statuses, never values.
|
||||||
|
|
||||||
## Publish Configuration Summary
|
## Publish Configuration Summary
|
||||||
|
|
||||||
@@ -134,9 +266,6 @@ publish:
|
|||||||
- source: narratio.artifact.session_recap
|
- source: narratio.artifact.session_recap
|
||||||
dest: artifacts/session_recap.md
|
dest: artifacts/session_recap.md
|
||||||
required: true
|
required: true
|
||||||
locks:
|
|
||||||
- source: narratio.artifact.session_recap
|
|
||||||
reason: manual post-publish edits
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
@@ -147,7 +276,8 @@ Rules:
|
|||||||
a rule names that source; the Notarius index and complete bundle are not
|
a rule names that source; the Notarius index and complete bundle are not
|
||||||
publish sources.
|
publish sources.
|
||||||
- `outputs[].required` defaults to `true`.
|
- `outputs[].required` defaults to `true`.
|
||||||
- static locks (`pipeline.publish.locks`) merge with remote locks (`{session_prefix}/locks.yml`), with static locks taking precedence on duplicates.
|
- publish locks are independent remote session objects managed with
|
||||||
|
`narratio session locks`; they are not pipeline configuration fields.
|
||||||
|
|
||||||
## Full Schema
|
## Full Schema
|
||||||
|
|
||||||
@@ -155,21 +285,21 @@ Rules:
|
|||||||
|
|
||||||
| Field | Type | Required | Default / Rule |
|
| Field | Type | Required | Default / Rule |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| `configkit.imports[].path` | string | No | explicit additive pipeline fragment relative to the root pipeline directory; `.yml` or `.yaml` regular files only |
|
||||||
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
|
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
|
||||||
| `pipeline.workspace.cleanup_after_publish` | bool | No | `false` |
|
| `pipeline.workspace.cleanup_after_publish` | bool | No | `false`; selects the published run's workspace target for final cleanup and requires effective `pipeline.publish.enabled=true` and `pipeline.publish.upload_run=true` |
|
||||||
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
|
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
|
||||||
| `pipeline.campaigns.default_campaign_id` | string | No | empty |
|
| `pipeline.campaigns.default_campaign_id` | string | No | empty |
|
||||||
| `pipeline.secrets.env_dir` | string | No | empty |
|
|
||||||
| `pipeline.storage.backend` | string | No | `local`; supported values are `local` and `s3` (case-insensitive) |
|
| `pipeline.storage.backend` | string | No | `local`; supported values are `local` and `s3` (case-insensitive) |
|
||||||
| `pipeline.storage.s3.bucket` | string | Conditional | required when backend is `s3` and S3 session-audio or publish upload is enabled |
|
| `pipeline.storage.s3.bucket` | string | Conditional | required when backend is `s3` and S3 session-audio or publish upload is enabled |
|
||||||
| `pipeline.storage.s3.root_prefix` | string | No | `dnd` |
|
| `pipeline.storage.s3.root_prefix` | string | No | `dnd` |
|
||||||
| `pipeline.storage.s3.region` | string | No | empty |
|
| `pipeline.storage.s3.region` | string | No | empty |
|
||||||
| `pipeline.storage.s3.endpoint` | string | No | empty |
|
| `pipeline.storage.s3.endpoint` | string | No | empty |
|
||||||
| `pipeline.storage.s3.force_path_style` | bool | No | `false` |
|
| `pipeline.storage.s3.force_path_style` | bool | No | `false` |
|
||||||
| `pipeline.storage.s3.access_key_id_env` | string | No | `OBJECT_STORAGE_KEY_ID` |
|
| `pipeline.storage.s3.access_key_id` | secret reference | Conditional | unset; must be paired with `secret_access_key` |
|
||||||
| `pipeline.storage.s3.secret_access_key_env` | string | No | `OBJECT_STORAGE_KEY` |
|
| `pipeline.storage.s3.secret_access_key` | secret reference | Conditional | unset; must be paired with `access_key_id` |
|
||||||
| `pipeline.spool.root` | string | No | `/var/spool/narratio` |
|
| `pipeline.spool.root` | string | No | `/var/spool/narratio` |
|
||||||
| `pipeline.spool.delete_audio_after_publish` | bool | No | `false` |
|
| `pipeline.spool.delete_audio_after_publish` | bool | No | `false`; selects the published run's spool-audio target for final cleanup and requires effective `pipeline.publish.enabled=true` and `pipeline.publish.upload_run=true` |
|
||||||
| `pipeline.cache.root` | string | No | `/var/cache/narratio` |
|
| `pipeline.cache.root` | string | No | `/var/cache/narratio` |
|
||||||
| `pipeline.cache.s3_audio` | bool | No | `true` |
|
| `pipeline.cache.s3_audio` | bool | No | `true` |
|
||||||
| `pipeline.publish.enabled` | bool | No | `true` |
|
| `pipeline.publish.enabled` | bool | No | `true` |
|
||||||
@@ -178,9 +308,6 @@ Rules:
|
|||||||
| `pipeline.publish.outputs[].source` | string | Yes (per rule) | must reference built-in or configured artifact source |
|
| `pipeline.publish.outputs[].source` | string | Yes (per rule) | must reference built-in or configured artifact source |
|
||||||
| `pipeline.publish.outputs[].dest` | string | Conditional | derived if omitted and source supports derivation |
|
| `pipeline.publish.outputs[].dest` | string | Conditional | derived if omitted and source supports derivation |
|
||||||
| `pipeline.publish.outputs[].required` | bool | No | `true` |
|
| `pipeline.publish.outputs[].required` | bool | No | `true` |
|
||||||
| `pipeline.publish.locks[]` | list | No | empty |
|
|
||||||
| `pipeline.publish.locks[].source` | string | Yes (per lock) | must reference supported publish source |
|
|
||||||
| `pipeline.publish.locks[].reason` | string | No | empty |
|
|
||||||
| `pipeline.whisperx.transcribe_url` | string | Yes | absolute `http` or `https` URL |
|
| `pipeline.whisperx.transcribe_url` | string | Yes | absolute `http` or `https` URL |
|
||||||
| `pipeline.whisperx.language` | string | No | `en` |
|
| `pipeline.whisperx.language` | string | No | `en` |
|
||||||
| `pipeline.whisperx.timeout` | duration | No | `30m` |
|
| `pipeline.whisperx.timeout` | duration | No | `30m` |
|
||||||
@@ -198,7 +325,7 @@ Rules:
|
|||||||
| `pipeline.seriatim.env.filler_max_duration` | float | No | unset |
|
| `pipeline.seriatim.env.filler_max_duration` | float | No | unset |
|
||||||
| `pipeline.audita.binary` | string | No | `audita` |
|
| `pipeline.audita.binary` | string | No | `audita` |
|
||||||
| `pipeline.audita.timeout` | duration | No | `3h` |
|
| `pipeline.audita.timeout` | duration | No | `3h` |
|
||||||
| `pipeline.audita.llm_api_key_env` | string | No | empty |
|
| `pipeline.audita.llm_api_key` | secret reference | No | unset |
|
||||||
| `pipeline.audita.modules[]` | list[string] | No | empty |
|
| `pipeline.audita.modules[]` | list[string] | No | empty |
|
||||||
| `pipeline.audita.base_url` | string | No | empty |
|
| `pipeline.audita.base_url` | string | No | empty |
|
||||||
| `pipeline.audita.model` | string | No | empty |
|
| `pipeline.audita.model` | string | No | empty |
|
||||||
@@ -243,6 +370,7 @@ Rules:
|
|||||||
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
||||||
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
||||||
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
||||||
|
| `pipeline.scriptorium.artifact_families` | map | No | empty; expands one ordinary artifact per canonical party character |
|
||||||
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
|
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
|
||||||
|
|
||||||
### Notarius Reference Bindings
|
### Notarius Reference Bindings
|
||||||
@@ -310,7 +438,7 @@ For each `pipeline.scriptorium.artifacts.<name>`:
|
|||||||
| Field | Type | Required | Rule |
|
| Field | Type | Required | Rule |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `enabled` | bool | No | `false` if omitted |
|
| `enabled` | bool | No | `false` if omitted |
|
||||||
| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; configured graph must be acyclic |
|
| `depends_on[]` | list[string] | No | explicit ordering-only edges; must reference configured artifact keys, may not self-reference, and join inferred input edges in one acyclic graph |
|
||||||
| `render_debug` | bool | No | per-artifact override |
|
| `render_debug` | bool | No | per-artifact override |
|
||||||
| `prompt_id` | string | Conditional | required when artifact is enabled |
|
| `prompt_id` | string | Conditional | required when artifact is enabled |
|
||||||
| `profile_id` | string | No | empty |
|
| `profile_id` | string | No | empty |
|
||||||
@@ -324,8 +452,8 @@ Narratio adds `session_id=narratio-session-<session_id>` to every Scriptorium re
|
|||||||
Without `--artifacts`, analyze executes enabled configured artifacts. With an
|
Without `--artifacts`, analyze executes enabled configured artifacts. With an
|
||||||
explicit `--artifacts` list, the exact named configured artifacts are the
|
explicit `--artifacts` list, the exact named configured artifacts are the
|
||||||
one-invocation targets even if their `enabled` values are false. Analyze closes
|
one-invocation targets even if their `enabled` values are false. Analyze closes
|
||||||
those targets over `depends_on`: a current prerequisite is reused, while a
|
those targets over the effective dependency graph: a current prerequisite is reused, while a
|
||||||
stale, missing, failed, or legacy prerequisite is rebuilt before its dependent.
|
stale, missing, or failed prerequisite is rebuilt before its dependent.
|
||||||
Unrelated artifacts are not executed. Named targets and any prerequisite that
|
Unrelated artifacts are not executed. Named targets and any prerequisite that
|
||||||
may require rebuilding must therefore have valid executable fields. This
|
may require rebuilding must therefore have valid executable fields. This
|
||||||
override affects analyze planning only; publish uses the list only to filter
|
override affects analyze planning only; publish uses the list only to filter
|
||||||
@@ -342,6 +470,53 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
|
|||||||
loading. Use the canonical `source` identifier to select the input; Narratio
|
loading. Use the canonical `source` identifier to select the input; Narratio
|
||||||
does not provide adapter-specific input passthrough fields.
|
does not provide adapter-specific input passthrough fields.
|
||||||
|
|
||||||
|
Each `narratio.artifact.<name>` input source automatically contributes an
|
||||||
|
effective dependency on that configured artifact. Narratio unions these inferred
|
||||||
|
edges with explicit `depends_on` entries, then trims, deduplicates, and sorts the
|
||||||
|
result during final configuration resolution. Authors use `depends_on` only for
|
||||||
|
ordering constraints that are not already expressed by an artifact input.
|
||||||
|
Use [`narratio config artifacts`](./cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
|
||||||
|
to inspect keys, definition sources, outputs, and effective dependencies without
|
||||||
|
loading a session or creating runtime state.
|
||||||
|
|
||||||
|
### Scriptorium Artifact Families
|
||||||
|
|
||||||
|
`pipeline.scriptorium.artifact_families` declares a shared artifact template
|
||||||
|
for every canonical campaign character. Configuration resolution expands each
|
||||||
|
family into ordinary `pipeline.scriptorium.artifacts` entries before analyze
|
||||||
|
planning or Scriptorium invocation. Party parsing rejects unsupported document
|
||||||
|
schemas before family expansion.
|
||||||
|
|
||||||
|
For each `pipeline.scriptorium.artifact_families.<name>`:
|
||||||
|
|
||||||
|
| Field | Type | Required | Rule |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `enabled`, `prompt_id`, `profile_id`, `timeout`, `render_debug`, `depends_on`, `inputs`, `vars` | ordinary artifact fields | No | copied to each generated artifact under the corresponding ordinary rules |
|
||||||
|
| `for_each` | string | Yes | exactly `party.characters` |
|
||||||
|
| `output_path_pattern` | string | Yes | safe path beneath `artifacts/` with exactly one `{character_id}` token and no other brace syntax |
|
||||||
|
| `member_vars` | map | No | maps an ordinary Scriptorium variable name to a supported canonical character selector |
|
||||||
|
| `member_dependencies` | list | No | unique family keys; each generated member depends on the corresponding generated member of each listed family |
|
||||||
|
| `publish` | map | No | typed family publish policy (`enabled`, `required`, `dest_pattern`) expanded into concrete publish outputs when enabled |
|
||||||
|
|
||||||
|
Generated keys are `<family>_<character_id>` and generated output paths must
|
||||||
|
not collide with explicit artifacts or another generated artifact. Families
|
||||||
|
expand even when disabled; normal analyze selection still omits disabled
|
||||||
|
artifacts unless they are explicitly selected by their concrete key.
|
||||||
|
|
||||||
|
Supported `member_vars` selectors are `character_id`, `player.name`,
|
||||||
|
`character.name`, `character.class_summary`, and `character.alias_summary`.
|
||||||
|
Their resolved values are strings. A member variable may not reuse a static
|
||||||
|
`vars` name; `session_id` remains owned and overwritten by Narratio as for any
|
||||||
|
other Scriptorium artifact.
|
||||||
|
|
||||||
|
Within a family only, an input source may use
|
||||||
|
`narratio.member_artifact.<family>`. Resolution rewrites the source to the
|
||||||
|
corresponding ordinary `narratio.artifact.<family>_<character_id>` source and
|
||||||
|
infers the concrete member dependency. Use `member_dependencies` only for
|
||||||
|
additional same-character ordering constraints that are not already inputs.
|
||||||
|
This syntax is rejected in explicit artifacts and never reaches runtime stages
|
||||||
|
or Scriptorium.
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
Narratio currently supports only `notification.mode: noop`, which is also the
|
Narratio currently supports only `notification.mode: noop`, which is also the
|
||||||
@@ -359,8 +534,7 @@ integration.
|
|||||||
| `inputs.speakers_file` | string | Yes | stable input default |
|
| `inputs.speakers_file` | string | Yes | stable input default |
|
||||||
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
||||||
| `inputs.glossary_file` | string | Yes | stable input default |
|
| `inputs.glossary_file` | string | Yes | stable input default |
|
||||||
| `inputs.players_file` | string | Yes | stable input default |
|
| `inputs.party_file` | string | Yes | stable campaign party source; relative paths resolve from `campaign.yml` |
|
||||||
| `inputs.party_file` | string | Yes | stable input default |
|
|
||||||
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
||||||
|
|
||||||
### Session
|
### Session
|
||||||
@@ -375,8 +549,6 @@ integration.
|
|||||||
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.autocorrect_file` | string | No | overrides campaign stable input |
|
| `inputs.autocorrect_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.players_file` | string | No | overrides campaign stable input |
|
|
||||||
| `inputs.party_file` | string | No | overrides campaign stable input |
|
|
||||||
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
||||||
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
||||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ polished transcripts and generated artifacts. Start with the
|
|||||||
[Architecture](policy/architecture.md) for normative system boundaries, and the
|
[Architecture](policy/architecture.md) for normative system boundaries, and the
|
||||||
[Internal Overview](internal/overview.md) for implemented component ownership.
|
[Internal Overview](internal/overview.md) for implemented component ownership.
|
||||||
|
|
||||||
|
## Go Prerequisite
|
||||||
|
|
||||||
|
Maintained development and verification require Go 1.26. Use the official Go
|
||||||
|
1.26 toolchain before running repository commands.
|
||||||
|
|
||||||
## What To Read
|
## What To Read
|
||||||
|
|
||||||
| When working on | Read | Why |
|
| When working on | Read | Why |
|
||||||
@@ -25,6 +30,7 @@ polished transcripts and generated artifacts. Start with the
|
|||||||
| Adapters or external tool contracts | [Adapter Internals](internal/adapters.md) and [Integration Contracts](integrations/README.md) | The internal guide owns adapter composition and mechanics; integration documents own external formats and protocols. |
|
| Adapters or external tool contracts | [Adapter Internals](internal/adapters.md) and [Integration Contracts](integrations/README.md) | The internal guide owns adapter composition and mechanics; integration documents own external formats and protocols. |
|
||||||
| Manifests, artifacts, workspace paths, or publish behavior | [Manifest Internals](internal/manifest.md), [Artifact Internals](internal/artifacts.md), [Workspace Internals](internal/workspace.md), [Publish Internals](internal/stage-publish.md), and [Operations](operations.md) | These separate implementation state and resolution from operator-visible layout and lifecycle. |
|
| Manifests, artifacts, workspace paths, or publish behavior | [Manifest Internals](internal/manifest.md), [Artifact Internals](internal/artifacts.md), [Workspace Internals](internal/workspace.md), [Publish Internals](internal/stage-publish.md), and [Operations](operations.md) | These separate implementation state and resolution from operator-visible layout and lifecycle. |
|
||||||
| Maintained configuration or input examples | [Configuration](config.md) and [Examples](../examples/README.md) | The reference owns field meanings; the examples directory owns complete copyable files. |
|
| Maintained configuration or input examples | [Configuration](config.md) and [Examples](../examples/README.md) | The reference owns field meanings; the examples directory owns complete copyable files. |
|
||||||
|
| Preparing, validating, or publishing a release | [Release Procedure](release.md) | The maintainer procedure owns version selection, candidate validation, guarded tag publication, and optional later CI inspection. |
|
||||||
| Proposed or unimplemented behavior | `docs/roadmap/` | Future work belongs only in roadmap documentation until implemented. |
|
| Proposed or unimplemented behavior | `docs/roadmap/` | Future work belongs only in roadmap documentation until implemented. |
|
||||||
|
|
||||||
For an existing subsystem, also inspect its focused tests and package-level
|
For an existing subsystem, also inspect its focused tests and package-level
|
||||||
@@ -46,13 +52,13 @@ go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
|||||||
|
|
||||||
The documentation check verifies local Markdown links and the dependency graph
|
The documentation check verifies local Markdown links and the dependency graph
|
||||||
of the Woodpecker workflows. The configuration check loads every maintained
|
of the Woodpecker workflows. The configuration check loads every maintained
|
||||||
pipeline and session example. Release automation repeats these checks and
|
pipeline and session example. Tag CI reuses this validation path before its
|
||||||
cross-compiles the CLI before it builds release assets; publishing depends on
|
asynchronous asset publication; the maintainer release boundary is documented
|
||||||
that validation path, so a failure cannot publish a release.
|
in the [Release Procedure](release.md).
|
||||||
|
|
||||||
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled
|
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled
|
||||||
job to expose ordering and repeatability defects. Current runners cross-compile
|
job to expose ordering and repeatability defects. Current runners cross-compile
|
||||||
for macOS and Windows, but do not provide native macOS or Windows execution.
|
the supported Linux amd64 and arm64 targets and the best-effort macOS arm64
|
||||||
Those cross-builds establish compilation only, not platform-equivalent runtime
|
target. The macOS cross-build establishes compilation only, not native runtime
|
||||||
evidence. Add native checks only when official runner labels and successful
|
evidence. Add a native check only when an official runner label and successful
|
||||||
native-run evidence are available.
|
native-run evidence are available.
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ focused stage documents.
|
|||||||
- [Audita](./audita.md): transcript polishing (`audita process`).
|
- [Audita](./audita.md): transcript polishing (`audita process`).
|
||||||
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
|
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
|
||||||
discovery (`notarius run`).
|
discovery (`notarius run`).
|
||||||
|
- [Party](./party.md): canonical campaign roster input.
|
||||||
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
|
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
|
||||||
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
|
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
|
||||||
(`scriptorium run|render`).
|
(`scriptorium run|render`).
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ Construction fails for invalid static config values, including:
|
|||||||
|
|
||||||
Run fails for:
|
Run fails for:
|
||||||
- missing required request paths;
|
- missing required request paths;
|
||||||
- missing required credential env var when configured (`llm_api_key_env`);
|
|
||||||
- subprocess execution failure;
|
- subprocess execution failure;
|
||||||
- invalid processed transcript JSON (`segments` array required);
|
- invalid processed transcript JSON (`segments` array required);
|
||||||
- invalid report JSON when reporting is enabled.
|
- invalid report JSON when reporting is enabled.
|
||||||
@@ -53,6 +52,9 @@ Failure results still include output/log/config/exit metadata for diagnostics.
|
|||||||
|
|
||||||
## Deterministic Behavior
|
## Deterministic Behavior
|
||||||
- CLI args are built from runner config + request in a fixed order.
|
- CLI args are built from runner config + request in a fixed order.
|
||||||
|
- A configured typed `llm_api_key` reference is resolved during pipeline load;
|
||||||
|
the captured value is supplied only as the child's `AUDITA_LLM_API_KEY`
|
||||||
|
override and is never re-read from the parent environment.
|
||||||
- Generated invocation YAML (`audita.generated.v1`) is emitted when requested.
|
- Generated invocation YAML (`audita.generated.v1`) is emitted when requested.
|
||||||
- Manifest writes are stage-owned; adapter itself is stateless.
|
- Manifest writes are stage-owned; adapter itself is stateless.
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ selector, as direct argument-vector entries without shell interpretation. A CLI
|
|||||||
binding takes precedence over a matching external path in Notarius
|
binding takes precedence over a matching external path in Notarius
|
||||||
configuration. Narratio never emits `--without-reference`.
|
configuration. Narratio never emits `--without-reference`.
|
||||||
|
|
||||||
|
For canonical party configuration, the `party` binding is the unchanged,
|
||||||
|
validated authored roster and the `players` binding is its generated
|
||||||
|
projection. Both retain their established `narratio.input.party` and
|
||||||
|
`narratio.input.players` source IDs, and both are resolved from the prepared
|
||||||
|
manifest rather than from campaign configuration at extraction time.
|
||||||
|
|
||||||
The maintained D&D boundary binds only the four campaign-owned external slots:
|
The maintained D&D boundary binds only the four campaign-owned external slots:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
71
docs/integrations/party.md
Normal file
71
docs/integrations/party.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Canonical Party Input
|
||||||
|
|
||||||
|
`party.yml` is a campaign-owned roster input. It must be a single, versioned
|
||||||
|
`narratio.party.v1` document when Narratio resolves a pipeline, campaign, and
|
||||||
|
session together.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schema_version: narratio.party.v1
|
||||||
|
|
||||||
|
characters:
|
||||||
|
arannis:
|
||||||
|
player:
|
||||||
|
name: Eric
|
||||||
|
character:
|
||||||
|
name: Arannis
|
||||||
|
alias:
|
||||||
|
- Ari
|
||||||
|
- The Grey Owl
|
||||||
|
classes:
|
||||||
|
- name: wizard
|
||||||
|
level: 8
|
||||||
|
```
|
||||||
|
|
||||||
|
`characters` is a non-empty mapping. Each key is a stable character ID using
|
||||||
|
the configured-artifact key grammar: a lowercase ASCII letter followed by zero
|
||||||
|
or more lowercase ASCII letters, digits, or underscores. Character order is
|
||||||
|
preserved where roster order matters.
|
||||||
|
|
||||||
|
Every entry has `player.name`, `character.name`, and a non-empty
|
||||||
|
`character.classes` list. Class entries require a non-empty `name` and may
|
||||||
|
include a positive integer `level`. The optional, intentionally singular
|
||||||
|
`character.alias` field is a list. Names, aliases, and class names must be
|
||||||
|
non-empty, trimmed display strings without control characters. Character names
|
||||||
|
and aliases must be unique across the full roster under Unicode-aware
|
||||||
|
case-insensitive comparison; player names may repeat.
|
||||||
|
|
||||||
|
The document has exactly one YAML document and accepts no unknown fields.
|
||||||
|
`schema_version` is required: a missing, wrong, or malformed value is an error.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Unversioned party inputs are rejected. Replace each legacy party document with
|
||||||
|
the versioned structure above, move each roster entry under `characters`, and
|
||||||
|
remove separate player and session-party settings: those keys are unsupported.
|
||||||
|
|
||||||
|
The configuration rules and source-relative path behavior are defined in the
|
||||||
|
[Configuration Reference](../config.md).
|
||||||
|
|
||||||
|
## Derived players document
|
||||||
|
|
||||||
|
During `prepare`, Narratio copies the canonical party source bytes unchanged
|
||||||
|
to `inputs/party.yml` and writes this deterministic players-only projection to
|
||||||
|
`inputs/players.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
schema_version: narratio.players.v1
|
||||||
|
players:
|
||||||
|
- name: Eric
|
||||||
|
character:
|
||||||
|
id: arannis
|
||||||
|
name: Arannis
|
||||||
|
alias:
|
||||||
|
- Ari
|
||||||
|
- The Grey Owl
|
||||||
|
```
|
||||||
|
|
||||||
|
There is one entry per character, sorted by stable character ID. Repeated
|
||||||
|
player names remain separate entries. The optional `alias` list retains its
|
||||||
|
declared order and is omitted when empty. The projection carries no class
|
||||||
|
data. Its prepared manifest record is marked `derived_from_party`; it is not a
|
||||||
|
separate user-provided `players_file`.
|
||||||
@@ -35,8 +35,9 @@ Adapters do not own:
|
|||||||
|
|
||||||
## Default Wiring
|
## Default Wiring
|
||||||
|
|
||||||
`internal/app/runner.go` initializes default adapters when not injected and
|
The application composes default adapters per invocation when they were not
|
||||||
only when the selected execution plan needs them:
|
injected through the stage environment, and only when the selected execution
|
||||||
|
plan needs them:
|
||||||
|
|
||||||
- WhisperX HTTP client for `transcribe`.
|
- WhisperX HTTP client for `transcribe`.
|
||||||
- Seriatim subprocess runner for `merge`, `normalize`, `trim`, or `render`.
|
- Seriatim subprocess runner for `merge`, `normalize`, `trim`, or `render`.
|
||||||
@@ -49,8 +50,10 @@ only when the selected execution plan needs them:
|
|||||||
Remote publish locks are loaded only for a selected, enabled publish that
|
Remote publish locks are loaded only for a selected, enabled publish that
|
||||||
uploads a run. Shared session lifecycle setup still applies to every selected
|
uploads a run. Shared session lifecycle setup still applies to every selected
|
||||||
range, but an unselected integration is neither initialized nor validated by
|
range, but an unselected integration is neither initialized nor validated by
|
||||||
runner composition. Each selected stage retains its own fail-fast configuration
|
application-owned composition. Constructor, clock, randomness, remote-lock,
|
||||||
and input validation.
|
and post-publish deletion boundaries are scoped to the invocation; stages still
|
||||||
|
receive their collaborators through the stage environment. Each selected stage
|
||||||
|
retains its own fail-fast configuration and input validation.
|
||||||
|
|
||||||
`session plan` is outside production adapter composition. It performs
|
`session plan` is outside production adapter composition. It performs
|
||||||
resume validation and models selected transitions against cloned manifest
|
resume validation and models selected transitions against cloned manifest
|
||||||
@@ -63,30 +66,35 @@ prepared reference resolution, receipt, bundle, and configured-lane policy.
|
|||||||
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
||||||
owner of serializing them as repeated `--reference` arguments before `--json`.
|
owner of serializing them as repeated `--reference` arguments before `--json`.
|
||||||
|
|
||||||
Object-store construction goes through `newCommandObjectStore`, which loads
|
Application-owned object-store construction loads configured filesystem
|
||||||
configured filesystem secrets before adapter initialization.
|
secrets before invoking the invocation's storage factory. Production commands
|
||||||
|
use the concrete storage adapter constructor; focused tests can supply an
|
||||||
|
in-memory backend without changing package state.
|
||||||
|
|
||||||
## Failure Semantics
|
## Failure Semantics
|
||||||
|
|
||||||
- Constructor errors fail stage execution setup early.
|
- Constructor errors fail stage execution setup early.
|
||||||
- Runtime adapter errors propagate to stage code and then manifest failure handling.
|
- Runtime adapter errors propagate to stage code and then manifest failure handling.
|
||||||
- Subprocess adapters persist stage logs/generated configs through stage-managed paths.
|
- Subprocess adapters persist stage logs/generated configs through stage-managed paths.
|
||||||
- Shared subprocess execution starts an owned process group on Linux/macOS or a
|
- Shared subprocess execution keeps private lifecycle mechanics separate from
|
||||||
kill-on-close job object on Windows. Every terminal path disposes of that
|
Narratio's environment, diagnostics, redaction, and error policy. It starts
|
||||||
owned tree before returning. After a natural leader exit, Unix checks for
|
an owned process group on Linux and macOS. Every terminal path disposes of
|
||||||
remaining group members and uses bounded graceful then forceful termination;
|
that owned tree before returning. After a natural leader exit, the runner checks
|
||||||
Windows closes the job so kill-on-close applies. Cancellation, deadlines, and
|
for remaining group members and uses bounded graceful then forceful termination.
|
||||||
|
Cancellation, deadlines, and
|
||||||
diagnostic limits use the same terminal disposal path without losing their
|
diagnostic limits use the same terminal disposal path without losing their
|
||||||
original result classification. Child environments contain only the execution
|
original result classification. Child environments contain only the execution
|
||||||
baseline and adapter-specified values; configured credentials are explicit
|
baseline and adapter-specified values; configured credentials are resolved
|
||||||
sensitive values. Stdout and stderr are redacted while streaming into separate
|
once by ConfigKit and passed explicitly as sensitive values. Adapters do not
|
||||||
|
re-read their named environment references. Stdout and stderr are redacted while streaming into separate
|
||||||
8 MiB diagnostic captures; a bounded wait closes a stream retained by a
|
8 MiB diagnostic captures; a bounded wait closes a stream retained by a
|
||||||
departed leader's descendant. Unsupported platforms reject owned command
|
departed leader's descendant. Unsupported platforms reject owned command
|
||||||
execution.
|
execution.
|
||||||
|
|
||||||
## Implementation And Tests
|
## Implementation And Tests
|
||||||
|
|
||||||
- Composition: `internal/app/runner.go`, `internal/app/object_store.go`
|
- Composition: `internal/app/runner_composition.go`,
|
||||||
|
`internal/app/runner_dependencies.go`, `internal/app/object_store.go`
|
||||||
- Shared subprocess mechanics: `internal/adapters/subprocess`
|
- Shared subprocess mechanics: `internal/adapters/subprocess`
|
||||||
- Focused adapters: `internal/adapters/{whisperx,seriatim,audita,scriptorium,notarius,storage,notify}`
|
- Focused adapters: `internal/adapters/{whisperx,seriatim,audita,scriptorium,notarius,storage,notify}`
|
||||||
- `internal/adapters/whisperx/http_test.go`
|
- `internal/adapters/whisperx/http_test.go`
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ The internal registry recognizes these stable built-in source IDs:
|
|||||||
- `narratio.transcript.final_trimmed_markdown`
|
- `narratio.transcript.final_trimmed_markdown`
|
||||||
- `narratio.bounds.session`
|
- `narratio.bounds.session`
|
||||||
|
|
||||||
Registry entries bind each ID to its producer, output kind, canonical fallback,
|
Registry entries bind each ID to its producer, output kind, and content
|
||||||
and content validator. The focused stage documents own their input/output flow;
|
validator. The focused stage documents own their input/output flow;
|
||||||
[Configuration](../config.md) owns where operators may select these IDs.
|
[Configuration](../config.md) owns where operators may select these IDs.
|
||||||
|
|
||||||
## Configured, Extraction, And Previous-Session Sources
|
## Configured, Extraction, And Previous-Session Sources
|
||||||
@@ -39,7 +39,10 @@ ID.
|
|||||||
Prepared stable source IDs are `narratio.input.players`,
|
Prepared stable source IDs are `narratio.input.players`,
|
||||||
`narratio.input.party`, `narratio.input.glossary`, and
|
`narratio.input.party`, `narratio.input.glossary`, and
|
||||||
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest
|
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest
|
||||||
kind and prepared filename vocabulary.
|
kind and prepared filename vocabulary. Canonical party mode preserves the
|
||||||
|
party source bytes in the party record and supplies the players record from the
|
||||||
|
deterministic `derived_from_party` projection; both remain ordinary prepared
|
||||||
|
source IDs for consumers.
|
||||||
|
|
||||||
## Runtime Catalog
|
## Runtime Catalog
|
||||||
|
|
||||||
@@ -55,13 +58,22 @@ Configured definitions are always registered. Without an explicit selection,
|
|||||||
the effective analyze set contains enabled definitions. With `--artifacts`, the
|
the effective analyze set contains enabled definitions. With `--artifacts`, the
|
||||||
exact named configured definitions become the effective set for that invocation,
|
exact named configured definitions become the effective set for that invocation,
|
||||||
regardless of their `enabled` value. The effective-set resolver itself does not
|
regardless of their `enabled` value. The effective-set resolver itself does not
|
||||||
expand dependencies; the analyze work planner closes those targets over their
|
expand dependencies. Configuration has already materialized the sorted union of
|
||||||
configured prerequisite graph. Availability is separate from executability.
|
explicit ordering edges and configured-artifact input edges, and the analyze
|
||||||
|
work planner closes targets over that one concrete prerequisite graph.
|
||||||
|
Availability is separate from executability. Configuration may normalize a
|
||||||
|
family selection into its concrete generated members before this resolver runs.
|
||||||
|
The effective set retains optional family
|
||||||
|
and character origin metadata, but its keys, catalog sources, and runtime
|
||||||
|
lookups remain concrete configured-artifact identities.
|
||||||
|
Family publish policies are likewise expanded into ordinary configured-source
|
||||||
|
publish rules during configuration resolution.
|
||||||
Configured outputs, including non-executable prerequisites, become available
|
Configured outputs, including non-executable prerequisites, become available
|
||||||
only when the versioned analyze state identifies a current result whose source,
|
only when the versioned analyze state identifies a current result whose source,
|
||||||
contract, canonical configured path, size, and checksum match a confined
|
contract, canonical configured path, size, and checksum match a confined
|
||||||
no-follow regular file. An incidental canonical file and a legacy aggregate
|
no-follow regular file. An incidental canonical file is unavailable, and a
|
||||||
analyze output are unavailable.
|
manifest with an unsupported aggregate-only representation is rejected before
|
||||||
|
catalog construction.
|
||||||
Extraction entries are registered from configuration and become available only
|
Extraction entries are registered from configuration and become available only
|
||||||
after compatible extraction evidence is hydrated.
|
after compatible extraction evidence is hydrated.
|
||||||
|
|
||||||
@@ -82,8 +94,13 @@ Current provenance values:
|
|||||||
|
|
||||||
Built-ins:
|
Built-ins:
|
||||||
|
|
||||||
1. manifest producer outputs (when present)
|
- resolve only from the current manifest's succeeded registered producer;
|
||||||
2. canonical session-path fallback
|
- require exactly one output with the registered source ID and output kind, a
|
||||||
|
safe session-rooted path, producer run ID, canonical checksum, and valid
|
||||||
|
content; and
|
||||||
|
- verify the registered checksum against the durable bytes. Incidental files,
|
||||||
|
canonical filenames, duplicate outputs, failed/stale producer records, and
|
||||||
|
malformed evidence are unavailable.
|
||||||
|
|
||||||
Configured sources (`narratio.artifact.*`):
|
Configured sources (`narratio.artifact.*`):
|
||||||
|
|
||||||
@@ -94,11 +111,15 @@ Configured sources (`narratio.artifact.*`):
|
|||||||
record for the exact configured key and source ID, a complete contract, the
|
record for the exact configured key and source ID, a complete contract, the
|
||||||
configured canonical relative path, positive stored size, and stored
|
configured canonical relative path, positive stored size, and stored
|
||||||
checksum matching bytes read from a confined no-follow regular file; and
|
checksum matching bytes read from a confined no-follow regular file; and
|
||||||
- treat non-current statuses, legacy or malformed records, removed keys,
|
- treat non-current statuses, malformed records, removed keys,
|
||||||
unsafe or missing files, and size/checksum mismatches as unavailable without
|
unsafe or missing files, and size/checksum mismatches as unavailable without
|
||||||
rewriting manifest state. Catalog construction iterates current
|
rewriting manifest state. Catalog construction iterates current
|
||||||
configuration, so removed or renamed records are not advertised.
|
configuration, so removed or renamed records are not advertised.
|
||||||
|
|
||||||
|
`narratio.member_artifact.*` is not a runtime source family. Configuration
|
||||||
|
resolution accepts it only in an artifact-family declaration and rewrites it
|
||||||
|
to the corresponding configured source before this catalog is built.
|
||||||
|
|
||||||
Prepared stable sources (`narratio.input.*`):
|
Prepared stable sources (`narratio.input.*`):
|
||||||
|
|
||||||
- resolve only from the current manifest's exact prepared-input record;
|
- resolve only from the current manifest's exact prepared-input record;
|
||||||
@@ -122,9 +143,12 @@ Extraction sources (`narratio.extraction.*`):
|
|||||||
|
|
||||||
Previous-session sources (`narratio.previous_session.artifact.*`):
|
Previous-session sources (`narratio.previous_session.artifact.*`):
|
||||||
|
|
||||||
- resolve only from local `previous/` cache state;
|
- resolve only from one matching `previous_artifact` input record in the
|
||||||
- prefer manifest-backed previous-input paths;
|
current manifest, including its exact source ID and checksum;
|
||||||
- fallback to existing previous-cache filesystem paths.
|
- are hydrated only from the unique `published_output` declaration for their
|
||||||
|
canonical source in the selected remote commit; and
|
||||||
|
- never infer a path from a previous manifest, current configuration, basename,
|
||||||
|
or existing cache file.
|
||||||
|
|
||||||
Source absence is evaluated by the consuming artifact input. An optional input
|
Source absence is evaluated by the consuming artifact input. An optional input
|
||||||
is omitted from that invocation; a required input fails resolution. This is
|
is omitted from that invocation; a required input fails resolution. This is
|
||||||
@@ -152,25 +176,34 @@ Validation by content type:
|
|||||||
Artifacts package owns shared remote current-state loading mechanics used by
|
Artifacts package owns shared remote current-state loading mechanics used by
|
||||||
restore, status and validation checks, and previous-cache planning.
|
restore, status and validation checks, and previous-cache planning.
|
||||||
|
|
||||||
|
The selected session manifest is accepted only with
|
||||||
|
`schema_version: narratio.session-manifest.v1` and complete stage identity.
|
||||||
|
An unsupported local or downloaded manifest must be replaced by regeneration,
|
||||||
|
verified restore, or republication; artifact resolution never fills in missing
|
||||||
|
authority markers.
|
||||||
|
|
||||||
For a new-protocol current state, the pointer-selected immutable commit is the
|
For a new-protocol current state, the pointer-selected immutable commit is the
|
||||||
complete restore authority. Callers receive its declared object identities and
|
complete restore authority. Callers receive its declared object identities and
|
||||||
must not supplement them by listing mutable session prefixes. The legacy reader
|
must not supplement them by listing mutable session prefixes.
|
||||||
is intentionally separate and remains migration-only support.
|
|
||||||
|
A `published_output` declaration must use a canonical publish-compatible source
|
||||||
|
ID and an exact `runs/{run_id}/published/{destination}` key in the same session.
|
||||||
|
Only this declaration type may refer to an earlier run; other declared objects
|
||||||
|
remain confined to the selected commit's run.
|
||||||
|
|
||||||
The reader opens each small control object directly and enforces owner-specific
|
The reader opens each small control object directly and enforces owner-specific
|
||||||
limits before decoding: 64 KiB for the mutable commit pointer, 4 MiB for the
|
limits before decoding: 64 KiB for the mutable commit pointer, 4 MiB for the
|
||||||
immutable commit manifest, and 8 MiB for the selected session manifest. Legacy
|
immutable commit manifest, and 8 MiB for the selected session manifest. These
|
||||||
compatibility applies a 4 KiB limit to `current/run_id.txt` and the same 8 MiB
|
are exposed as `MaxCurrentCommitPointerBytes`,
|
||||||
manifest limit to `current/manifest.json`. These are exposed as
|
`MaxRemoteCommitManifestBytes`, and `MaxRemoteSessionManifestBytes`.
|
||||||
`MaxCurrentCommitPointerBytes`, `MaxRemoteCommitManifestBytes`,
|
|
||||||
`MaxRemoteSessionManifestBytes`, `MaxLegacyCurrentRunPointerBytes`, and
|
|
||||||
`MaxLegacyCurrentManifestBytes`.
|
|
||||||
|
|
||||||
Each read uses the generation and size metadata returned with its opened body.
|
Each read uses the generation and size metadata returned with its opened body.
|
||||||
Actual bytes remain subject to a limit-plus-one read even if size metadata is
|
Actual bytes remain subject to a limit-plus-one read even if size metadata is
|
||||||
absent or inaccurate. Immutable selections then retain their declared-size,
|
absent or inaccurate. Immutable selections then retain their declared-size,
|
||||||
checksum, generation, and identity checks. No current-state control object is
|
checksum, generation, and identity checks. No current-state control object is
|
||||||
downloaded through a temporary file.
|
downloaded through a temporary file. Artifact and previous-session downloads
|
||||||
|
validate their complete sibling temporary file against available committed
|
||||||
|
identity before replacing a canonical local path.
|
||||||
|
|
||||||
Core helpers:
|
Core helpers:
|
||||||
|
|
||||||
@@ -178,10 +211,9 @@ Core helpers:
|
|||||||
- `ValidateCurrentStateIdentity`
|
- `ValidateCurrentStateIdentity`
|
||||||
- `RemoteCommitManifest` and `CurrentCommitPointer`
|
- `RemoteCommitManifest` and `CurrentCommitPointer`
|
||||||
|
|
||||||
Typed missing-state errors:
|
Typed missing-state error:
|
||||||
|
|
||||||
- `CurrentRunPointerMissingError` (`ErrCurrentRunPointerMissing`)
|
- `CurrentCommitPointerMissingError` (`ErrCurrentCommitPointerMissing`)
|
||||||
- `CurrentManifestMissingError` (`ErrCurrentManifestMissing`)
|
|
||||||
|
|
||||||
Identity validation supports caller-provided expectations:
|
Identity validation supports caller-provided expectations:
|
||||||
|
|
||||||
@@ -195,6 +227,21 @@ Caller policy is intentionally outside artifacts helpers:
|
|||||||
- some callers downgrade missing state to status/findings;
|
- some callers downgrade missing state to status/findings;
|
||||||
- some callers skip optional behavior when state is missing.
|
- some callers skip optional behavior when state is missing.
|
||||||
|
|
||||||
|
## Remote Lock Objects
|
||||||
|
|
||||||
|
The artifacts package defines the independent remote-lock payload and identity
|
||||||
|
contract. Each canonical publish source maps to one object below the validated
|
||||||
|
session `locks/` prefix; its filename is the lowercase SHA-256 digest of the
|
||||||
|
exact source followed by `.lock`.
|
||||||
|
|
||||||
|
Lock payloads are strict versioned JSON containing the canonical source, an
|
||||||
|
optional reason, and a UTC creation time. Reads are bounded to
|
||||||
|
`MaxRemoteLockObjectBytes` (64 KiB), reject unknown fields and trailing values,
|
||||||
|
and verify that the payload source matches the key digest. Exact lookup reports
|
||||||
|
presence explicitly, prefix listing validates every object and sorts by source,
|
||||||
|
creation uses an absence precondition, forced replacement uses one observed
|
||||||
|
generation, and removal is idempotent.
|
||||||
|
|
||||||
## Key Path Helpers
|
## Key Path Helpers
|
||||||
|
|
||||||
`internal/artifacts/paths.go` and S3-key helpers define canonical helpers for:
|
`internal/artifacts/paths.go` and S3-key helpers define canonical helpers for:
|
||||||
@@ -207,8 +254,13 @@ Caller policy is intentionally outside artifacts helpers:
|
|||||||
New publication creates run-scoped immutable objects, including
|
New publication creates run-scoped immutable objects, including
|
||||||
`runs/{run_id}/commit.json` and `runs/{run_id}/session-manifest.json`. The sole
|
`runs/{run_id}/commit.json` and `runs/{run_id}/session-manifest.json`. The sole
|
||||||
mutable selector is `current/commit-pointer.json`; readers verify its selected
|
mutable selector is `current/commit-pointer.json`; readers verify its selected
|
||||||
commit and declared object generations/checksums. Legacy current-pair loading
|
commit and declared object generations/checksums.
|
||||||
is confined to `current_state_legacy.go` for migration only.
|
|
||||||
|
A `published_output` declaration may identify its commit's run or an earlier
|
||||||
|
immutable run in the same campaign/session namespace. The key parser validates
|
||||||
|
that namespace, rejects mutable and malformed keys, and derives the originating
|
||||||
|
run from the exact declaration. All other commit artifacts remain confined to
|
||||||
|
the commit's own run prefix.
|
||||||
|
|
||||||
Campaign, session, and Narratio run IDs are validated as portable opaque
|
Campaign, session, and Narratio run IDs are validated as portable opaque
|
||||||
segments at configuration and artifact boundaries before they can be used in a
|
segments at configuration and artifact boundaries before they can be used in a
|
||||||
@@ -239,7 +291,6 @@ physical layout.
|
|||||||
`internal/artifacts/prepared_input.go`
|
`internal/artifacts/prepared_input.go`
|
||||||
- Current state: `internal/artifacts/current_state.go`,
|
- Current state: `internal/artifacts/current_state.go`,
|
||||||
`internal/artifacts/current_state_commit.go`,
|
`internal/artifacts/current_state_commit.go`,
|
||||||
`internal/artifacts/current_state_legacy.go`
|
|
||||||
- Paths and keys: `internal/artifacts/paths.go`,
|
- Paths and keys: `internal/artifacts/paths.go`,
|
||||||
`internal/artifacts/s3_keys.go`
|
`internal/artifacts/s3_keys.go`
|
||||||
- Previous requirements: `internal/artifacts/previous_requirements.go`
|
- Previous requirements: `internal/artifacts/previous_requirements.go`
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ physical restore scope belong in
|
|||||||
Restore separates remote authority, local conflict policy, and filesystem
|
Restore separates remote authority, local conflict policy, and filesystem
|
||||||
mutation so each remains testable independently.
|
mutation so each remains testable independently.
|
||||||
|
|
||||||
|
The public command supplies production defaults to one private,
|
||||||
|
invocation-scoped dependency value. Discovery, planning, and execution use that
|
||||||
|
same value, so focused sequencing tests can replace one boundary without
|
||||||
|
mutable package hooks.
|
||||||
|
|
||||||
## Discovery Contract
|
## Discovery Contract
|
||||||
|
|
||||||
Discovery delegates current-state pointer and manifest loading to
|
Discovery delegates current-state pointer and manifest loading to
|
||||||
@@ -34,18 +39,25 @@ Planner behavior:
|
|||||||
|
|
||||||
- a new-protocol restore uses only the selected commit's declared artifact set;
|
- a new-protocol restore uses only the selected commit's declared artifact set;
|
||||||
each action carries that artifact's immutable key, checksum, size, and
|
each action carries that artifact's immutable key, checksum, size, and
|
||||||
generation. Coherent legacy state remains on the isolated compatibility path;
|
generation;
|
||||||
- remote-to-local mapping is traversal-safe;
|
- remote-to-local mapping is traversal-safe;
|
||||||
- actions are sorted by local relative path and then remote key;
|
- actions are sorted by local relative path and then remote key;
|
||||||
|
- planning opens the selected session root and inspects every target by its
|
||||||
|
normalized session-relative path. Missing ancestors produce a missing target;
|
||||||
|
an ancestor or leaf symlink is a non-regular conflict and is never opened for
|
||||||
|
hashing;
|
||||||
- force converts differing eligible regular files from conflicts to downloads;
|
- force converts differing eligible regular files from conflicts to downloads;
|
||||||
directories and other non-regular targets remain conflicts.
|
directories, symlinks, and other non-regular targets remain conflicts.
|
||||||
|
|
||||||
For a non-dry-run restore, planning/classification happens only after acquiring
|
For a non-dry-run restore, planning/classification happens only after acquiring
|
||||||
the session lock. Runner manifest/reuse checks acquire that same lock first.
|
the session lock. Runner manifest/reuse checks acquire that same lock first.
|
||||||
|
|
||||||
Previous-cache readiness is resolved through `previouscache.Resolve` for restore,
|
Previous-cache readiness is resolved through `previouscache.Resolve` for restore,
|
||||||
prepare, status, and validation. A committed source is selected only by its
|
prepare, status, and validation. Each requirement selects the unique
|
||||||
exact source identity; legacy fallback remains isolated and rejects ambiguity.
|
`published_output` for its canonical source in the pointer-selected commit,
|
||||||
|
then strips only that run's immutable `published/` prefix for its safe local
|
||||||
|
cache path. It never probes candidate objects or derives paths from manifests,
|
||||||
|
configuration, or basenames.
|
||||||
|
|
||||||
## Execution Contract
|
## Execution Contract
|
||||||
|
|
||||||
@@ -53,10 +65,14 @@ Execution order and safety:
|
|||||||
|
|
||||||
- non-manifest downloads happen before manifest install;
|
- non-manifest downloads happen before manifest install;
|
||||||
- `manifest.json` installs last;
|
- `manifest.json` installs last;
|
||||||
- downloads use sibling temp files plus atomic rename;
|
- downloads use sibling temp files, validate the complete object, then use an
|
||||||
|
atomic rename;
|
||||||
- manifest replacement is validated before rename;
|
- manifest replacement is validated before rename;
|
||||||
- each committed object is verified against its declared checksum, size, and
|
- each committed object is verified against its declared checksum, size, and
|
||||||
generation before installation;
|
generation before installation;
|
||||||
|
- installation reopens the selected session root, creates only validated
|
||||||
|
missing directory components with workspace modes, and rejects symlinked or
|
||||||
|
non-regular ancestors and leaves before replacement;
|
||||||
- a committed manifest already verified during discovery is retained for the
|
- a committed manifest already verified during discovery is retained for the
|
||||||
matching restore action and revalidated before installation, avoiding a
|
matching restore action and revalidated before installation, avoiding a
|
||||||
second body transfer;
|
second body transfer;
|
||||||
@@ -67,15 +83,17 @@ Execution order and safety:
|
|||||||
- restored manifest local references are rebased beneath the selected local
|
- restored manifest local references are rebased beneath the selected local
|
||||||
session root. Unsafe relative references and producer-machine absolute paths
|
session root. Unsafe relative references and producer-machine absolute paths
|
||||||
outside the manifest's producer session root are rejected; producer-local
|
outside the manifest's producer session root are rejected; producer-local
|
||||||
spool/cache and cleanup locations are not restored as authority.
|
spool paths and run-local diagnostic references are not restored as
|
||||||
|
authority. Cleanup remains ordinary restored stage state and is not reset or
|
||||||
|
synthesized by restore.
|
||||||
|
|
||||||
Audio restore path:
|
Audio restore path:
|
||||||
|
|
||||||
- uses `audio.MaterializeS3Audio`;
|
- uses `audio.MaterializeS3Audio`;
|
||||||
- integrates spool and S3 audio cache paths;
|
- integrates spool and S3 audio cache paths;
|
||||||
- reuses cached audio only when its no-follow regular file, content digest, and
|
- reuses cached audio only when its opened regular file, content digest, and
|
||||||
identity sidecar all match the selected remote object version; otherwise it
|
identity sidecar all match the selected remote object version; otherwise it
|
||||||
refreshes through the durable download path.
|
refreshes through the validated download path.
|
||||||
|
|
||||||
## Reporting Contract
|
## Reporting Contract
|
||||||
|
|
||||||
@@ -92,9 +110,7 @@ Audio restore path:
|
|||||||
loaded at discovery; later pointer changes cannot add objects or substitute a
|
loaded at discovery; later pointer changes cannot add objects or substitute a
|
||||||
different run into its plan;
|
different run into its plan;
|
||||||
- a verified `current/commit-pointer.json` and its selected immutable commit
|
- a verified `current/commit-pointer.json` and its selected immutable commit
|
||||||
establish new-protocol remote commitment; coherent legacy
|
establish remote commitment; when absent, republish the session;
|
||||||
`current/run_id.txt` plus `current/manifest.json` remains read-only migration
|
|
||||||
support;
|
|
||||||
- restore does not execute pipeline stages.
|
- restore does not execute pipeline stages.
|
||||||
|
|
||||||
## Implementation And Tests
|
## Implementation And Tests
|
||||||
@@ -107,4 +123,5 @@ Audio restore path:
|
|||||||
- Tests: `internal/app/restore_discovery_test.go`,
|
- Tests: `internal/app/restore_discovery_test.go`,
|
||||||
`internal/app/restore_plan_test.go`,
|
`internal/app/restore_plan_test.go`,
|
||||||
`internal/app/restore_execution_test.go`,
|
`internal/app/restore_execution_test.go`,
|
||||||
|
`internal/app/restore_recovery_test.go`,
|
||||||
`internal/app/restore_workflow_test.go`
|
`internal/app/restore_workflow_test.go`
|
||||||
|
|||||||
211
docs/internal/configuration.md
Normal file
211
docs/internal/configuration.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Configuration Internals
|
||||||
|
|
||||||
|
User-visible fields, defaults, and selection behavior belong in the
|
||||||
|
[Configuration Reference](../config.md). This document describes the internal
|
||||||
|
pipeline-loading boundary implemented by `internal/config`.
|
||||||
|
|
||||||
|
## Pipeline Loading
|
||||||
|
|
||||||
|
`LoadPipeline` assembles and validates a pipeline in this order:
|
||||||
|
|
||||||
|
1. Give ConfigKit a fresh typed `PipelineConfig` default value, the root file,
|
||||||
|
enabled imports, and the caller's profile selection. Omitted, empty, and
|
||||||
|
exact `default` selections use the base; named profiles use the fixed
|
||||||
|
`profiles/<name>.yml` convention.
|
||||||
|
2. ConfigKit parses the root and declared imports, additively merges base
|
||||||
|
sources, applies only the selected overlay, and strictly decodes the result.
|
||||||
|
3. The definition resolver applies recorded entry-level and inherited defaults,
|
||||||
|
runs deterministic domain normalization, records derived publish destinations,
|
||||||
|
and resolves ordinary paths relative to the root pipeline file.
|
||||||
|
4. The definition validator performs read-only checks that require only the
|
||||||
|
pipeline file. Checks requiring canonical-party family expansion remain at
|
||||||
|
the existing explicit pipeline/campaign resolution boundary.
|
||||||
|
5. Narratio inspects public ConfigKit metadata to reject artifact and family keys
|
||||||
|
declared by multiple base files, adapts source ownership records, attaches
|
||||||
|
runtime-only state, and computes the normalized effective digest.
|
||||||
|
|
||||||
|
This ordering preserves monolithic configuration behavior. Moving a field to
|
||||||
|
an imported fragment changes its source ownership, not its path base, default,
|
||||||
|
or schema semantics.
|
||||||
|
|
||||||
|
## Loaded Context Resolution
|
||||||
|
|
||||||
|
`LoadedPipelineCampaign` carries one already composed pipeline and its selected
|
||||||
|
campaign into session resolution. `LoadSessionWithPipelineCampaignOptions`
|
||||||
|
loads a local session against that context, while
|
||||||
|
`ResolveLoadedPipelineCampaign` also accepts an already loaded remote session
|
||||||
|
or no session while a caller retrieves one. Compatibility loaders route through
|
||||||
|
these functions after their initial pipeline and campaign reads.
|
||||||
|
|
||||||
|
Application commands own pipeline and campaign discovery, campaign-file versus
|
||||||
|
registry selection, and the corresponding mutual-exclusion rules. Once they
|
||||||
|
have a `LoadedPipelineCampaign`, local session discovery and remote-session
|
||||||
|
download retain that exact pipeline object and its private provenance. Removing
|
||||||
|
a temporary downloaded session file therefore cannot invalidate the resolved
|
||||||
|
pipeline or campaign context.
|
||||||
|
|
||||||
|
Campaign and session files use independent ConfigKit definitions with imports
|
||||||
|
and profiles disabled. Campaign files normally load directly from their source;
|
||||||
|
session files are first read through the 8 MiB bounded-read boundary because the
|
||||||
|
application must reject unresolved template placeholders before passing an exact
|
||||||
|
byte snapshot to ConfigKit. Local and remotely downloaded session bytes then use
|
||||||
|
the same typed loader and retain the existing post-load identity expectations,
|
||||||
|
stable-input precedence, and source-relative path semantics.
|
||||||
|
|
||||||
|
The application also has a separate read-only inspection resolver for `config
|
||||||
|
validate`, `config show`, `config sources`, and `config artifacts`. It uses the same production root/profile and
|
||||||
|
campaign selection functions, but never routes through session discovery,
|
||||||
|
remote-session download, adapter composition, workspace
|
||||||
|
initialization, manifest access, or cleanup. A pipeline with retained artifact
|
||||||
|
family declarations must resolve its selected campaign before ordinary pipeline
|
||||||
|
validation, which expands its canonical-party members and generated publish
|
||||||
|
rules. A pipeline without those declarations may be validated by itself.
|
||||||
|
Secret-directory bootstrap remains invocation-scoped and happens before the
|
||||||
|
ordinary ConfigKit load because explicit typed references must resolve even for
|
||||||
|
inspection.
|
||||||
|
|
||||||
|
`MarshalEffectivePipeline` is the configuration-owned projection for `config
|
||||||
|
show`. It serializes one explicit typed safe projection with the existing YAML
|
||||||
|
encoder. The projection has no slots for resolution-only artifact family
|
||||||
|
declarations or private provenance. The result has one trailing newline;
|
||||||
|
commands do not marshal runtime objects directly.
|
||||||
|
|
||||||
|
`EffectivePipelineSources` and `EffectiveCampaignSources` provide the separate
|
||||||
|
safe provenance projection for `config sources`. Pipeline ownership begins with
|
||||||
|
the complete logical field paths projected from the schema and classifies
|
||||||
|
each contributor as root, import, profile, or centralized default. The
|
||||||
|
projection replaces generated concrete member paths with paired family and
|
||||||
|
canonical-party records, and does the same for generated publish rules.
|
||||||
|
Campaign records identify campaign-owned fields and party inputs; canonical
|
||||||
|
derived players point to the party source. The application command only joins these sorted
|
||||||
|
records with selection metadata and never reparses configuration files.
|
||||||
|
|
||||||
|
`EffectiveArtifactCatalog` projects the same finalized concrete artifact map for
|
||||||
|
`config artifacts`. It derives source identity from each map key, carries sorted
|
||||||
|
effective dependencies, aggregates ConfigKit definition contributors, and adds
|
||||||
|
family/character/party provenance for generated members. It does not inspect
|
||||||
|
files named after artifacts or infer another runtime graph.
|
||||||
|
|
||||||
|
`config diff` uses a thin paired loader that performs two independent ConfigKit
|
||||||
|
loads. When campaign
|
||||||
|
resolution is needed, the command loads one selected campaign and party and
|
||||||
|
expands both effective pipelines from that same party value. The configuration
|
||||||
|
owner projects each normalized effective mapping directly from that same typed
|
||||||
|
projection into sorted logical paths using JSON number preservation;
|
||||||
|
mapping leaves are compared individually while sequence values remain atomic.
|
||||||
|
Values are compact deterministic JSON representations for command output, not
|
||||||
|
raw YAML fragments, ownership records, or secret material. A differing digest
|
||||||
|
with no projected difference is treated as an internal consistency error.
|
||||||
|
|
||||||
|
Campaign context construction reads the campaign-owned party source through
|
||||||
|
`ParseParty`. The required canonical party retains its raw bytes and normalized
|
||||||
|
roster in runtime-only `ResolvedParty` provenance. Canonical resolution creates
|
||||||
|
a deterministic `derived_from_party` players projection. The party is the only
|
||||||
|
configured roster authority; player and session-party fields are unsupported.
|
||||||
|
|
||||||
|
## Canonical Party Domain
|
||||||
|
|
||||||
|
`ParseParty` is the package-owned boundary for parsing a party source. Its
|
||||||
|
single-source ConfigKit definition disables imports and profiles, requires
|
||||||
|
`schema_version` through `VersionRule`, and strictly decodes the
|
||||||
|
character-keyed typed schema before applying Narratio's party-domain checks.
|
||||||
|
Successful metadata coordinates recover authored character-map order without a
|
||||||
|
second YAML parse; class and alias slice order comes directly from typed decode.
|
||||||
|
The canonical value retains a separate exact byte copy of its source so
|
||||||
|
consumers can materialize the authored party document without reserializing it.
|
||||||
|
Its `PlayersYAML` method deterministically derives the ID-sorted, versioned
|
||||||
|
players-only projection.
|
||||||
|
|
||||||
|
An unversioned source is rejected before resolution, family expansion, or
|
||||||
|
prepare-stage materialization.
|
||||||
|
|
||||||
|
## Validation Ownership
|
||||||
|
|
||||||
|
Configuration validation remains within `internal/config`, with the public
|
||||||
|
entry points and pipeline-domain sequencing separated from the private owners
|
||||||
|
for core pipeline/session rules, external adapters, publish/storage, Notarius,
|
||||||
|
Scriptorium, and shared scalar/path checks. These are package-local ownership
|
||||||
|
boundaries rather than extension points or a general validation framework.
|
||||||
|
|
||||||
|
Validation is read-only, fails fast, and does not define which of several
|
||||||
|
independent invalid fields is reported first. Map-backed declarations are
|
||||||
|
nevertheless traversed deterministically. Reusable domain normalization is an
|
||||||
|
explicit preceding operation for both loaded and programmatically constructed
|
||||||
|
pipelines. Notarius normalization commits canonical scalar, reference, and output
|
||||||
|
values only after the complete normalization succeeds; its validator then checks
|
||||||
|
that candidate without changing it. Scriptorium validates artifact declarations
|
||||||
|
and outputs, dependencies and inputs, variables, referenced outputs, and the final
|
||||||
|
dependency graph in deterministic artifact order.
|
||||||
|
|
||||||
|
Diagnostics identify the relevant semantic field. Merge conflicts also
|
||||||
|
retain every claiming source needed to correct an additive ownership conflict.
|
||||||
|
Complete prose, punctuation, formatting, and private validation choreography are
|
||||||
|
not compatibility contracts.
|
||||||
|
|
||||||
|
## Diagnostics And Runtime Metadata
|
||||||
|
|
||||||
|
Syntax, duplicate-key, merge, profile, and schema failures include the
|
||||||
|
relevant source name and full field path. Additive conflicts report every
|
||||||
|
claiming source so operators can repair the split without repeatedly
|
||||||
|
rediscovering additional conflicts.
|
||||||
|
|
||||||
|
The loaded pipeline retains private runtime metadata for the absolute root
|
||||||
|
path, ordered imports, selected profile name and selection source, selected
|
||||||
|
overlay, original ConfigKit metadata and source snapshots, effective digest,
|
||||||
|
and leaf ownership. Base
|
||||||
|
leaves retain their root/import owners, replaced leaves belong to the selected
|
||||||
|
overlay, and centrally supplied values use the synthetic `default` owner. This
|
||||||
|
metadata does not participate in YAML decoding or alter the public
|
||||||
|
configuration model.
|
||||||
|
|
||||||
|
The effective digest is SHA-256 over a versioned deterministic JSON encoding of
|
||||||
|
the same typed safe projection used by show and diff. Runtime Notarius paths remain absolute for
|
||||||
|
execution, but the digest substitutes their normalized logical values captured
|
||||||
|
before root-relative resolution, so relocating an equivalent configuration
|
||||||
|
bundle does not change provenance. Because ConfigKit and resolution metadata
|
||||||
|
are private, the digest excludes source layout, profile name, and ownership.
|
||||||
|
Configuration stores credentials as ConfigKit typed references. A successful
|
||||||
|
load resolves their values inside the opaque typed boundary, while this safe
|
||||||
|
projection serializes only `EnvName()` identities. Credential-value changes are
|
||||||
|
therefore never hashed; reference-name changes remain ordinary provenance.
|
||||||
|
`recomputePipelineEffectiveDigest` is the single package-owned refresh point
|
||||||
|
for later runtime expansion.
|
||||||
|
|
||||||
|
## Test Surfaces
|
||||||
|
|
||||||
|
`pipeline_configkit_test.go` exercises real imports and fixed-path profiles,
|
||||||
|
base selection, unselected-overlay isolation, source attribution, and Narratio's
|
||||||
|
unique artifact/family base-declaration rule. Existing focused configuration
|
||||||
|
tests protect defaults, explicit-value semantics, strict decoding, validation,
|
||||||
|
metadata, and root-relative path behavior. Application configuration-loader tests protect the
|
||||||
|
single-read boundary by changing the pipeline file after its initial load and
|
||||||
|
confirming local session resolution retains the original pipeline. Other
|
||||||
|
configuration tests continue to protect defaults and validation after assembly.
|
||||||
|
`party_test.go` protects the versioned typed party schema, domain invariants,
|
||||||
|
authored character order, retained bytes, and deterministic players projection
|
||||||
|
without involving campaign or runtime wiring.
|
||||||
|
`party_resolution_test.go` protects campaign-owned party loading, canonical
|
||||||
|
input restrictions, source provenance, and virtual players input selection.
|
||||||
|
|
||||||
|
## Artifact Family Resolution
|
||||||
|
|
||||||
|
Pipeline loading retains `scriptorium.artifact_families` as a resolution-only
|
||||||
|
declaration. Once campaign party resolution establishes a canonical roster,
|
||||||
|
configuration expands families in sorted family-key and character-ID order
|
||||||
|
into ordinary `ScriptoriumArtifactConfig` values. The expansion owns the narrow
|
||||||
|
`{character_id}` output substitution, closed member-variable selectors, key and
|
||||||
|
output collision checks, and the runtime-only family-origin catalog. It then
|
||||||
|
removes family declarations from `ScriptoriumConfig`, runs ordinary Scriptorium
|
||||||
|
validation, and refreshes the effective pipeline digest. Stages and adapters
|
||||||
|
therefore receive only concrete artifact maps.
|
||||||
|
|
||||||
|
The catalog retains sorted family member keys plus family/character/source
|
||||||
|
origins and the typed dependency/publish declarations for their later owners.
|
||||||
|
`member_dependencies` add corresponding ordinary concrete dependencies, while
|
||||||
|
the family-only `narratio.member_artifact.<family>` input form is rewritten to
|
||||||
|
the matching ordinary configured-artifact source. The catalog records those
|
||||||
|
resolved dependency and input identities with their declaring family and party
|
||||||
|
member. No member-artifact source is registered as a runtime policy source.
|
||||||
|
An enabled family publish declaration expands to ordinary configured-artifact
|
||||||
|
publish rules before the existing publish and lock validators run. Runtime
|
||||||
|
publication consequently receives no family wildcard or special matcher.
|
||||||
@@ -1,71 +1,55 @@
|
|||||||
# Internal: File Operations
|
# Internal: File Operations
|
||||||
|
|
||||||
`internal/fileops` owns the narrow mechanics for durable replacement of one
|
`internal/fileops` owns the narrow mechanics for atomically visible replacement of one
|
||||||
byte file. Callers keep ownership of serialization, validation, cancellation,
|
byte file. Callers keep ownership of serialization, validation, cancellation,
|
||||||
and destination-directory policy.
|
and destination-directory policy.
|
||||||
|
|
||||||
## Destination Confinement
|
## Destination Paths
|
||||||
|
|
||||||
Before it creates, replaces, or installs a destination file, `fileops` opens
|
Ordinary workspace replacement uses standard operating-system path semantics.
|
||||||
each ancestor from the filesystem root and rejects symbolic links or components
|
Callers with a root-scoped safety contract own that validation at their boundary.
|
||||||
that change during traversal. The resulting parent-directory handle is retained
|
|
||||||
for sibling temporary-file creation and rename, so a later pathname swap cannot
|
|
||||||
redirect the replacement. Existing destination symlinks are replaced as leaf
|
|
||||||
entries; their targets are never followed.
|
|
||||||
|
|
||||||
Remote object acquisition uses a writer supplied by the storage owner. The
|
Remote object acquisition is owned by its domain caller. The caller writes a
|
||||||
writer receives a `fileops`-owned, already-open sibling temporary file rather
|
sibling temporary file, validates the complete result, and passes that file to
|
||||||
than a mutable destination path. Callers still own remote object selection,
|
`InstallSiblingTempFile` only when it is ready to become canonical. Callers own
|
||||||
validation, conflict handling, and final mode.
|
remote object selection, validation, conflict handling, and temporary-file
|
||||||
|
cleanup.
|
||||||
|
|
||||||
Directory promotion keeps the verified destination parent open while it creates
|
Directory bundles are not promoted by `internal/fileops`; extraction owns its
|
||||||
the temporary tree, copies regular source entries, and performs the platform
|
validated Notarius bundle rename.
|
||||||
no-replace rename. Platforms without a verified handle-relative atomic
|
|
||||||
no-replace primitive reject promotion before writing a temporary tree.
|
|
||||||
|
|
||||||
## Cleanup Contract
|
## Cleanup Contract
|
||||||
|
|
||||||
`RemoveAllUnderRoot` accepts an explicit root and a proper descendant. It opens
|
`RemoveAllUnderRoot` accepts an explicit root and a proper descendant, opens
|
||||||
the root and each target ancestor without following symlinks, then removes the
|
that root with `os.OpenRoot`, and removes the normalized root-relative target
|
||||||
tree through those directory handles. It rejects root deletion and any symlink
|
with `Root.RemoveAll`. It rejects root, traversal, and out-of-root targets; a
|
||||||
encountered in the target path or tree; repeated removal of a missing target is
|
missing target is successful. `RemoveFileUnderRoot` additionally requires an
|
||||||
successful. Command and post-publish policy remains owned by `internal/app`.
|
existing leaf to be regular. Command and post-publish policy remains owned by
|
||||||
|
`internal/app`.
|
||||||
|
|
||||||
## Confined Reads
|
## Bounded Reads
|
||||||
|
|
||||||
`ReadRegularFileUnderRoot` is the no-follow, bounded read primitive for a
|
`ReadFileBounded` opens a normal path and reads at most its caller-supplied
|
||||||
caller-selected root and relative file path; `ReadRegularFile` is its
|
limit. Credential mode policy and environment precedence remain owned by
|
||||||
path-based convenience wrapper. They verify every ancestor through directory
|
`internal/app`.
|
||||||
handles and admit only a stable regular-file handle. Callers enforce their own
|
|
||||||
byte limits and access policy. Credential mode policy and environment
|
|
||||||
precedence remain owned by `internal/app`.
|
|
||||||
|
|
||||||
## Replacement Contract
|
## Replacement Contract
|
||||||
|
|
||||||
`ReplaceFileAtomic` requires an existing destination directory. It creates a
|
`ReplaceFileAtomic` requires an existing destination directory. It creates a
|
||||||
sibling temporary file, writes the complete byte sequence, applies the
|
sibling temporary file, writes the complete byte sequence, applies the
|
||||||
caller-supplied mode, syncs and closes the file, runs an optional pre-rename
|
caller-supplied mode, closes the file, runs an optional pre-rename check, and
|
||||||
check, replaces the destination with a rename, then syncs the containing
|
replaces the destination with a rename.
|
||||||
directory.
|
|
||||||
|
|
||||||
The pre-rename check is the last point at which a caller can cancel without
|
The pre-rename check is the last point at which a caller can cancel without
|
||||||
installing a new destination. A failure before the rename leaves the old
|
installing a new destination. A failure before the rename leaves the old
|
||||||
destination unchanged and removes the temporary file; any cleanup failure is
|
destination unchanged and removes the temporary file. Successful replacement
|
||||||
returned alongside the primary failure. A failure after the rename may leave
|
provides atomic visibility; it makes no crash- or power-loss durability claim.
|
||||||
the new file visible, but it is not reported as crash-durable.
|
|
||||||
|
|
||||||
Replacement follows the operating system's same-filesystem rename semantics.
|
Replacement follows the operating system's same-filesystem rename semantics.
|
||||||
If a platform cannot replace an existing destination, the operation returns an
|
If a platform cannot replace an existing destination, the operation returns an
|
||||||
error and never removes the old file as an emulation step.
|
error and never removes the old file as an emulation step.
|
||||||
|
|
||||||
## Directory-Sync Support
|
`WriteFileAtomic` and the copy helpers create the destination parent with the
|
||||||
|
repository's workspace permissions. `InstallSiblingTempFile` requires the
|
||||||
Linux and macOS attempt to sync the destination directory. Windows opens the
|
caller to provide an existing temporary file in the existing destination
|
||||||
directory with backup semantics and flushes its buffers. If either operation
|
directory.
|
||||||
is unavailable for the platform, directory handle, or filesystem,
|
|
||||||
`ErrDirectorySyncUnsupported` is returned. Narratio does not treat that result
|
|
||||||
as successful crash-durable replacement.
|
|
||||||
|
|
||||||
`WriteFileAtomic`, copy helpers, and downloaded temporary-file installation
|
|
||||||
retain their compatibility behavior of creating the destination parent with
|
|
||||||
the repository's workspace permissions before using this contract.
|
|
||||||
|
|||||||
@@ -2,29 +2,44 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Explain the session-progress and invocation-audit models implemented by
|
Explain the session-progress model implemented by `internal/manifest`. Physical
|
||||||
`internal/manifest`. Physical manifest placement belongs in
|
manifest placement belongs in
|
||||||
[Operations](../operations.md#local-state-layout).
|
[Operations](../operations.md#local-state-layout).
|
||||||
|
|
||||||
## Session Manifest
|
## Session Manifest
|
||||||
|
|
||||||
`manifest.Manifest` records:
|
`manifest.Manifest` records:
|
||||||
|
|
||||||
|
- required `schema_version: narratio.session-manifest.v1`
|
||||||
- identity (`session_id`, `campaign`, `run_id`)
|
- identity (`session_id`, `campaign`, `run_id`)
|
||||||
- local path metadata (`local_workdir`, `local_spool_dir`)
|
- local path metadata (`local_workdir`, `local_spool_dir`)
|
||||||
- remote identity metadata (`s3_bucket`, `s3_session_prefix`, `s3_run_prefix`)
|
- remote identity metadata (`s3_bucket`, `s3_session_prefix`, `s3_run_prefix`)
|
||||||
- `inputs` records
|
- `inputs` records
|
||||||
- durable `artifacts` records
|
- durable `artifacts` records
|
||||||
- per-stage `stages` map
|
- per-stage `stages` map, including the ordinary cleanup stage record
|
||||||
- an optional `post_publish_cleanup` obligation, which binds a committed run,
|
|
||||||
remote commit identity, and each exact root-confined local target to its
|
|
||||||
completion evidence
|
|
||||||
|
|
||||||
Session, campaign, and run identities in local and downloaded manifests must be
|
Session, campaign, and run identities in local and downloaded manifests must be
|
||||||
portable opaque segments. Unsafe legacy identities are rejected with migration
|
portable opaque segments. Unsafe legacy identities are rejected with migration
|
||||||
guidance rather than being normalized into a different workspace or remote
|
guidance rather than being normalized into a different workspace or remote
|
||||||
namespace.
|
namespace.
|
||||||
|
|
||||||
|
Decoding is strict at every typed manifest field. Unknown fields are rejected;
|
||||||
|
the schema version remains `narratio.session-manifest.v1` and there is no
|
||||||
|
compatibility reader that silently drops retired state. Cleanup progress uses
|
||||||
|
only the cleanup entry in `stages`.
|
||||||
|
|
||||||
|
Every persisted session manifest has a non-nil `stages` map. Each entry is
|
||||||
|
non-nil and its embedded `name` exactly matches its map key; loading never
|
||||||
|
invents either marker. A missing or unsupported schema version, or incomplete
|
||||||
|
stage identity, is unsupported local state: move it aside and regenerate it,
|
||||||
|
or restore a verified current committed snapshot when that is authoritative.
|
||||||
|
|
||||||
|
Prepare records independent `party` and `players` input checksums. In canonical
|
||||||
|
party mode, the party record retains its campaign source identity while the
|
||||||
|
players record uses `derived_from_party`; raw roster content is never embedded
|
||||||
|
in manifest metadata. Both records remain the durable authority for consumers
|
||||||
|
of their prepared input source IDs.
|
||||||
|
|
||||||
The model admits these stage states:
|
The model admits these stage states:
|
||||||
|
|
||||||
- `pending`
|
- `pending`
|
||||||
@@ -37,11 +52,11 @@ The model admits these stage states:
|
|||||||
|
|
||||||
### Analyze-owned artifact state
|
### Analyze-owned artifact state
|
||||||
|
|
||||||
The `analyze` stage record may carry `analyze_state_version: 1` and an
|
Every `analyze` stage record carries `analyze_state_version: 1` and an
|
||||||
`analyze_artifacts` map keyed by normalized configured artifact key. The
|
`analyze_artifacts` map keyed by normalized configured artifact key. The
|
||||||
version is the authority marker: version 1 with no entries is a valid evaluated
|
version is the authority marker: version 1 with no entries is a valid evaluated
|
||||||
empty set, while an absent version is legacy aggregate-only state and provides
|
empty set. An absent or unsupported version is unsupported local state and is
|
||||||
no current configured-artifact evidence.
|
rejected rather than being treated as aggregate-only authority.
|
||||||
|
|
||||||
Each analyze artifact record has one disposition:
|
Each analyze artifact record has one disposition:
|
||||||
|
|
||||||
@@ -60,19 +75,21 @@ checksum, and positive byte size. Non-current records cannot carry an output,
|
|||||||
so an older file is not advertised through stale, missing, failed, or
|
so an older file is not advertised through stale, missing, failed, or
|
||||||
unselected state.
|
unselected state.
|
||||||
|
|
||||||
The session-stage collection is the reconciled authority across invocations.
|
Family-produced records additionally retain optional `family` and
|
||||||
The corresponding collection on an invocation's `analyze` stage record is an
|
`character_id` provenance supplied by configuration resolution. These fields
|
||||||
audit of only the artifacts evaluated or attempted by that run. These records
|
do not replace the concrete configured key or infer family membership from a
|
||||||
remain analyze-owned data inside the fixed stage; they are not dynamic stages
|
name, so older records without them remain valid.
|
||||||
or generic subtasks.
|
|
||||||
|
|
||||||
The stage result contract has one analyze-specific projection boundary. On
|
The session-stage collection is the reconciled authority across invocations.
|
||||||
success, the runner validates and deep-copies the complete reconciled session
|
These records remain analyze-owned data inside the fixed stage; they are not
|
||||||
collection and the invocation subset. Aggregate session outputs are rebuilt in
|
dynamic stages or generic subtasks.
|
||||||
configured-key order from current session records only; invocation outputs are
|
|
||||||
limited to current records produced by that invocation's run ID. Ordinary
|
Analyze checkpoints one complete validated candidate collection after each
|
||||||
stage outputs cannot accompany this projection, so there is one source of
|
successful artifact and before exposing that artifact to later work. It saves a
|
||||||
artifact authority.
|
detached manifest candidate and installs its version, collection, and updated
|
||||||
|
timestamp into live state only after the store reports success. Aggregate stage
|
||||||
|
outputs are ordinary result outputs rebuilt in configured-key order from current
|
||||||
|
session records only.
|
||||||
|
|
||||||
Successful incremental execution replaces only evaluated artifact records and
|
Successful incremental execution replaces only evaluated artifact records and
|
||||||
preserves valid unrelated current records. Rebuilt outputs are compared by
|
preserves valid unrelated current records. Rebuilt outputs are compared by
|
||||||
@@ -81,42 +98,31 @@ the same recomputed fingerprint to remain current, while a changed identity
|
|||||||
removes output authority from every unselected transitive dependent by marking
|
removes output authority from every unselected transitive dependent by marking
|
||||||
it stale. A partial analyze invocation can therefore succeed while unrelated
|
it stale. A partial analyze invocation can therefore succeed while unrelated
|
||||||
configured records remain stale. Existing canonical files never create current
|
configured records remain stale. Existing canonical files never create current
|
||||||
records without validated execution and projection.
|
records without validated execution and a successful checkpoint.
|
||||||
|
|
||||||
Aggregate analyze status is deliberately coarser than this collection. Resume
|
Aggregate analyze status is deliberately coarser than this collection. Resume
|
||||||
validation may skip a succeeded aggregate record when the selected artifact
|
validation may skip a succeeded aggregate record when the selected artifact
|
||||||
closure is current even if unrelated records are stale. Conversely, a stale
|
closure is current even if unrelated records are stale. Conversely, a stale
|
||||||
aggregate record may cross the ordinary runner boundary and perform zero
|
aggregate record may cross the ordinary runner boundary and perform zero
|
||||||
Scriptorium calls when reconciliation proves every selected artifact current;
|
Scriptorium calls when reconciliation proves every selected artifact current;
|
||||||
the successful projection then restores the aggregate status.
|
ordinary successful stage completion then restores the aggregate status.
|
||||||
|
|
||||||
Analyze may return a projection together with an error. That restricted result
|
On an artifact error, analyze installs a complete retry collection on the live
|
||||||
cannot carry ordinary outputs, skip state, aggregate logs, generated configs,
|
manifest before returning. The active record is failed without output, current
|
||||||
or metadata. The runner persists only the validated per-artifact collections,
|
transitive dependents are stale, unrelated current records survive, and only
|
||||||
then marks the aggregate analyze and run state failed and invalidates delivery
|
earlier checkpointed completions remain current. The runner's ordinary failure
|
||||||
dependents conservatively. Unrelated current records survive because the
|
path marks aggregate analyze failed, clears aggregate outputs, invalidates
|
||||||
session projection is complete. A malformed projection is not applied, and a
|
delivery dependents, and saves that collection. A terminal session-save failure
|
||||||
failed session projection save restores the prior per-artifact authority before
|
is joined with the original adapter, validation, filesystem, cancellation, or
|
||||||
terminal failure persistence.
|
checkpoint cause.
|
||||||
|
|
||||||
The incremental executor constructs this restricted projection at each
|
If an artifact checkpoint fails, analyze rolls its working collection back to
|
||||||
scheduled artifact boundary. The active record is failed without output,
|
the last committed collection before constructing the active failed record.
|
||||||
current transitive dependents are stale, unrelated current records survive, and
|
Newly materialized canonical bytes therefore remain incidental and cannot
|
||||||
only earlier validated and materialized completions remain current in the
|
become current authority. A later ordinary run reconciles the durable records,
|
||||||
invocation subset. Session failure state is persisted before invocation failure
|
reuses earlier current artifacts, and resumes with the failed or stale work.
|
||||||
state. If either terminal save fails, its persistence error is joined with the
|
The same recovery applies after process interruption: the aggregate record may
|
||||||
original adapter, validation, or filesystem cause; a failed projection save
|
be non-successful, but every completed artifact checkpoint remains reusable.
|
||||||
does not turn incidental canonical bytes into manifest authority.
|
|
||||||
|
|
||||||
## Run Manifest
|
|
||||||
|
|
||||||
`manifest.RunManifest` is created for each invocation and records:
|
|
||||||
|
|
||||||
- invocation identity and `force` flag
|
|
||||||
- requested stages
|
|
||||||
- per-stage action (`run` or `skip`)
|
|
||||||
- per-stage status
|
|
||||||
- overall run status (`running`, `succeeded`, `failed`)
|
|
||||||
|
|
||||||
## Remote Commit Manifest
|
## Remote Commit Manifest
|
||||||
|
|
||||||
@@ -133,44 +139,59 @@ binds that object by checksum, size, and generation. Readers strictly reject
|
|||||||
unknown fields, version mismatches, pointer/commit identity mismatches, and
|
unknown fields, version mismatches, pointer/commit identity mismatches, and
|
||||||
objects that do not match their declaration.
|
objects that do not match their declaration.
|
||||||
|
|
||||||
The reader retains a temporary, clearly isolated compatibility path for a
|
An absent commit pointer means no remote state is published. Republish to
|
||||||
coherent legacy `current/manifest.json` plus `current/run_id.txt` pair. That
|
establish a current immutable snapshot.
|
||||||
path is removable after migration and is never used to write new state.
|
|
||||||
|
|
||||||
## Persistence Semantics
|
## Persistence Semantics
|
||||||
|
|
||||||
`manifest.LocalStore`:
|
`manifest.LocalStore`:
|
||||||
|
|
||||||
- validates loaded documents;
|
- accepts only the exact supported session-manifest schema version;
|
||||||
- normalizes missing maps/stage records;
|
- rejects unknown fields and multiple JSON values;
|
||||||
- writes through a sibling temporary file, syncing the completed file and
|
- validates stage-map identity without synthesizing missing maps, records, or
|
||||||
destination directory after atomic replacement;
|
names;
|
||||||
|
- retains only representation-preserving collection normalization;
|
||||||
|
- writes through a sibling temporary file and atomic replacement visibility;
|
||||||
- updates `updated_at` on save.
|
- updates `updated_at` on save.
|
||||||
|
|
||||||
If the operating system or filesystem cannot sync a directory, save returns an
|
Manifest replacement provides atomic visibility, not a crash- or power-loss
|
||||||
explicit error instead of claiming crash-durable replacement. A returned error
|
durability guarantee. Callers reload after a returned save error before retrying.
|
||||||
after the rename can therefore leave the new manifest visible but not confirmed
|
An analyze checkpoint error leaves the prior manifest as per-artifact authority.
|
||||||
durable; callers must reload it before retrying.
|
The runner then attempts to persist the bounded failed/stale retry collection;
|
||||||
|
if that terminal save also fails, its error is joined with the originating
|
||||||
|
failure and the next invocation must reconcile from the last readable manifest.
|
||||||
|
|
||||||
## Execution Semantics
|
## Execution Semantics
|
||||||
|
|
||||||
The application runner marks an executing stage running and then succeeded or
|
Planning and execution share one pure pre-execution decision over the recorded
|
||||||
failed in both manifests, persisting each transition. On success it records
|
stage status, force selection, and normalized resume-validation result. That
|
||||||
outputs, logs, generated configuration references, and metadata. Artifact
|
decision identifies run versus reuse and any required stale and dependent
|
||||||
|
invalidation transition. Planning applies it only to a cloned manifest.
|
||||||
|
Execution remains the sole owner of observed stage results and persistence.
|
||||||
|
|
||||||
|
For each selected stage, the application runner computes semantic evidence,
|
||||||
|
performs any eligible resume validation, applies the pre-execution decision,
|
||||||
|
and persists required invalidation before either reusing the existing success
|
||||||
|
or beginning an attempt. The same one-stage execution owner marks the attempt
|
||||||
|
running before invoking the stage and then records and persists its failed,
|
||||||
|
skipped, or successful terminal state before a later stage may start. On
|
||||||
|
success it records outputs, logs, generated configuration references, metadata,
|
||||||
|
and—when the stage implements the optional contract—a versioned
|
||||||
|
semantic-configuration fingerprint. Artifact
|
||||||
records may include optional contract and external provenance objects; old
|
records may include optional contract and external provenance objects; old
|
||||||
manifests remain compatible when those fields are absent. A successful forced
|
manifests remain compatible when those fields are absent. A successful forced
|
||||||
rerun marks only succeeded transitive dependent session-stage records stale.
|
rerun marks only succeeded transitive dependent session-stage records stale.
|
||||||
The application owns a fixed dependency relation distinct from execution order;
|
The application owns a fixed dependency relation distinct from execution order;
|
||||||
dependents are returned in canonical order. Render and extract therefore never
|
dependents are returned in canonical order. Render and extract therefore never
|
||||||
stale one another, while either can stale analyze, publish, and notify.
|
stale one another, while either can stale analyze, publish, notify, and cleanup.
|
||||||
|
|
||||||
Starting an execution clears the current session-stage record's prior outputs,
|
Starting an execution clears the current session-stage record's prior outputs,
|
||||||
logs, generated configuration references, and metadata. Failed and skipped
|
logs, generated configuration references, metadata, and semantic fingerprint.
|
||||||
|
Failed and skipped
|
||||||
transitions enforce the same clearing rule directly, while success repopulates
|
transitions enforce the same clearing rule directly, while success repopulates
|
||||||
only fields returned by the new result. Marking a record stale does not clear
|
only fields returned by the new result. Marking a record stale does not clear
|
||||||
those details because resume validation and diagnosis may still require them
|
those details because resume validation and diagnosis may still require them
|
||||||
before execution begins. Invocation run manifests remain immutable audit
|
before execution begins.
|
||||||
records of their own outcomes.
|
|
||||||
|
|
||||||
Aggregate lifecycle clearing deliberately preserves the analyze-owned
|
Aggregate lifecycle clearing deliberately preserves the analyze-owned
|
||||||
per-artifact collection. This lets later reconciliation replace only evaluated
|
per-artifact collection. This lets later reconciliation replace only evaluated
|
||||||
@@ -179,30 +200,75 @@ existing aggregate-only lifecycle behavior and are forbidden from carrying the
|
|||||||
analyze-specific fields.
|
analyze-specific fields.
|
||||||
|
|
||||||
A stage may explicitly return a skipped disposition and stable reason. The
|
A stage may explicitly return a skipped disposition and stable reason. The
|
||||||
runner persists that outcome in both manifests, clears older outputs for the
|
runner persists that outcome in the session manifest, clears older outputs,
|
||||||
session-stage record along with older logs, generated configuration references,
|
logs, generated configuration references, and metadata, then applies any
|
||||||
and metadata, then applies any bounded details from the current skip and
|
bounded details from the current skip and continues. This self-skip is distinct from deciding not to execute an
|
||||||
continues. This self-skip is distinct from deciding not to execute an
|
|
||||||
already-succeeded stage and is reconsidered on later runs. Skipped results
|
already-succeeded stage and is reconsidered on later runs. Skipped results
|
||||||
cannot contain outputs.
|
cannot contain outputs. An intentional self-skip records the current semantic
|
||||||
|
fingerprint because it is a completed, reusable stage result; failed or
|
||||||
|
interrupted work never promotes one.
|
||||||
|
|
||||||
When an already-succeeded stage is skipped, the invocation run manifest records
|
When an already-succeeded stage is skipped, the session manifest deliberately
|
||||||
the `skip` action and reason. The session manifest deliberately retains its
|
retains its existing succeeded record because it remains the cross-invocation
|
||||||
existing succeeded record because it remains the cross-invocation progress
|
progress authority. If a stage supplies semantic configuration evidence, reuse first
|
||||||
authority. Extraction and analyze have resume validators and may reject an
|
requires the persisted positive schema version and lowercase SHA-256 digest to
|
||||||
otherwise eligible skip when their selected durable evidence is obsolete; the
|
match the current resolved stage semantics. Missing semantic evidence, malformed
|
||||||
runner marks the aggregate record stale and executes it. Analyze's validator
|
evidence, or a mismatch makes the stage and its fixed transitive dependents
|
||||||
can still accept a partial selection when only unrelated artifact records are
|
stale. The existing stage-specific resume
|
||||||
stale.
|
validator runs only after this semantic check succeeds; both checks are
|
||||||
|
required. Extraction, analyze, and cleanup have resume validators and may
|
||||||
|
reject an otherwise eligible skip when their selected durable evidence is
|
||||||
|
obsolete; the runner marks the aggregate record stale and executes it.
|
||||||
|
Analyze's validator can still accept a partial selection when only unrelated
|
||||||
|
artifact records are stale.
|
||||||
|
|
||||||
Session manifest is the authoritative stage-progress ledger across invocations.
|
Implemented reuse coverage is deliberately split between aggregate semantic
|
||||||
Run manifest is invocation-scoped audit state.
|
evidence and focused durable validators:
|
||||||
|
|
||||||
|
| Work | Reuse authority | Focused owners |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| prepare | aggregate semantic fingerprint | [prepare](stage-prepare.md) |
|
||||||
|
| transcribe | aggregate semantic fingerprint | [transcribe](stage-transcribe.md), [WhisperX](../integrations/whisperx.md) |
|
||||||
|
| merge | aggregate semantic fingerprint | [merge](stage-merge.md), [Seriatim](../integrations/seriatim.md) |
|
||||||
|
| polish | aggregate semantic fingerprint | [polish](stage-polish.md), [Audita](../integrations/audita.md) |
|
||||||
|
| normalize | aggregate semantic fingerprint | [normalize](stage-normalize.md), [Seriatim](../integrations/seriatim.md) |
|
||||||
|
| trim | aggregate semantic fingerprint | [trim](stage-trim.md), [Scriptorium](../integrations/scriptorium.md), [Seriatim](../integrations/seriatim.md) |
|
||||||
|
| render | aggregate semantic fingerprint | [render](stage-render.md), [Seriatim](../integrations/seriatim.md) |
|
||||||
|
| extract | aggregate semantic fingerprint plus reference/output validator | [extract](stage-extract.md), [Notarius](../integrations/notarius.md) |
|
||||||
|
| analyze artifacts | per-artifact fingerprint, reconciliation, and output validator | [analyze](stage-analyze.md), [Scriptorium](../integrations/scriptorium.md) |
|
||||||
|
| publish | aggregate semantic fingerprint plus immediate lock/commit checks | [publish](stage-publish.md), [storage adapter](adapters.md) |
|
||||||
|
| notify | aggregate delivery-mode fingerprint | [pipeline overview](overview.md), [configuration](../config.md#notifications) |
|
||||||
|
| cleanup | aggregate semantic fingerprint plus publish-evidence and target-absence validator | [cleanup](stage-cleanup.md), [workspace](workspace.md) |
|
||||||
|
|
||||||
|
These contracts record resolved choices Narratio can observe, not operational
|
||||||
|
runner tuning. External model, module, prompt, profile, and configuration-file
|
||||||
|
contents that a tool privately loads remain outside the contract when their
|
||||||
|
configured identifier is unchanged; operators must force the affected work
|
||||||
|
after such a private content change.
|
||||||
|
|
||||||
|
The session manifest is the authoritative stage-progress ledger across
|
||||||
|
invocations. It retains the most recently resolved invocation's bounded
|
||||||
|
configuration provenance. It identifies the selected profile name and source
|
||||||
|
(`default` or `cli`) plus the effective configuration digest, but never a raw
|
||||||
|
secret or profile content. This provenance is informational: it does not
|
||||||
|
participate in stage resume or cache decisions. A profile change therefore
|
||||||
|
invalidates only stages whose semantic configuration changed. When a private
|
||||||
|
external-tool model, module, prompt, or profile changes behind an unchanged
|
||||||
|
configured identifier, use `--force` for the affected work.
|
||||||
|
|
||||||
|
`session plan` computes the same current fingerprint and applies the shared
|
||||||
|
decision and invalidation rules to a cloned manifest. It predicts the runner
|
||||||
|
decision without persisting session state. The semantic fingerprint owner
|
||||||
|
hashes deterministic JSON from stage-owned typed structs; stage providers must
|
||||||
|
exclude secrets, complete effective-configuration dumps, and operational
|
||||||
|
values that cannot affect canonical results. Concrete coverage is owned by the
|
||||||
|
focused stage and integration documents linked above.
|
||||||
|
|
||||||
Before an explicitly bounded execution starts after `prepare`, the application
|
Before an explicitly bounded execution starts after `prepare`, the application
|
||||||
reads the session manifest and accepts only `succeeded` or `skipped` for every
|
reads the session manifest and accepts only `succeeded` or `skipped` for every
|
||||||
excluded canonical prefix stage. The first other status or absent record fails
|
excluded canonical prefix stage. The first other status or absent record fails
|
||||||
the request before layout mutation, adapter initialization, session-manifest
|
the request before layout mutation, adapter initialization, session-manifest
|
||||||
writes, or run-manifest creation. Excluded prefix records are not passed to
|
writes, or run-directory creation. Excluded prefix records are not passed to
|
||||||
resume validators. Records after the selected end are not prerequisites and
|
resume validators. Records after the selected end are not prerequisites and
|
||||||
may be made stale by selected work without being scheduled.
|
may be made stale by selected work without being scheduled.
|
||||||
|
|
||||||
@@ -219,30 +285,30 @@ session manifest must agree on campaign and session identity before execution;
|
|||||||
the current projection is refreshed for every invocation while stage progress,
|
the current projection is refreshed for every invocation while stage progress,
|
||||||
inputs, and durable artifacts remain session history.
|
inputs, and durable artifacts remain session history.
|
||||||
|
|
||||||
For handled failures after an invocation record is created, the runner records
|
For handled failures after the session identity transition, the runner records
|
||||||
the failure on the session ledger and persists it before persisting the failed
|
the failure on the session ledger and atomically attempts its save while
|
||||||
run audit record. This preserves the resume authority while making a partial
|
preserving the originating error. Abrupt process death remains an accepted case
|
||||||
persistence disagreement visible. Abrupt process death remains an accepted case
|
where the last session transition can require operator interpretation.
|
||||||
where a durable running record can require operator interpretation.
|
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- stage resume/skip decisions are session-manifest driven.
|
- stage resume/skip decisions are session-manifest driven.
|
||||||
|
- semantic fingerprint comparison precedes stage-specific resume validation.
|
||||||
|
- only successful and intentional-skipped results promote current semantic
|
||||||
|
evidence.
|
||||||
- running, failed, and self-skipped stages do not retain result payloads from
|
- running, failed, and self-skipped stages do not retain result payloads from
|
||||||
an earlier success.
|
an earlier success.
|
||||||
- stale stages retain prior details until replacement execution starts.
|
- stale stages retain prior details until replacement execution starts.
|
||||||
- force reruns stale succeeded stages in the fixed dependency relation.
|
- force reruns stale succeeded stages in the fixed dependency relation.
|
||||||
- run manifest does not replace session manifest as progress authority.
|
- the session manifest is the only durable local progress ledger.
|
||||||
- remote commitment is established by a verified current pointer and remote
|
- remote commitment is established by a verified current pointer and remote
|
||||||
commit relationship, never by a mutable session-manifest boolean.
|
commit relationship, never by a mutable session-manifest boolean.
|
||||||
|
|
||||||
## Implementation And Tests
|
## Implementation And Tests
|
||||||
|
|
||||||
- Models and transitions: `internal/manifest/manifest.go`,
|
- Models and transitions: `internal/manifest/manifest.go`
|
||||||
`internal/manifest/run_manifest.go`
|
|
||||||
- Remote commit model and readers: `internal/artifacts/remote_commit.go`,
|
- Remote commit model and readers: `internal/artifacts/remote_commit.go`,
|
||||||
`internal/artifacts/current_state_commit.go`,
|
`internal/artifacts/current_state_commit.go`,
|
||||||
`internal/artifacts/current_state_legacy.go`
|
|
||||||
- Persistence and validation: `internal/manifest/store.go`
|
- Persistence and validation: `internal/manifest/store.go`
|
||||||
- Package tests: `internal/manifest/*_test.go`
|
- Package tests: `internal/manifest/*_test.go`
|
||||||
- Assembled execution behavior: `internal/app/runner_test.go`,
|
- Assembled execution behavior: `internal/app/runner_test.go`,
|
||||||
|
|||||||
@@ -22,20 +22,29 @@ session safety controls, and runs commands. Pipeline commands execute the
|
|||||||
canonical stage sequence through adapter interfaces, while manifests record
|
canonical stage sequence through adapter interfaces, while manifests record
|
||||||
progress and artifact services resolve durable inputs and outputs.
|
progress and artifact services resolve durable inputs and outputs.
|
||||||
|
|
||||||
|
The pipeline runner follows one explicit invocation lifecycle: inspect bounded
|
||||||
|
prerequisites without mutation, prepare the workspace layout, acquire one
|
||||||
|
session lock, load or create the authoritative session manifest, compose only
|
||||||
|
the collaborators needed by the selected range, execute each selected stage in
|
||||||
|
order, and then return the summary. Cleanup, when selected, is the ordinary
|
||||||
|
final stage rather than runner finalization work.
|
||||||
|
The lock remains held from manifest initialization through finalization, and a
|
||||||
|
failed stage or required manifest save prevents later stages from starting.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
| Area | Implemented owners | Responsibility |
|
| Area | Implemented owners | Responsibility |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Executable | `cmd/narratio` | Process entry, standard stream wiring, argument handoff, and exit status. |
|
| Executable | `cmd/narratio` | Process entry, standard stream wiring, argument handoff, and exit status. |
|
||||||
| Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, cleanup gates, and user-facing reporting. |
|
| Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, and user-facing reporting. |
|
||||||
| Configuration | `internal/config` | Strict YAML loading, discovery, defaults, normalization, session templating, and validation. |
|
| Configuration | [`internal/config`](configuration.md) | ConfigKit-backed pipeline imports and profiles, canonical party and family expansion, strict YAML loading, defaults, normalization, session templating, and validation. |
|
||||||
| Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. |
|
| Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. |
|
||||||
| External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. |
|
| External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. |
|
||||||
| Manifests | `internal/manifest` | Durable session progress, invocation audit state, stage transitions, validation, and atomic persistence. |
|
| Manifests | `internal/manifest` | Durable session progress, stage transitions, validation, and atomic persistence. |
|
||||||
| Artifacts and paths | `internal/artifacts`, `internal/pathsafe` | Artifact identities and resolution, local and remote path/key models, current-state discovery, and confined relative destinations. |
|
| Artifacts and paths | `internal/artifacts`, `internal/pathsafe` | Artifact identities and resolution, local and remote path/key models, current-state discovery, and confined relative destinations. |
|
||||||
| Previous-session cache | `internal/previouscache` | Deterministic planning and materialization requirements for configured previous-session inputs. |
|
| Previous-session cache | `internal/previouscache` | Deterministic planning and materialization requirements for configured previous-session inputs. |
|
||||||
| Artifact policy | `internal/artifactpolicy` | Source and destination policy, configured artifact identity validation, and publish destination safety. |
|
| Artifact policy | `internal/artifactpolicy` | Source and destination policy, configured artifact identity validation, and publish destination safety. |
|
||||||
| Shared models and file operations | `internal/artifactmodel`, `internal/contracts`, [`internal/fileops`](fileops.md) | Transcript and artifact data contracts plus durable single-file replacement helpers; unsupported directory syncing is reported explicitly. |
|
| Shared models and file operations | `internal/artifactmodel`, `internal/contracts`, [`internal/fileops`](fileops.md) | Transcript and artifact data contracts plus atomic single-file replacement, bounded reads, workspace modes, and root-scoped cleanup helpers. |
|
||||||
| Logging | `internal/logging` | Application logger construction and shared structured logging behavior. |
|
| Logging | `internal/logging` | Application logger construction and shared structured logging behavior. |
|
||||||
|
|
||||||
The application boundary composes concrete implementations. Stages depend on
|
The application boundary composes concrete implementations. Stages depend on
|
||||||
@@ -43,13 +52,24 @@ Narratio-level contracts; external transport and SDK details remain in
|
|||||||
adapters. The normative rules for these relationships remain in
|
adapters. The normative rules for these relationships remain in
|
||||||
[Architecture](../policy/architecture.md).
|
[Architecture](../policy/architecture.md).
|
||||||
|
|
||||||
|
Command execution normalizes one private dependency value per invocation.
|
||||||
|
Configuration reads, secret-directory reads, remote downloads, object-store
|
||||||
|
construction, and restore phase functions use that value throughout the
|
||||||
|
command. Public command entry points supply production defaults, while focused
|
||||||
|
tests replace only the external or sequencing boundary they exercise without
|
||||||
|
mutating package state.
|
||||||
|
|
||||||
Pipeline execution and `session plan` share the same inclusive contiguous-range
|
Pipeline execution and `session plan` share the same inclusive contiguous-range
|
||||||
model. Planning clones session state and applies selected-stage transitions and
|
model and the same pure pre-execution decision over recorded status, force, and
|
||||||
resume validation in memory; it does not create invocation state or initialize
|
normalized resume validation. Planning applies that decision and modeled
|
||||||
stage-execution adapters. Command configuration loading can still retrieve a
|
selected-stage transitions to cloned session state; it does not persist, create
|
||||||
missing session file through configured remote storage. Analyze planning
|
invocation state, initialize stage-execution adapters, or claim to observe an
|
||||||
additionally exposes the artifact closure's targets, prerequisite rebuilds,
|
execution result. Command configuration loading can still retrieve a
|
||||||
execution order, and current reuse.
|
missing session file through configured remote storage. It retains the initially
|
||||||
|
composed pipeline and selected campaign while resolving either a local or
|
||||||
|
downloaded remote session, so one invocation cannot mix pipeline revisions.
|
||||||
|
Analyze planning additionally exposes the artifact closure's targets,
|
||||||
|
prerequisite rebuilds, execution order, and current reuse.
|
||||||
|
|
||||||
## Pipeline Stage Set
|
## Pipeline Stage Set
|
||||||
|
|
||||||
@@ -66,10 +86,12 @@ The implemented canonical order is:
|
|||||||
9. [`analyze`](stage-analyze.md)
|
9. [`analyze`](stage-analyze.md)
|
||||||
10. [`publish`](stage-publish.md)
|
10. [`publish`](stage-publish.md)
|
||||||
11. `notify` (no-op)
|
11. `notify` (no-op)
|
||||||
|
12. [`cleanup`](stage-cleanup.md)
|
||||||
|
|
||||||
`notify` currently has no persisted pipeline outputs and uses the explicit
|
`notify` currently has no persisted pipeline outputs and uses the explicit
|
||||||
`noop` notification mode. The focused stage documents own implementation
|
`noop` notification mode. Its versioned semantic evidence records that delivery
|
||||||
mechanics. The
|
mode and excludes adapter credentials and response data. The focused stage
|
||||||
|
documents own implementation mechanics. The
|
||||||
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
|
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
|
||||||
and execution semantics.
|
and execution semantics.
|
||||||
|
|
||||||
@@ -78,16 +100,19 @@ registry owns the flat execution sequence. The application orchestration owner
|
|||||||
uses a fixed, validated dependency relation to find transitive dependents in
|
uses a fixed, validated dependency relation to find transitive dependents in
|
||||||
canonical order. In particular, `render` and `extract` are sibling consumers of
|
canonical order. In particular, `render` and `extract` are sibling consumers of
|
||||||
trimmed transcript state: neither invalidates the other, while either can stale
|
trimmed transcript state: neither invalidates the other, while either can stale
|
||||||
`analyze`, `publish`, and `notify`.
|
`analyze`, `publish`, `notify`, and `cleanup`. Cleanup is a dependent of notify,
|
||||||
|
so any earlier replacement can stale a prior cleanup result.
|
||||||
|
|
||||||
## Focused Documentation
|
## Focused Documentation
|
||||||
|
|
||||||
|
- [Configuration Internals](configuration.md): pipeline imports and profiles,
|
||||||
|
field ownership, decoding, and root-relative path semantics.
|
||||||
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
|
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
|
||||||
failure behavior, and test surfaces.
|
failure behavior, and test surfaces.
|
||||||
- [Artifact Internals](artifacts.md): source identities, runtime catalog,
|
- [Artifact Internals](artifacts.md): source identities, runtime catalog,
|
||||||
resolution, previous requirements, and current-state helpers.
|
resolution, previous requirements, and current-state helpers.
|
||||||
- [Manifest Internals](manifest.md): session and run records, persistence, and
|
- [Manifest Internals](manifest.md): session records, persistence, and execution
|
||||||
execution transitions.
|
transitions.
|
||||||
- [Storage Internals](storage.md): object-store interface and S3 behavior.
|
- [Storage Internals](storage.md): object-store interface and S3 behavior.
|
||||||
- [Workspace Internals](workspace.md): local layout, locking, and cleanup
|
- [Workspace Internals](workspace.md): local layout, locking, and cleanup
|
||||||
guardrails.
|
guardrails.
|
||||||
@@ -103,6 +128,7 @@ trimmed transcript state: neither invalidates the other, while either can stale
|
|||||||
- [`extract`](stage-extract.md)
|
- [`extract`](stage-extract.md)
|
||||||
- [`analyze`](stage-analyze.md)
|
- [`analyze`](stage-analyze.md)
|
||||||
- [`publish`](stage-publish.md)
|
- [`publish`](stage-publish.md)
|
||||||
|
- [`cleanup`](stage-cleanup.md)
|
||||||
|
|
||||||
Use this map to find an owner, then read its focused documentation and tests
|
Use this map to find an owner, then read its focused documentation and tests
|
||||||
before changing behavior.
|
before changing behavior.
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ dependency order, and safely materialize validated outputs.
|
|||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- configured artifacts from `pipeline.scriptorium.artifacts`
|
- ordinary configured artifacts from `pipeline.scriptorium.artifacts`; canonical
|
||||||
|
party artifact families have already expanded into this map during
|
||||||
|
configuration resolution, including corresponding member dependencies and
|
||||||
|
rewritten member-artifact input sources. Each artifact's dependency list is
|
||||||
|
already the normalized union of explicit ordering edges and configured-artifact
|
||||||
|
input edges.
|
||||||
- optional selected artifact keys supplied through the stage environment
|
- optional selected artifact keys supplied through the stage environment
|
||||||
- built-in, configured, extraction, and previous-session source references in
|
- built-in, configured, extraction, and previous-session source references in
|
||||||
artifact inputs
|
artifact inputs
|
||||||
@@ -28,12 +33,14 @@ Supported source families:
|
|||||||
## Key Behavior
|
## Key Behavior
|
||||||
|
|
||||||
- when `pipeline.scriptorium` is absent or no configured artifact is
|
- when `pipeline.scriptorium` is absent or no configured artifact is
|
||||||
executable, completes successfully with no outputs and records explanatory
|
executable, completes successfully and records explanatory metadata. Before
|
||||||
metadata. This is not an explicit self-skip: both manifests record success,
|
returning, it reconciles and checkpoints the complete session collection:
|
||||||
satisfy publish's prerequisite, and an ordinary later run reuses the result
|
records removed from configuration are dropped, while valid records for
|
||||||
while the effective set remains empty. Enabling or selecting an artifact
|
configured but unselected artifacts remain authoritative. This is not an
|
||||||
later makes missing versioned evidence non-resumable and schedules it without
|
explicit self-skip: the session manifest records success, satisfies publish's
|
||||||
requiring force.
|
prerequisite, and an ordinary later run reuses the result while the effective
|
||||||
|
set remains empty. Enabling or selecting an artifact later makes missing
|
||||||
|
versioned evidence non-resumable and schedules it without requiring force.
|
||||||
- builds a runtime artifact catalog containing built-ins, configured artifacts,
|
- builds a runtime artifact catalog containing built-ins, configured artifacts,
|
||||||
and configured extraction lanes. Extraction availability is hydrated only
|
and configured extraction lanes. Extraction availability is hydrated only
|
||||||
from compatible successful extraction evidence.
|
from compatible successful extraction evidence.
|
||||||
@@ -46,6 +53,9 @@ Supported source families:
|
|||||||
manifest record and durable output pass the configured-artifact evidence
|
manifest record and durable output pass the configured-artifact evidence
|
||||||
contract; an incidental or stale canonical file is unavailable.
|
contract; an incidental or stale canonical file is unavailable.
|
||||||
- validates selected artifact dependency order (cycle-safe topo ordering).
|
- validates selected artifact dependency order (cycle-safe topo ordering).
|
||||||
|
- consumes only the concrete dependency graph finalized by configuration;
|
||||||
|
planning, prerequisite closure, execution, reuse, invalidation, and semantic
|
||||||
|
evidence do not infer a second graph from inputs.
|
||||||
- resolves required/optional inputs per artifact source definition into an
|
- resolves required/optional inputs per artifact source definition into an
|
||||||
ordered semantic identity. Each identity records the configured input name,
|
ordered semantic identity. Each identity records the configured input name,
|
||||||
canonical source ID, required policy, explicit presence, source contract,
|
canonical source ID, required policy, explicit presence, source contract,
|
||||||
@@ -72,7 +82,7 @@ Supported source families:
|
|||||||
validated current dependency-output identities, and sorted effective
|
validated current dependency-output identities, and sorted effective
|
||||||
Scriptorium variables (including Narratio's sticky session variable).
|
Scriptorium variables (including Narratio's sticky session variable).
|
||||||
- provides read-only reconciliation that classifies each configured record as
|
- provides read-only reconciliation that classifies each configured record as
|
||||||
current, stale, missing, failed, legacy, or otherwise non-resumable, and
|
current, stale, missing, failed, or otherwise non-resumable, and
|
||||||
separately identifies manifest records removed from current configuration.
|
separately identifies manifest records removed from current configuration.
|
||||||
A record is current only when its fingerprint version and value match and its
|
A record is current only when its fingerprint version and value match and its
|
||||||
configured output still passes manifest-authoritative evidence validation.
|
configured output still passes manifest-authoritative evidence validation.
|
||||||
@@ -86,13 +96,14 @@ Supported source families:
|
|||||||
rebuilt when required, while unrelated disabled artifacts are excluded.
|
rebuilt when required, while unrelated disabled artifacts are excluded.
|
||||||
- the work plan carries explicit targets, prerequisite-only work, deterministic
|
- the work plan carries explicit targets, prerequisite-only work, deterministic
|
||||||
execution and reuse lists, invalidated and removed records, and a cloned
|
execution and reuse lists, invalidated and removed records, and a cloned
|
||||||
projected record collection. Valid unrelated configured records survive the
|
reconciled starting collection. Valid unrelated configured records survive
|
||||||
projection, removed records are omitted, and legacy files never become
|
reconciliation, removed records are omitted, and incidental files never
|
||||||
current without regeneration.
|
become current without manifest-backed regeneration.
|
||||||
- implements aggregate resume validation by running the same read-only catalog,
|
- implements aggregate resume validation by running the same read-only catalog,
|
||||||
fingerprint reconciliation, and work planner used by execution. A succeeded
|
fingerprint reconciliation, and work planner used by execution. A succeeded
|
||||||
aggregate record is reusable exactly when the selected closure schedules no
|
aggregate record is reusable exactly when the selected closure schedules no
|
||||||
artifact work; stale unrelated records do not block a partial selection.
|
artifact work and no removed configuration records require a checkpoint;
|
||||||
|
stale unrelated records do not block a partial selection.
|
||||||
- exposes the typed artifact decision to `session plan`. Planning applies it to
|
- exposes the typed artifact decision to `session plan`. Planning applies it to
|
||||||
a cloned manifest after modeling earlier selected stage transitions, so
|
a cloned manifest after modeling earlier selected stage transitions, so
|
||||||
aggregate run/skip and artifact execute/reuse decisions match the ordinary
|
aggregate run/skip and artifact execute/reuse decisions match the ordinary
|
||||||
@@ -101,6 +112,10 @@ Supported source families:
|
|||||||
prerequisites remain available through the runtime catalog without invoking
|
prerequisites remain available through the runtime catalog without invoking
|
||||||
Scriptorium; newly produced prerequisites enter that catalog with the same
|
Scriptorium; newly produced prerequisites enter that catalog with the same
|
||||||
contract, checksum, and size identity used for persisted current evidence.
|
contract, checksum, and size identity used for persisted current evidence.
|
||||||
|
- before the first adapter call, checkpoints the reconciled starting collection
|
||||||
|
when it removed configuration records or cleared obsolete authority. After
|
||||||
|
each successful artifact, checkpoints the complete collection before making
|
||||||
|
that artifact available to the next scheduled dependent.
|
||||||
- keeps adapter output in the invocation's run-local analyze directory until
|
- keeps adapter output in the invocation's run-local analyze directory until
|
||||||
it is a safe, non-empty, bounded regular file with a calculated checksum and
|
it is a safe, non-empty, bounded regular file with a calculated checksum and
|
||||||
complete output contract. Canonical replacement uses the shared atomic file
|
complete output contract. Canonical replacement uses the shared atomic file
|
||||||
@@ -109,16 +124,15 @@ Supported source families:
|
|||||||
- records each successful artifact's freshly computed fingerprint, canonical
|
- records each successful artifact's freshly computed fingerprint, canonical
|
||||||
relative output path, contract, checksum, size, producer run ID, bounded
|
relative output path, contract, checksum, size, producer run ID, bounded
|
||||||
Scriptorium provenance, logs, and generated configuration references in the
|
Scriptorium provenance, logs, and generated configuration references in the
|
||||||
analyze-owned projection.
|
analyze-owned manifest collection.
|
||||||
- preserves valid unrelated current records during partial execution. If a
|
- preserves valid unrelated current records during partial execution. If a
|
||||||
rebuilt output's bytes and contract are unchanged, unselected dependents may
|
rebuilt output's bytes and contract are unchanged, unselected dependents may
|
||||||
remain current. If that semantic identity changes, unselected transitive
|
remain current. If that semantic identity changes, unselected transitive
|
||||||
dependents become stale without being executed; dependents included in the
|
dependents become stale without being executed; dependents included in the
|
||||||
invocation are evaluated in dependency order instead.
|
invocation are evaluated in dependency order instead.
|
||||||
- reports all evaluated targets and prerequisites in invocation state. The
|
- returns aggregate outputs from every current session record in normalized-key
|
||||||
runner reconstructs aggregate session outputs from every current session
|
order after the complete reconciled collection has been checkpointed;
|
||||||
record and invocation outputs from only records produced by the current run.
|
unrelated stale records do not make an otherwise successful partial
|
||||||
Unrelated stale records do not make an otherwise successful partial
|
|
||||||
invocation fail.
|
invocation fail.
|
||||||
- resolves previous-session sources from local `previous/` cache only.
|
- resolves previous-session sources from local `previous/` cache only.
|
||||||
- runs optional render-debug, then artifact execution.
|
- runs optional render-debug, then artifact execution.
|
||||||
@@ -134,25 +148,31 @@ Supported source families:
|
|||||||
guidance.
|
guidance.
|
||||||
- dependency cycles or unavailable required dependencies fail.
|
- dependency cycles or unavailable required dependencies fail.
|
||||||
- adapter validation failures fail stage.
|
- adapter validation failures fail stage.
|
||||||
- a scheduled artifact failure returns the restricted analyze-state projection
|
- a scheduled artifact failure installs a retry collection on the live manifest
|
||||||
with the active artifact marked `failed`, a bounded error, and no output
|
with the active artifact marked `failed`, a bounded error, and no output
|
||||||
authority. Current transitive dependents become stale without execution.
|
authority. Current transitive dependents become stale without execution.
|
||||||
- earlier artifacts from the invocation remain current only after their
|
- earlier artifacts remain current only after their run-local output passed
|
||||||
run-local output passed validation and canonical materialization. They remain
|
validation, canonical materialization, and a successful complete-collection
|
||||||
in invocation history; unattempted later artifacts do not appear there.
|
manifest checkpoint; unattempted later artifacts retain their reconciled
|
||||||
|
session state.
|
||||||
- unrelated current records survive a partial failure. Old canonical bytes for
|
- unrelated current records survive a partial failure. Old canonical bytes for
|
||||||
the failed artifact and newly materialized bytes whose projection cannot be
|
the failed artifact and newly materialized bytes whose checkpoint fails are
|
||||||
persisted are incidental, not current evidence.
|
incidental, not current evidence.
|
||||||
- the runner persists a valid partial projection before it marks aggregate
|
- a checkpoint failure stops the queue immediately. The in-memory retry
|
||||||
analyze failed and invalidates publish and notify through the application
|
collection starts from the last successful checkpoint, marks the active
|
||||||
dependency relation. Projection-persistence errors retain the last durable
|
artifact failed without output authority, and stales its current transitive
|
||||||
per-artifact authority and are joined with the original failure context.
|
dependents.
|
||||||
|
- the runner's ordinary error path marks aggregate analyze failed, clears its
|
||||||
|
aggregate outputs, invalidates publish, notify, and cleanup through the
|
||||||
|
application dependency relation, and persists the live retry collection.
|
||||||
|
Terminal-save errors are joined with the original artifact or checkpoint
|
||||||
|
failure.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- `analyze` performs no remote storage calls for previous-session source resolution.
|
- `analyze` performs no remote storage calls for previous-session source resolution.
|
||||||
- input-identity resolution is read-only: it does not invoke adapters,
|
- input-identity resolution is read-only: it does not invoke adapters,
|
||||||
materialize outputs, update status, or create run records.
|
materialize outputs, update status, or create run-local files.
|
||||||
- fingerprints exclude timeouts, retries, timestamps, producer and Narratio run
|
- fingerprints exclude timeouts, retries, timestamps, producer and Narratio run
|
||||||
IDs, executable and config paths, workspace roots, diagnostic locations, and
|
IDs, executable and config paths, workspace roots, diagnostic locations, and
|
||||||
executable or private transitive configuration contents. A change that is
|
executable or private transitive configuration contents. A change that is
|
||||||
@@ -161,21 +181,30 @@ Supported source families:
|
|||||||
- output provenance and metadata are deterministic per execution.
|
- output provenance and metadata are deterministic per execution.
|
||||||
- a canonical file without current per-artifact manifest evidence is never
|
- a canonical file without current per-artifact manifest evidence is never
|
||||||
promoted to current state.
|
promoted to current state.
|
||||||
|
- an ordinary retry after interruption or partial failure reuses every valid
|
||||||
|
earlier checkpoint and schedules only failed, stale, missing, or otherwise
|
||||||
|
non-current work in dependency order.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Configuration](../config.md#scriptorium-artifact-entries) owns artifact
|
- [Configuration](../config.md#scriptorium-artifact-entries) owns artifact
|
||||||
fields and source-selection rules.
|
fields and source-selection rules, including
|
||||||
|
[artifact families](../config.md#scriptorium-artifact-families).
|
||||||
- [CLI](../cli.md) owns user-visible artifact selection.
|
- [CLI](../cli.md) owns user-visible artifact selection.
|
||||||
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
||||||
- Implementation and tests: `internal/stage/analyze.go`,
|
- Implementation and tests: `internal/stage/analyze.go`,
|
||||||
`internal/stage/analyze_input_identity.go`, `internal/stage/analyze_test.go`,
|
`internal/stage/analyze_artifact_execution.go`,
|
||||||
`internal/stage/analyze_input_identity_test.go`,
|
`internal/stage/analyze_execution_state.go`,
|
||||||
|
`internal/stage/analyze_input_identity.go`,
|
||||||
`internal/stage/analyze_fingerprint.go`,
|
`internal/stage/analyze_fingerprint.go`,
|
||||||
|
`internal/stage/analyze_reconciliation.go`, `internal/stage/analyze_plan.go`,
|
||||||
|
`internal/stage/analyze_resume.go`, `internal/stage/analyze_test.go`,
|
||||||
|
`internal/stage/analyze_checkpoint_test.go`,
|
||||||
|
`internal/stage/analyze_input_identity_test.go`,
|
||||||
`internal/stage/analyze_fingerprint_test.go`,
|
`internal/stage/analyze_fingerprint_test.go`,
|
||||||
`internal/stage/analyze_reconciliation.go`, and
|
|
||||||
`internal/stage/analyze_reconciliation_test.go`,
|
`internal/stage/analyze_reconciliation_test.go`,
|
||||||
`internal/stage/analyze_plan.go`, `internal/stage/analyze_plan_test.go`, and
|
`internal/stage/analyze_plan_test.go`,
|
||||||
`internal/stage/analyze_incremental_execution_test.go`, and
|
`internal/stage/analyze_incremental_execution_test.go`,
|
||||||
`internal/stage/analyze_failure_test.go`,
|
`internal/stage/analyze_failure_test.go`,
|
||||||
`internal/stage/analyze_resume.go`, and `internal/stage/analyze_resume_test.go`
|
`internal/stage/analyze_resume_test.go`, and
|
||||||
|
`internal/app/analyze_checkpoint_recovery_test.go`.
|
||||||
|
|||||||
54
docs/internal/stage-cleanup.md
Normal file
54
docs/internal/stage-cleanup.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Stage: cleanup
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Remove explicitly configured local data for the run selected by a successful
|
||||||
|
publish commit. Cleanup is the twelfth and final canonical stage, after
|
||||||
|
`notify`.
|
||||||
|
|
||||||
|
## Authorization And Targets
|
||||||
|
|
||||||
|
Cleanup is disabled unless at least one of these policies is true:
|
||||||
|
|
||||||
|
- `pipeline.spool.delete_audio_after_publish`
|
||||||
|
- `pipeline.workspace.cleanup_after_publish`
|
||||||
|
|
||||||
|
An enabled cleanup requires the manifest's publish stage to be succeeded with
|
||||||
|
`uploaded=true`, a canonical `published_run_id`, and the exact remote commit and
|
||||||
|
current commit-pointer keys for the manifest campaign/session prefix. The
|
||||||
|
configured campaign and session must match the manifest. Cleanup derives its
|
||||||
|
only possible targets through the canonical artifact path helpers:
|
||||||
|
|
||||||
|
- the published run's session spool-audio directory; and
|
||||||
|
- the published run's workspace run directory.
|
||||||
|
|
||||||
|
It does not scan parent directories, infer the newest run, or read object
|
||||||
|
storage. Every deletion uses the root-confined file operation, which rejects a
|
||||||
|
root itself, traversal, symlink escape, and out-of-root targets.
|
||||||
|
|
||||||
|
## Execution And Resume
|
||||||
|
|
||||||
|
Disabled cleanup records an explicit skipped result. Enabled cleanup removes
|
||||||
|
targets in deterministic policy order and treats an already-missing target as
|
||||||
|
success. A successful record contains the published run ID and completed policy
|
||||||
|
names.
|
||||||
|
|
||||||
|
A previous cleanup success is reusable only while its semantic configuration
|
||||||
|
matches and every selected target remains absent. A recreated target makes the
|
||||||
|
stage non-resumable. A failure is recorded against cleanup; publish remains
|
||||||
|
succeeded and remote current state is unchanged. On a later normal full run,
|
||||||
|
valid upstream stages are skipped and cleanup is retried without republishing.
|
||||||
|
|
||||||
|
`narratio publish` selects publish only and never invokes cleanup. A bounded run
|
||||||
|
through `notify` or any earlier endpoint also excludes it. Operators may run
|
||||||
|
`narratio run-stage cleanup <session_id>` directly; missing, failed, malformed,
|
||||||
|
or identity-mismatched publication evidence fails before deletion.
|
||||||
|
|
||||||
|
## Implementation And Tests
|
||||||
|
|
||||||
|
- Planning and evidence validation: `internal/stage/cleanup_plan.go`
|
||||||
|
- Execution and resume: `internal/stage/cleanup.go`
|
||||||
|
- Root-confined deletion: `internal/fileops/cleanup.go`
|
||||||
|
- Focused tests: `internal/stage/cleanup_plan_test.go`,
|
||||||
|
`internal/stage/cleanup_test.go`, and
|
||||||
|
`internal/app/cleanup_orchestration_test.go`
|
||||||
@@ -21,8 +21,8 @@ procedures belong in [Operations](../operations.md).
|
|||||||
manifest-authoritative identity resolver before creating run-local output;
|
manifest-authoritative identity resolver before creating run-local output;
|
||||||
3. streams each verified reference into an invocation-local snapshot and
|
3. streams each verified reference into an invocation-local snapshot and
|
||||||
rejects any source change observed while copying;
|
rejects any source change observed while copying;
|
||||||
4. fingerprints the Notarius invocation contract, including sorted reference
|
4. fingerprints the byte- and provenance-bearing Notarius invocation evidence,
|
||||||
identities;
|
including sorted reference identities;
|
||||||
5. creates a run-local staging directory and invokes the injected
|
5. creates a run-local staging directory and invokes the injected
|
||||||
`notarius.Runner`;
|
`notarius.Runner`;
|
||||||
6. revalidates the reference snapshots, then validates the v2 successful
|
6. revalidates the reference snapshots, then validates the v2 successful
|
||||||
@@ -46,8 +46,8 @@ invocation-local snapshot path, never payload contents. Snapshot bytes must
|
|||||||
match the prepared identity both before and after Notarius runs, so a concurrent
|
match the prepared identity both before and after Notarius runs, so a concurrent
|
||||||
prepared-file replacement cannot make recorded provenance describe different
|
prepared-file replacement cannot make recorded provenance describe different
|
||||||
bytes from those supplied to Notarius.
|
bytes from those supplied to Notarius.
|
||||||
Validation completes before
|
Validation completes before the single durable rename, so a rejected result
|
||||||
promotion, so a rejected result cannot expose a partial durable bundle.
|
cannot expose a partial durable bundle.
|
||||||
|
|
||||||
Any executed extraction outcome that replaces a different effective outcome
|
Any executed extraction outcome that replaces a different effective outcome
|
||||||
marks succeeded analysis and delivery dependents stale. Render is an independent
|
marks succeeded analysis and delivery dependents stale. Render is an independent
|
||||||
@@ -56,14 +56,20 @@ outputs is stable and does not repeatedly invalidate dependent stages.
|
|||||||
|
|
||||||
## Resume Validation
|
## Resume Validation
|
||||||
|
|
||||||
`internal/stage/extract_resume.go` permits a skip only when the existing stage
|
Before the focused validator runs, the application compares extract's versioned
|
||||||
record succeeded and still matches the current invocation fingerprint. The
|
semantic fingerprint. It covers enablement, Notarius pipeline identity, sorted
|
||||||
fingerprint covers the resolved executable and config paths, pipeline ID,
|
reference selector/source mappings, sorted declared output contracts, and each
|
||||||
timeout, working directory, sorted configured output contracts, the current
|
canonical `narratio.extraction.<key>` output identity. It excludes executable,
|
||||||
direct trimmed-transcript identity, and sorted prepared-reference identities.
|
timeout, working directory, config path, and private Notarius config contents.
|
||||||
The same reference helper and transcript identity are resolved again for
|
|
||||||
artifact evidence, so changing the current transcript bytes or producer
|
`internal/stage/extract_resume.go` then permits a skip only when the existing
|
||||||
identity makes the prior extraction obsolete.
|
stage record still matches the current byte- and provenance-bearing invocation
|
||||||
|
evidence. That evidence covers the current direct trimmed-transcript identity,
|
||||||
|
sorted prepared-reference identities, pipeline identity, and configured output
|
||||||
|
contracts. The same reference helper and transcript identity are resolved again
|
||||||
|
for artifact evidence, so changing current transcript bytes, reference bytes,
|
||||||
|
or producer identity makes the prior extraction obsolete. Operational runner
|
||||||
|
settings do not invalidate otherwise current durable evidence.
|
||||||
|
|
||||||
A valid prepared-reference change makes extraction non-resumable. Missing,
|
A valid prepared-reference change makes extraction non-resumable. Missing,
|
||||||
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
|
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
|
||||||
@@ -76,32 +82,34 @@ contracts and provenance, regular-file status, and stored checksums. Missing or
|
|||||||
obsolete results are non-resumable and run again; unsafe filesystem conditions
|
obsolete results are non-resumable and run again; unsafe filesystem conditions
|
||||||
return an error rather than silently accepting or replacing data.
|
return an error rather than silently accepting or replacing data.
|
||||||
|
|
||||||
The fingerprint cannot observe files imported by Notarius configuration,
|
Neither contract can observe files imported by Notarius configuration, profile
|
||||||
profile contents, prompt/module definitions, or other transitive inputs.
|
contents, prompt/module definitions, or other transitive inputs. Operators must
|
||||||
Operators must force extraction after changing any such input.
|
force extraction after changing any such private input behind a stable
|
||||||
|
identifier.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
Adapter startup, timeout, nonzero exit, receipt decoding, path confinement,
|
Adapter startup, timeout, nonzero exit, receipt decoding, path confinement,
|
||||||
index compatibility, inconsistent warning or diagnostic envelopes,
|
index compatibility, inconsistent warning or diagnostic envelopes,
|
||||||
required-lane rejection or incomplete validation, payload inspection,
|
required-lane rejection or incomplete validation, payload inspection,
|
||||||
checksum, or promotion errors fail the stage through ordinary manifest
|
checksum, or installation errors fail the stage through ordinary manifest
|
||||||
transition handling.
|
transition handling.
|
||||||
Stdout receipt and stderr diagnostics remain separate. Downstream stages are
|
Stdout receipt and stderr diagnostics remain separate. Downstream stages are
|
||||||
not given selectable extraction sources unless the complete configured result
|
not given selectable extraction sources unless the complete configured result
|
||||||
has passed validation and promotion.
|
has passed validation and installation.
|
||||||
|
|
||||||
When a replacement attempt begins, the current session-stage record no longer
|
When a replacement attempt begins, the current session-stage record no longer
|
||||||
advertises payload from the previous success. A failed replacement therefore
|
advertises payload from the previous success. A failed replacement therefore
|
||||||
has no current outputs, logs, generated configuration references, or metadata,
|
has no current outputs, logs, generated configuration references, or metadata,
|
||||||
while the earlier invocation manifest and immutable promoted bundle remain
|
while the earlier immutable promoted bundle remains available for audit and
|
||||||
available for audit and recovery.
|
recovery.
|
||||||
|
|
||||||
## Implementation And Focused Tests
|
## Implementation And Focused Tests
|
||||||
|
|
||||||
- Stage execution, selection, and resume validation: `internal/stage/extract.go`,
|
- Stage execution, selection, and resume validation: `internal/stage/extract.go`,
|
||||||
`internal/stage/extract_resume.go`,
|
`internal/stage/extract_resume.go`,
|
||||||
`internal/stage/extract_test.go`
|
`internal/stage/extract_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_delivery.go`
|
||||||
- Subprocess boundary: `internal/adapters/notarius/subprocess.go`,
|
- Subprocess boundary: `internal/adapters/notarius/subprocess.go`,
|
||||||
`internal/adapters/notarius/subprocess_test.go`
|
`internal/adapters/notarius/subprocess_test.go`
|
||||||
- Catalog hydration: `internal/artifacts/extraction_catalog.go`,
|
- Catalog hydration: `internal/artifacts/extraction_catalog.go`,
|
||||||
|
|||||||
@@ -29,9 +29,23 @@ Normalize raw transcript inputs and merge into base transcript via Seriatim.
|
|||||||
- base transcript must validate before stage success.
|
- base transcript must validate before stage success.
|
||||||
- report output is config-gated.
|
- report output is config-gated.
|
||||||
|
|
||||||
|
## Resume Evidence
|
||||||
|
|
||||||
|
Merge records a versioned semantic-configuration fingerprint for the Seriatim
|
||||||
|
merge operation, output schema, coalesce gap, and every configured advanced
|
||||||
|
merge transformation. A change reruns merge and stales only its fixed
|
||||||
|
descendants; prepare and transcribe remain reusable. Binary path, timeout,
|
||||||
|
report emission, logs, and diagnostic retention are operational exclusions.
|
||||||
|
|
||||||
|
Configuration or resources loaded privately inside Seriatim are outside
|
||||||
|
Narratio's observable contract and require `--force` when changed. An existing
|
||||||
|
successful merge record without evidence reruns once when selected.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
||||||
- [Configuration](../config.md#pipeline) owns operator-selected Seriatim values.
|
- [Configuration](../config.md#pipeline) owns operator-selected Seriatim values.
|
||||||
- Implementation and tests: `internal/stage/merge.go`,
|
- Implementation and tests: `internal/stage/merge.go`,
|
||||||
`internal/stage/merge_test.go`
|
`internal/stage/merge_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_initial.go`, and
|
||||||
|
`internal/stage/semantic_contracts_initial_test.go`
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Normalize polished transcript into final transcript using Seriatim.
|
|||||||
|
|
||||||
## Key Behavior
|
## Key Behavior
|
||||||
|
|
||||||
- resolves polished transcript from manifest outputs/canonical fallback.
|
- resolves polished transcript only from verified manifest output evidence.
|
||||||
- applies `pipeline.normalize` config or default normalize config.
|
- applies `pipeline.normalize` config or default normalize config.
|
||||||
- runs Seriatim normalize with configured timeout/binary.
|
- runs Seriatim normalize with configured timeout/binary.
|
||||||
- validates normalized transcript and optional report.
|
- validates normalized transcript and optional report.
|
||||||
@@ -26,9 +26,17 @@ Normalize polished transcript into final transcript using Seriatim.
|
|||||||
- final transcript must validate as processed transcript JSON (`segments` array).
|
- final transcript must validate as processed transcript JSON (`segments` array).
|
||||||
- normalize defaults are applied when `pipeline.normalize` is unset.
|
- normalize defaults are applied when `pipeline.normalize` is unset.
|
||||||
|
|
||||||
|
## Resume Semantics
|
||||||
|
|
||||||
|
The versioned semantic fingerprint covers the Seriatim normalize operation,
|
||||||
|
output schema and canonical output identity, plus the configured transcript
|
||||||
|
transformations. Seriatim's executable and timeout and optional report
|
||||||
|
generation are operational and do not invalidate the normalized transcript.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
||||||
- [Configuration](../config.md#pipeline) owns normalize fields and defaults.
|
- [Configuration](../config.md#pipeline) owns normalize fields and defaults.
|
||||||
- Implementation and tests: `internal/stage/normalize.go`,
|
- Implementation and tests: `internal/stage/normalize.go`,
|
||||||
`internal/stage/normalize_test.go`
|
`internal/stage/normalize_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_refinement.go`
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Run Audita polishing on base transcript and produce polished transcript.
|
|||||||
|
|
||||||
## Key Behavior
|
## Key Behavior
|
||||||
|
|
||||||
- resolves base transcript from merge outputs/canonical fallback.
|
- resolves base transcript only from verified merge manifest output evidence.
|
||||||
- invokes an Audita runner configured with static model/runtime options; the
|
- invokes an Audita runner configured with static model/runtime options; the
|
||||||
invocation supplies paths and modules.
|
invocation supplies paths and modules.
|
||||||
- validates processed transcript structure (`segments` array required).
|
- validates processed transcript structure (`segments` array required).
|
||||||
@@ -28,10 +28,27 @@ Run Audita polishing on base transcript and produce polished transcript.
|
|||||||
- polished transcript schema validation is mandatory.
|
- polished transcript schema validation is mandatory.
|
||||||
- report output is config-gated.
|
- report output is config-gated.
|
||||||
|
|
||||||
|
## Resume Semantics
|
||||||
|
|
||||||
|
The versioned semantic fingerprint covers the Audita service endpoint, model,
|
||||||
|
validation model, module set, transcript description, output schema, selected
|
||||||
|
external configuration path, and canonical polished-transcript identity. Module
|
||||||
|
ordering is normalized because the configured modules form a set. Audita's
|
||||||
|
executable, timeouts, concurrency, report and debug behavior, work retention,
|
||||||
|
and resolved credential value are operational and do not invalidate a
|
||||||
|
successful result. The public credential reference name may participate in
|
||||||
|
configuration provenance but never exposes its resolved value.
|
||||||
|
|
||||||
|
Narratio can fingerprint a selected model, module, or configuration identifier,
|
||||||
|
but it cannot inspect content that Audita privately resolves behind that stable
|
||||||
|
identifier. Force `polish` after changing such private content without changing
|
||||||
|
its identifier.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Audita](../integrations/audita.md) owns subprocess, validation, and failure
|
- [Audita](../integrations/audita.md) owns subprocess, validation, and failure
|
||||||
semantics.
|
semantics.
|
||||||
- [Configuration](../config.md#pipeline) owns operator-selected Audita values.
|
- [Configuration](../config.md#pipeline) owns operator-selected Audita values.
|
||||||
- Implementation and tests: `internal/stage/polish.go`,
|
- Implementation and tests: `internal/stage/polish.go`,
|
||||||
`internal/stage/polish_test.go`
|
`internal/stage/polish_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_refinement.go`
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
- materializes a configured spell catalog with checksum and provenance, or
|
- materializes a configured spell catalog with checksum and provenance, or
|
||||||
safely removes an obsolete canonical spell catalog and its manifest record
|
safely removes an obsolete canonical spell catalog and its manifest record
|
||||||
when the effective input is omitted.
|
when the effective input is omitted.
|
||||||
|
- copies the validated canonical party bytes unchanged and deterministically
|
||||||
|
generates the prepared players projection.
|
||||||
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
|
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
|
||||||
- clears managed `previous/` state on every invocation, then, when requirements exist:
|
- clears managed `previous/` state on every invocation, then, when requirements exist:
|
||||||
- resolves the pointer-selected previous source through the shared resolver;
|
- resolves the pointer-selected previous source through the shared resolver;
|
||||||
@@ -46,8 +48,10 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
- records previous inputs in `manifest.inputs`.
|
- records previous inputs in `manifest.inputs`.
|
||||||
|
|
||||||
Required previous-session inputs fail when unavailable; optional missing inputs
|
Required previous-session inputs fail when unavailable; optional missing inputs
|
||||||
are typed skipped results. Committed sources use their exact source-to-destination
|
are typed skipped results. Each requirement resolves only from its unique
|
||||||
mapping, while the isolated legacy reader rejects ambiguous fallback matches.
|
`published_output` declaration in the pointer-selected remote commit. Prepare
|
||||||
|
strips that selected run's immutable `published/` prefix to derive the local
|
||||||
|
cache path, then validates the committed checksum, size, and generation.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
@@ -57,6 +61,26 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches.
|
|||||||
- managed `previous/` state represents only the current requirement set.
|
- managed `previous/` state represents only the current requirement set.
|
||||||
- `manifest.inputs` ordering is deterministic (`kind`, `path`).
|
- `manifest.inputs` ordering is deterministic (`kind`, `path`).
|
||||||
|
|
||||||
|
## Resume Evidence
|
||||||
|
|
||||||
|
Prepare records a versioned semantic-configuration fingerprint for the
|
||||||
|
resolved campaign/session selection, local-versus-S3 audio mode and canonical
|
||||||
|
audio names, stable-input ownership/presence, previous-session identity, and
|
||||||
|
the party mode plus canonical players projection version, and the effective
|
||||||
|
previous-artifact requirement set. A change reruns prepare and
|
||||||
|
stales its fixed descendants. Existing successful records without this
|
||||||
|
evidence rerun once when selected.
|
||||||
|
|
||||||
|
Workspace, spool, and cache placement and absolute source relocation are not
|
||||||
|
semantic when logical selection, canonical names, and bytes are equivalent.
|
||||||
|
The fingerprint deliberately does not read or rehash large audio. Prepared
|
||||||
|
input checksums remain the content provenance. Before reusing success, prepare
|
||||||
|
validates every durable prepared copy and compares current stable-input bytes,
|
||||||
|
canonical party and derived-player bytes, local audio membership/checksums, or
|
||||||
|
S3 key/size/entity-tag identity with that provenance. Source relocation with
|
||||||
|
equivalent names and bytes remains reusable; changed or unavailable evidence
|
||||||
|
causes a normal prepare rerun.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Configuration](../config.md) owns audio selection, stable input fields, and
|
- [Configuration](../config.md) owns audio selection, stable input fields, and
|
||||||
@@ -66,5 +90,10 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches.
|
|||||||
- [Storage Internals](storage.md) and [Artifact Internals](artifacts.md) explain
|
- [Storage Internals](storage.md) and [Artifact Internals](artifacts.md) explain
|
||||||
the internal collaborators.
|
the internal collaborators.
|
||||||
- Implementation and tests: `internal/stage/prepare.go`,
|
- Implementation and tests: `internal/stage/prepare.go`,
|
||||||
`internal/stage/prepare_test.go`, `internal/audio/s3_audio_test.go`,
|
`internal/stage/prepare_test.go`,
|
||||||
|
`internal/stage/prepare_resume.go`,
|
||||||
|
`internal/stage/prepare_resume_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_initial.go`,
|
||||||
|
`internal/stage/semantic_contracts_initial_test.go`,
|
||||||
|
`internal/audio/s3_audio_test.go`,
|
||||||
`internal/previouscache/*_test.go`
|
`internal/previouscache/*_test.go`
|
||||||
|
|||||||
@@ -7,15 +7,16 @@ Upload run/session outputs to object storage and atomically advance remote curre
|
|||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- successful preceding stages from the [canonical stage set](overview.md#pipeline-stage-set)
|
- successful preceding stages from the [canonical stage set](overview.md#pipeline-stage-set)
|
||||||
- invocation-scoped run files
|
- declared run-local diagnostic files
|
||||||
- resolved publish output rules
|
- resolved publish output rules
|
||||||
- effective publish locks (static + remote merged lock set), revalidated at the
|
- independent per-source remote lock objects read at planning start
|
||||||
remote commit boundary
|
- the validated prior current commit when an initially locked source may carry
|
||||||
- durable previous-session cache files when present
|
- durable previous-session cache files when present
|
||||||
|
|
||||||
## Outputs
|
## Outputs
|
||||||
|
|
||||||
- uploaded invocation record and selected publish outputs;
|
- uploaded session manifest, declared run-local diagnostics, and selected
|
||||||
|
publish outputs;
|
||||||
- uploaded durable previous-session cache files when present;
|
- uploaded durable previous-session cache files when present;
|
||||||
- immutable run-scoped commit manifest; and
|
- immutable run-scoped commit manifest; and
|
||||||
- current commit pointer, written last.
|
- current commit pointer, written last.
|
||||||
@@ -25,14 +26,27 @@ Exact remote placement and the operator workflow belong in
|
|||||||
|
|
||||||
## Key Behavior
|
## Key Behavior
|
||||||
|
|
||||||
|
`publish.go` retains the transaction-owning `Run` path. Source discovery,
|
||||||
|
manifest-derived archive metadata, confined opening, and checksum validation
|
||||||
|
live in `publish_sources.go`; artifact-catalog matching, selection, required
|
||||||
|
and optional rules, destination resolution, and collision inputs live in
|
||||||
|
`publish_outputs.go`. Copying, hashing, immutable upload, reuse checks, and
|
||||||
|
remote verification live in `publish_transfer.go`; committed-manifest,
|
||||||
|
immutable-commit, and current-pointer encoding and writes live in
|
||||||
|
`publish_commit.go`. `publish_output_plan.go` owns the deterministic, I/O-free
|
||||||
|
five-outcome model and its secret-free metadata projection.
|
||||||
|
|
||||||
- when publishing or run upload is disabled, completes successfully with no
|
- when publishing or run upload is disabled, completes successfully with no
|
||||||
outputs and records explanatory metadata. This is not an explicit self-skip:
|
outputs and records explanatory metadata. This is not an explicit self-skip:
|
||||||
both manifests record success, and an ordinary later run reuses that result
|
the session manifest records success. Enablement and upload policy are
|
||||||
until publish is forced.
|
fingerprinted, so changing either automatically makes the prior result
|
||||||
|
non-resumable.
|
||||||
- validates prerequisite stage success and object-store availability.
|
- validates prerequisite stage success and object-store availability.
|
||||||
- derives a deterministic run-archive allowlist from the validated run
|
- derives a deterministic run-archive allowlist from the session manifest's
|
||||||
`manifest.json`: declared run-local outputs, logs, generated configs, and the
|
declared current-run outputs, logs, and generated configurations. References
|
||||||
manifest itself. Unlisted workspace files are not archive candidates.
|
outside the current run root, including canonical session files and prior-run
|
||||||
|
diagnostics, are not archive candidates. No distinguished diagnostic file is
|
||||||
|
read or uploaded.
|
||||||
- opens each archive candidate beneath its archive root without following
|
- opens each archive candidate beneath its archive root without following
|
||||||
symlinked ancestors or leaf entries, verifies that it is a regular file and
|
symlinked ancestors or leaf entries, verifies that it is a regular file and
|
||||||
checks a declared checksum when present, then streams the opened descriptor.
|
checks a declared checksum when present, then streams the opened descriptor.
|
||||||
@@ -41,26 +55,34 @@ Exact remote placement and the operator workflow belong in
|
|||||||
- resolves publish output sources through runtime artifact catalog and
|
- resolves publish output sources through runtime artifact catalog and
|
||||||
manifest-aware resolution. Configured Scriptorium outputs are publishable
|
manifest-aware resolution. Configured Scriptorium outputs are publishable
|
||||||
only from validated `current` per-artifact analyze evidence; an incidental
|
only from validated `current` per-artifact analyze evidence; an incidental
|
||||||
canonical file, legacy aggregate output, stale/failed/unselected record, or
|
canonical file, aggregate output, stale/failed/unselected record, or
|
||||||
mismatched path, size, or checksum remains unavailable. This does not change
|
mismatched path, size, or checksum remains unavailable. Built-in and
|
||||||
the explicit compatibility policies owned by built-in, extraction, or
|
extraction sources likewise require their own current manifest evidence.
|
||||||
previous-session sources.
|
|
||||||
- publishes extraction lanes only through explicit configured output rules;
|
- publishes extraction lanes only through explicit configured output rules;
|
||||||
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
||||||
- selected artifact filter applies to configured artifact sources only.
|
- selected artifact filter applies to configured artifact sources only.
|
||||||
- locked outputs are skipped intentionally (including required ones).
|
- plans every configured output into exactly one source-sorted outcome:
|
||||||
- optional missing outputs are skipped; required missing unlocked outputs fail.
|
`published`, `carried_locked`, `locked_unpublished`, `optional_missing`, or
|
||||||
|
`unselected`.
|
||||||
|
- an initially locked selected output carries its exact prior
|
||||||
|
`published_output` declaration when one exists. The declaration may point to
|
||||||
|
an earlier run in the same session and retains its source, key, checksum,
|
||||||
|
size, and generation; the bytes are not copied.
|
||||||
|
- verifies a carried object's exact key, size, and generation through object
|
||||||
|
metadata without downloading or opening the local output source.
|
||||||
|
- an initially locked source with no prior declaration remains absent even
|
||||||
|
when the output rule is required. Optional missing unlocked outputs remain
|
||||||
|
absent; required missing unlocked outputs fail.
|
||||||
- creates one complete immutable source-to-destination mapping before upload;
|
- creates one complete immutable source-to-destination mapping before upload;
|
||||||
- uploads and verifies every declared immutable object and the commit manifest;
|
- uploads and verifies every declared immutable object and the commit manifest;
|
||||||
- updates `current/commit-pointer.json` exactly once, last; and
|
- writes the immutable commit from the combined new and exact carried
|
||||||
- does not write the legacy `current/manifest.json` or `current/run_id.txt` pair.
|
declarations.
|
||||||
- rechecks remote lock state immediately before the pointer update. A newly
|
- rereads every initially unlocked participating source after the immutable
|
||||||
committed lock aborts selection, leaving any uploaded immutable attempt
|
commit. A newly present lock aborts selection, leaving the uploaded immutable
|
||||||
unselected.
|
attempt unselected. Removing an initial lock may conservatively produce one
|
||||||
- reads the mutable remote lock document through a direct limit-plus-one read
|
carry because the initial snapshot controls the plan.
|
||||||
capped by `MaxRemoteLockStoreBytes` (1 MiB), retaining the generation returned
|
- updates `current/commit-pointer.json` exactly once, last, and writes no other
|
||||||
with the opened body for conditional updates. Oversized lock documents fail
|
mutable publication control record.
|
||||||
before YAML decoding; published artifact payloads do not use this limit.
|
|
||||||
|
|
||||||
## Metadata Signals
|
## Metadata Signals
|
||||||
|
|
||||||
@@ -68,9 +90,8 @@ Includes counts/lists for:
|
|||||||
- run uploads
|
- run uploads
|
||||||
- published output uploads
|
- published output uploads
|
||||||
- previous uploads
|
- previous uploads
|
||||||
- skipped optional outputs
|
- one `publish_output_outcomes` collection containing all five output states,
|
||||||
- skipped unselected outputs
|
exact declared keys, and key-derived origin runs where applicable
|
||||||
- locked outputs
|
|
||||||
- remote commit and current-pointer key paths
|
- remote commit and current-pointer key paths
|
||||||
- the run identifier selected by the commit
|
- the run identifier selected by the commit
|
||||||
|
|
||||||
@@ -83,23 +104,44 @@ Includes counts/lists for:
|
|||||||
from verified descriptors; symlinks, special files, replacement races, and
|
from verified descriptors; symlinks, special files, replacement races, and
|
||||||
undeclared entries are rejected or ignored before uploads begin.
|
undeclared entries are rejected or ignored before uploads begin.
|
||||||
- run-local diagnostics, including Notarius receipt and stderr files, are
|
- run-local diagnostics, including Notarius receipt and stderr files, are
|
||||||
archived only when recorded by the run manifest.
|
archived only when recorded by the session manifest. A run with no declared
|
||||||
- publish locks are not overridden by `--force`; remote locks are revalidated
|
current-run diagnostics legitimately uploads none.
|
||||||
immediately before current-state selection.
|
- independent publish locks are not overridden by `--force`; every initially
|
||||||
- post-commit local cleanup is authorized by the committed publish metadata and
|
unlocked relevant source is reread immediately before current-state
|
||||||
is durably recorded by the application lifecycle before any local deletion.
|
selection.
|
||||||
|
- successful uploaded publication records the exact run ID, remote commit key,
|
||||||
|
and current-pointer key consumed by the later cleanup stage.
|
||||||
|
|
||||||
The commit boundary and cleanup gate are normative architecture invariants; see
|
## Resume Semantics
|
||||||
|
|
||||||
|
The versioned semantic fingerprint covers enabled behavior, run-upload policy,
|
||||||
|
normalized source/destination/required output rules, and the remote backend,
|
||||||
|
bucket, region, endpoint, and root-prefix identity. Rule ordering is
|
||||||
|
canonicalized. Credential
|
||||||
|
environment names, path-addressing transport mode, local workspace placement,
|
||||||
|
and run identifiers are excluded. Independent remote locks remain mutable state
|
||||||
|
and are read directly by publish at planning and commit boundaries; semantic
|
||||||
|
evidence does not replace those safety checks.
|
||||||
|
|
||||||
|
The commit boundary and cleanup authorization are normative architecture invariants; see
|
||||||
[Architecture](../policy/architecture.md#publish-commit-boundary).
|
[Architecture](../policy/architecture.md#publish-commit-boundary).
|
||||||
|
|
||||||
|
Publish never deletes local state. The standalone `publish` command selects
|
||||||
|
only this stage; a full run selects notify and cleanup afterward.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Configuration](../config.md#publish-configuration-summary) owns output and
|
- [Configuration](../config.md#publish-configuration-summary) owns output
|
||||||
static-lock fields.
|
rules.
|
||||||
- [Operations](../operations.md#publish-locks) owns remote lock lifecycle and
|
- [Operations](../operations.md#publish-locks) owns remote lock lifecycle and
|
||||||
physical remote state.
|
physical remote state.
|
||||||
- [Artifact Internals](artifacts.md) explains source resolution and current-state
|
- [Artifact Internals](artifacts.md) explains source resolution and current-state
|
||||||
helpers.
|
helpers.
|
||||||
- Implementation and tests: `internal/stage/publish.go`,
|
- Implementation and tests: `internal/stage/publish.go`,
|
||||||
`internal/stage/publish_test.go`, `internal/app/operator_helpers_test.go`,
|
`internal/stage/publish_sources.go`, `internal/stage/publish_outputs.go`,
|
||||||
`internal/app/post_publish_cleanup_test.go`
|
`internal/stage/publish_output_plan.go`,
|
||||||
|
`internal/stage/publish_transfer.go`, `internal/stage/publish_commit.go`,
|
||||||
|
`internal/stage/publish_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_delivery.go`,
|
||||||
|
`internal/app/operator_helpers_test.go`, and
|
||||||
|
`internal/app/cleanup_orchestration_test.go`
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ records that may consume rendered transcripts.
|
|||||||
## Key Behavior
|
## Key Behavior
|
||||||
|
|
||||||
- uses `pipeline.render` settings (enabled/format/title/booleans).
|
- uses `pipeline.render` settings (enabled/format/title/booleans).
|
||||||
- resolves inputs manifest-first, then canonical fallback.
|
- resolves inputs only from verified manifest output evidence.
|
||||||
- writes run-local outputs first, then materializes canonical session outputs.
|
- writes run-local outputs first, then materializes canonical session outputs.
|
||||||
- records input provenance, output paths, adapter metadata, logs, and generated config refs.
|
- records input provenance, output paths, adapter metadata, logs, and generated config refs.
|
||||||
- when `pipeline.render.enabled=false`, completes successfully with no outputs
|
- when `pipeline.render.enabled=false`, completes successfully with no outputs
|
||||||
and records explanatory metadata. This is not an explicit self-skip: both
|
and records explanatory metadata. This is not an explicit self-skip: both
|
||||||
manifests record success, and enabling render later requires a forced run.
|
manifests record success. Because enablement is fingerprinted, enabling
|
||||||
|
render later automatically makes the prior result non-resumable.
|
||||||
|
|
||||||
## Failure Semantics
|
## Failure Semantics
|
||||||
|
|
||||||
@@ -40,9 +41,20 @@ records that may consume rendered transcripts.
|
|||||||
- only `format: markdown` is supported.
|
- only `format: markdown` is supported.
|
||||||
- render stage owns production of built-in Markdown transcript sources.
|
- render stage owns production of built-in Markdown transcript sources.
|
||||||
|
|
||||||
|
## Resume Semantics
|
||||||
|
|
||||||
|
The versioned semantic fingerprint covers enablement, final format, resolved
|
||||||
|
title (including the session-title fallback), timestamp, segment-ID and
|
||||||
|
metadata inclusion, both canonical input identities, and both Markdown output
|
||||||
|
identities. Seriatim's executable, timeout, and report behavior are operational
|
||||||
|
and do not invalidate rendered transcripts. A render-only change leaves the
|
||||||
|
independent `extract` sibling reusable while invalidating their shared
|
||||||
|
downstream consumers.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Seriatim](../integrations/seriatim.md) owns render subprocess behavior.
|
- [Seriatim](../integrations/seriatim.md) owns render subprocess behavior.
|
||||||
- [Configuration](../config.md#pipeline) owns render fields and defaults.
|
- [Configuration](../config.md#pipeline) owns render fields and defaults.
|
||||||
- Implementation and tests: `internal/stage/render.go`,
|
- Implementation and tests: `internal/stage/render.go`,
|
||||||
`internal/stage/render_test.go`
|
`internal/stage/render_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_refinement.go`
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
|
|||||||
|
|
||||||
- discovers prepared audio from manifest inputs or canonical audio directory.
|
- discovers prepared audio from manifest inputs or canonical audio directory.
|
||||||
- derives the transcript identity from the prepared `.flac` filename.
|
- derives the transcript identity from the prepared `.flac` filename.
|
||||||
- dispatches WhisperX requests through a bounded worker pool.
|
- dispatches one WhisperX request per prepared file through a bounded errgroup.
|
||||||
- validates each output as JSON.
|
- validates each output as JSON.
|
||||||
- writes run-local outputs then materializes canonical transcript outputs only
|
- writes run-local outputs then materializes canonical transcript outputs only
|
||||||
after every planned request succeeds.
|
after every planned request succeeds.
|
||||||
@@ -31,6 +31,19 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
|
|||||||
- each successful output is validated before stage success, and cancellation or
|
- each successful output is validated before stage success, and cancellation or
|
||||||
incomplete dispatch cannot be reported as a successful result.
|
incomplete dispatch cannot be reported as a successful result.
|
||||||
|
|
||||||
|
## Resume Evidence
|
||||||
|
|
||||||
|
Transcribe records a versioned semantic-configuration fingerprint containing
|
||||||
|
the Narratio-visible WhisperX service URL and recognition language. Changes to
|
||||||
|
either rerun transcription and stale its fixed descendants while leaving
|
||||||
|
prepare reusable. Retry count/delay, concurrency, timeout, credentials, and
|
||||||
|
diagnostic locations are operational and do not change this evidence.
|
||||||
|
|
||||||
|
WhisperX models or private service configuration not exposed by Narratio's
|
||||||
|
adapter contract cannot be fingerprinted; use `--force` after changing them.
|
||||||
|
An existing successful transcribe record without evidence reruns once when
|
||||||
|
selected.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [WhisperX](../integrations/whisperx.md) owns HTTP, retry, timeout, and
|
- [WhisperX](../integrations/whisperx.md) owns HTTP, retry, timeout, and
|
||||||
@@ -38,4 +51,6 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
|
|||||||
- [Configuration](../config.md#pipeline) owns concurrency and other
|
- [Configuration](../config.md#pipeline) owns concurrency and other
|
||||||
operator-selected values.
|
operator-selected values.
|
||||||
- Implementation and tests: `internal/stage/transcribe.go`,
|
- Implementation and tests: `internal/stage/transcribe.go`,
|
||||||
`internal/stage/transcribe_test.go`
|
`internal/stage/transcribe_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_initial.go`, and
|
||||||
|
`internal/stage/semantic_contracts_initial_test.go`
|
||||||
|
|||||||
@@ -33,6 +33,19 @@ When `trim.enabled=false`:
|
|||||||
- bounds output exists only in enabled trim path.
|
- bounds output exists only in enabled trim path.
|
||||||
- render-debug output is diagnostic and not a declared stage output.
|
- render-debug output is diagnostic and not a declared stage output.
|
||||||
|
|
||||||
|
## Resume Semantics
|
||||||
|
|
||||||
|
The versioned semantic fingerprint covers enablement, the bounds prompt and
|
||||||
|
profile identifiers, the Scriptorium configuration identity, transcript input
|
||||||
|
name, sticky session variable, bounds and trimmed output identities, and the
|
||||||
|
Seriatim trim operation. Diagnostic bounds rendering, diagnostic output paths,
|
||||||
|
timeouts, executable paths, and optional reports are operational and do not
|
||||||
|
invalidate the canonical trimmed transcript.
|
||||||
|
|
||||||
|
Narratio cannot inspect prompt, profile, or configuration content that
|
||||||
|
Scriptorium or Seriatim privately resolves behind a stable identifier. Force
|
||||||
|
`trim` after changing such private content without changing its identifier.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Scriptorium](../integrations/scriptorium.md) owns bounds generation and
|
- [Scriptorium](../integrations/scriptorium.md) owns bounds generation and
|
||||||
@@ -40,4 +53,5 @@ When `trim.enabled=false`:
|
|||||||
- [Seriatim](../integrations/seriatim.md) owns transcript trimming behavior.
|
- [Seriatim](../integrations/seriatim.md) owns transcript trimming behavior.
|
||||||
- [Configuration](../config.md#pipeline) owns trim fields and defaults.
|
- [Configuration](../config.md#pipeline) owns trim fields and defaults.
|
||||||
- Implementation and tests: `internal/stage/trim.go`,
|
- Implementation and tests: `internal/stage/trim.go`,
|
||||||
`internal/stage/trim_test.go`
|
`internal/stage/trim_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_refinement.go`
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ operator-selected storage fields and credential mechanisms belong in
|
|||||||
- `Download(ctx, key, localPath)`
|
- `Download(ctx, key, localPath)`
|
||||||
- `Upload(ctx, localPath, key, opts)`
|
- `Upload(ctx, localPath, key, opts)`
|
||||||
- `UploadConditional(ctx, source, key, opts, condition)`
|
- `UploadConditional(ctx, source, key, opts, condition)`
|
||||||
|
- `Delete(ctx, key)` removes one object and succeeds when it is already absent
|
||||||
- `Exists(ctx, key)`
|
- `Exists(ctx, key)`
|
||||||
|
|
||||||
Key invariant:
|
Key invariant:
|
||||||
@@ -47,6 +48,8 @@ not own discovery, defaults, or configuration validation.
|
|||||||
- `Read` binds a returned body to its S3 ETag. `UploadConditional` maps an ETag
|
- `Read` binds a returned body to its S3 ETag. `UploadConditional` maps an ETag
|
||||||
match or absence precondition directly to the provider request and reports a
|
match or absence precondition directly to the provider request and reports a
|
||||||
failed precondition without performing a local check-then-write replacement.
|
failed precondition without performing a local check-then-write replacement.
|
||||||
|
- `Delete` maps one normalized key to the provider delete-object operation and
|
||||||
|
treats an absent object as success.
|
||||||
- `Exists` maps not-found responses to `false`.
|
- `Exists` maps not-found responses to `false`.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
@@ -55,6 +58,11 @@ not own discovery, defaults, or configuration validation.
|
|||||||
- bounded reads never retain more than the caller's limit plus one byte and do
|
- bounded reads never retain more than the caller's limit plus one byte and do
|
||||||
not replace owner-specific size policy.
|
not replace owner-specific size policy.
|
||||||
- publish ordering semantics are owned by stage/app code, not storage adapters.
|
- publish ordering semantics are owned by stage/app code, not storage adapters.
|
||||||
|
- A caller that installs remote content into a canonical workspace path first
|
||||||
|
writes a sibling temporary file, validates the complete object against its
|
||||||
|
owner-specific contract, and only then replaces the destination. Temporary
|
||||||
|
files used solely for remote comparisons are removed by their callers and do
|
||||||
|
not represent installed workspace state.
|
||||||
|
|
||||||
## Implementation And Tests
|
## Implementation And Tests
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ path model, and layout creation goes through `EnsureLayoutFor`. Callers should
|
|||||||
consume those helpers instead of rebuilding relative paths.
|
consume those helpers instead of rebuilding relative paths.
|
||||||
|
|
||||||
`internal/pathsafe` validates relative destinations. `internal/fileops` opens
|
`internal/pathsafe` validates relative destinations. `internal/fileops` opens
|
||||||
cleanup roots and their descendants through no-follow directory handles before
|
explicit cleanup roots and removes only normalized proper descendants through
|
||||||
removing them.
|
the resulting root handle.
|
||||||
|
|
||||||
`internal/fileops` owns the ordinary workspace mode contract. On POSIX,
|
`internal/fileops` owns the ordinary workspace mode contract. On POSIX,
|
||||||
`WorkspaceDirectoryMode` is setgid `02775` and `WorkspaceFileMode` is `0664`.
|
`WorkspaceDirectoryMode` is setgid `02775` and `WorkspaceFileMode` is `0664`.
|
||||||
@@ -33,13 +33,8 @@ previous-session cache paths remain session-durable and are never redirected
|
|||||||
into run-local output space.
|
into run-local output space.
|
||||||
|
|
||||||
Extraction uses run-local receipt, stderr, and output-root helpers, then
|
Extraction uses run-local receipt, stderr, and output-root helpers, then
|
||||||
promotes the validated external bundle to the unique immutable Notarius bundle
|
validates and renames the external bundle to the unique immutable Notarius
|
||||||
path supplied by `internal/artifacts`. `internal/fileops.PromoteDirectory`
|
bundle path supplied by `internal/artifacts`. Exact physical paths belong in
|
||||||
copies only regular files and directories to a same-filesystem temporary
|
|
||||||
sibling. Source traversal uses confined directory handles and identity checks
|
|
||||||
so replacing an inspected root, directory, or file is rejected rather than
|
|
||||||
followed. The completed tree is atomically renamed without replacing an
|
|
||||||
existing destination. Exact physical paths belong in
|
|
||||||
[Operations](../operations.md#extraction-workflow).
|
[Operations](../operations.md#extraction-workflow).
|
||||||
|
|
||||||
## Locking
|
## Locking
|
||||||
@@ -50,21 +45,25 @@ file retains owner metadata after release or process death; its existence is
|
|||||||
not evidence that a lock is active. Command and restore flows wait for this
|
not evidence that a lock is active. Command and restore flows wait for this
|
||||||
lock only while their context remains active, and report a release failure.
|
lock only while their context remains active, and report a release failure.
|
||||||
|
|
||||||
|
The runner assigns every invocation a run-workspace identity but creates the
|
||||||
|
physical directory only before an executing stage that requires it. Fully
|
||||||
|
reused invocations and cleanup-only invocations therefore do not accumulate
|
||||||
|
empty run directories.
|
||||||
|
|
||||||
## Cleanup Semantics
|
## Cleanup Semantics
|
||||||
|
|
||||||
Automatic post-publish cleanup:
|
The canonical cleanup stage:
|
||||||
|
|
||||||
- is created only after a successful publish commit with complete publish
|
- runs after notify only when selected by the invocation range;
|
||||||
metadata, then is persisted before any deletion;
|
- requires a succeeded publish record with `uploaded=true`, the published run
|
||||||
- requires `uploaded=true`, a remote commit key, and a current commit-pointer
|
ID, remote commit key, and current commit-pointer key;
|
||||||
key in publish metadata;
|
|
||||||
- consumes the resolved cleanup policy described in
|
- consumes the resolved cleanup policy described in
|
||||||
[Configuration](../config.md);
|
[Configuration](../config.md);
|
||||||
- refuses unsafe deletes (root delete, out-of-root delete, and symlinked
|
- refuses unsafe deletes (root delete, traversal, and out-of-root targets);
|
||||||
ancestors or entries);
|
- treats missing targets as successful, idempotent deletion; and
|
||||||
- retries any recorded incomplete target on later invocations even when no
|
- uses its ordinary failed/succeeded manifest stage record for retry. A later
|
||||||
publish work is selected. Missing targets are a successful, idempotent
|
full run can skip reusable upstream stages and retry cleanup without
|
||||||
cleanup result only after the completion evidence is saved.
|
republishing.
|
||||||
|
|
||||||
Manual cleanup uses the same root-confined deletion mechanism. Invocation
|
Manual cleanup uses the same root-confined deletion mechanism. Invocation
|
||||||
syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
|
syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
|
||||||
@@ -75,6 +74,8 @@ syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
|
|||||||
- campaign-aware session root is mandatory.
|
- campaign-aware session root is mandatory.
|
||||||
- manifest-driven stage state is durable across runs.
|
- manifest-driven stage state is durable across runs.
|
||||||
- cleanup guardrails prevent destructive root/out-of-scope deletion.
|
- cleanup guardrails prevent destructive root/out-of-scope deletion.
|
||||||
|
- restore installation is confined to validated relative paths beneath the
|
||||||
|
selected session root.
|
||||||
- ordinary workspace paths retain group-writable directory and file modes across
|
- ordinary workspace paths retain group-writable directory and file modes across
|
||||||
nested creation, replacement, and Notarius promotion.
|
nested creation, replacement, and Notarius promotion.
|
||||||
|
|
||||||
@@ -83,12 +84,14 @@ syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
|
|||||||
- Path model and local store: `internal/artifacts/paths.go`,
|
- Path model and local store: `internal/artifacts/paths.go`,
|
||||||
`internal/artifacts/local.go`
|
`internal/artifacts/local.go`
|
||||||
- Run-local materialization: `internal/stage/run_local.go`
|
- Run-local materialization: `internal/stage/run_local.go`
|
||||||
- Immutable bundle promotion: `internal/fileops/directory.go`
|
- Immutable bundle installation: `internal/stage/extract.go`
|
||||||
- Workspace modes: `internal/fileops/modes.go`
|
- Workspace modes: `internal/fileops/modes.go`
|
||||||
- Cleanup confinement: `internal/fileops/cleanup.go`,
|
- Cleanup confinement: `internal/fileops/cleanup.go`,
|
||||||
`internal/app/cleanup_targets.go`, `internal/app/post_publish_cleanup.go`
|
`internal/stage/cleanup.go`, `internal/stage/cleanup_plan.go`, and
|
||||||
|
`internal/app/cleanup_targets.go`
|
||||||
- Tests: `internal/artifacts/paths_model_test.go`,
|
- Tests: `internal/artifacts/paths_model_test.go`,
|
||||||
`internal/artifacts/local_test.go`, `internal/stage/run_local_test.go`,
|
`internal/artifacts/local_test.go`, `internal/stage/run_local_test.go`,
|
||||||
`internal/fileops/directory_test.go`, `internal/fileops/modes_posix_test.go`,
|
`internal/fileops/modes_posix_test.go`,
|
||||||
`internal/fileops/cleanup_test.go`, `internal/app/cleanup_targets_test.go`,
|
`internal/fileops/cleanup_test.go`, `internal/app/cleanup_targets_test.go`,
|
||||||
`internal/app/post_publish_cleanup_test.go`
|
`internal/stage/cleanup_test.go`, and
|
||||||
|
`internal/app/cleanup_orchestration_test.go`
|
||||||
|
|||||||
@@ -70,6 +70,22 @@ narratio run 2026-04-04
|
|||||||
narratio session status 2026-04-04
|
narratio session status 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run, plan, and status output identify the resolved pipeline profile (or `none`)
|
||||||
|
and effective configuration digest. Status distinguishes the current resolved
|
||||||
|
value from the last value persisted in the session manifest, which helps
|
||||||
|
diagnose profile switches without changing resume authority.
|
||||||
|
|
||||||
|
Before switching an operational profile, compare its effective meaning with the
|
||||||
|
production base through `narratio config diff default <profile>`.
|
||||||
|
The command is read-only and succeeds whether it finds differences or not. Its
|
||||||
|
sorted records describe defaulted, expanded concrete configuration—not source
|
||||||
|
file layout—so it can be used to review model, artifact, and publish changes
|
||||||
|
without creating a session or run. Use `narratio config artifacts` to review the
|
||||||
|
concrete artifact identities, definitions, outputs, and dependencies. Select the
|
||||||
|
same campaign explicitly when profiles could resolve different campaign paths;
|
||||||
|
see the [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
|
||||||
|
for syntax and record format.
|
||||||
|
|
||||||
## Stage Execution and Continuation Behavior
|
## Stage Execution and Continuation Behavior
|
||||||
|
|
||||||
Canonical stage order:
|
Canonical stage order:
|
||||||
@@ -85,10 +101,12 @@ Canonical stage order:
|
|||||||
9. `analyze`
|
9. `analyze`
|
||||||
10. `publish`
|
10. `publish`
|
||||||
11. `notify`
|
11. `notify`
|
||||||
|
12. `cleanup`
|
||||||
|
|
||||||
Execution rules:
|
Execution rules:
|
||||||
|
|
||||||
- succeeded stages are skipped unless `--force` is set;
|
- succeeded stages are skipped unless `--force` is set; stages with semantic
|
||||||
|
configuration contracts additionally require matching versioned evidence;
|
||||||
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
||||||
replacement runs; render and extract are independent siblings; and
|
replacement runs; render and extract are independent siblings; and
|
||||||
@@ -97,6 +115,27 @@ Execution rules:
|
|||||||
repeated self-skip with the same reason and no outputs is stable and does not
|
repeated self-skip with the same reason and no outputs is stable and does not
|
||||||
perpetually rerun dependent work.
|
perpetually rerun dependent work.
|
||||||
|
|
||||||
|
Every aggregate stage except analyze currently provides semantic-configuration
|
||||||
|
evidence; analyze retains its more precise per-artifact fingerprints and
|
||||||
|
validator.
|
||||||
|
Prepare additionally validates current stable/local/S3 source identity and the
|
||||||
|
checksums of its durable prepared copies before reuse. Changed bytes, audio
|
||||||
|
membership, S3 object identity, or missing/tampered copies rerun prepare and
|
||||||
|
its fixed descendants without requiring `--force`. Changing prepare selection
|
||||||
|
semantics likewise reruns all fixed descendants; changing
|
||||||
|
WhisperX language/service identity reuses prepare; changing a Seriatim merge
|
||||||
|
transformation reuses prepare and transcribe; and changing an Audita model
|
||||||
|
reuses prepare, transcribe, and merge while rebuilding transcript refinement.
|
||||||
|
A trim change invalidates both render and extract through the fixed dependency
|
||||||
|
relation, while a render-only change preserves the extract sibling.
|
||||||
|
|
||||||
|
Operational timeouts, retry/concurrency tuning, executable paths,
|
||||||
|
workspace/cache/spool placement, reports, diagnostics, and secret values are
|
||||||
|
excluded. Configuration, models, prompts, modules, or resources loaded
|
||||||
|
privately inside external tools remain unobservable to Narratio. If their
|
||||||
|
contents change behind the same configured identifier, explicitly force the
|
||||||
|
affected stage.
|
||||||
|
|
||||||
An explicit self-skip is a durable `skipped` stage outcome that later runs
|
An explicit self-skip is a durable `skipped` stage outcome that later runs
|
||||||
reconsider. It differs from successful no-output execution: disabled `render`
|
reconsider. It differs from successful no-output execution: disabled `render`
|
||||||
and `publish`, and absent or no-executable `analyze`, record `succeeded` with
|
and `publish`, and absent or no-executable `analyze`, record `succeeded` with
|
||||||
@@ -119,7 +158,7 @@ narratio run 2026-04-04 --from extract --through analyze --force
|
|||||||
```
|
```
|
||||||
|
|
||||||
Omitting `--from` selects from `prepare`; omitting `--through` selects through
|
Omitting `--from` selects from `prepare`; omitting `--through` selects through
|
||||||
`notify`. Force applies only within the selected range. Repeating `--from`,
|
`cleanup`. Force applies only within the selected range. Repeating `--from`,
|
||||||
`--through`, or `--force` is rejected instead of resolving by argument order.
|
`--through`, or `--force` is rejected instead of resolving by argument order.
|
||||||
The plan command uses the same selection contract and prints only the selected
|
The plan command uses the same selection contract and prints only the selected
|
||||||
range. Planning is read-only: it clones the loaded manifest, models selected
|
range. Planning is read-only: it clones the loaded manifest, models selected
|
||||||
@@ -133,11 +172,11 @@ selected artifact evidence is still semantically current.
|
|||||||
Before a bounded run or plan whose range starts after `prepare`, every excluded
|
Before a bounded run or plan whose range starts after `prepare`, every excluded
|
||||||
prefix stage must already have a session-manifest status of `succeeded` or
|
prefix stage must already have a session-manifest status of `succeeded` or
|
||||||
`skipped`. Narratio reports the first absent, pending, running, failed, stale,
|
`skipped`. Narratio reports the first absent, pending, running, failed, stale,
|
||||||
or interrupted prerequisite without creating a run record or changing session
|
or interrupted prerequisite without creating a run-local directory or changing
|
||||||
state. Widen `--from` to include that stage, or recover it explicitly before
|
session state. Widen `--from` to include that stage, or recover it explicitly
|
||||||
retrying. Excluded prefix stages are not resume-validated or repaired as part
|
before retrying. Excluded prefix stages are not resume-validated or repaired as
|
||||||
of the bounded invocation; selected stages still reject missing, unsafe, or
|
part of the bounded invocation; selected stages still reject missing, unsafe,
|
||||||
manifest-inconsistent inputs at their owning boundary.
|
or manifest-inconsistent inputs at their owning boundary.
|
||||||
|
|
||||||
Stages after `--through` are not prerequisites and are never scheduled by the
|
Stages after `--through` are not prerequisites and are never scheduled by the
|
||||||
bounded invocation. A selected forced stage can mark one of those succeeded
|
bounded invocation. A selected forced stage can mark one of those succeeded
|
||||||
@@ -159,8 +198,8 @@ This command is a transparent expansion to a forced bounded `run` from
|
|||||||
`extract` through `analyze`. Extraction always rebuilds its complete configured
|
`extract` through `analyze`. Extraction always rebuilds its complete configured
|
||||||
bundle. Analysis rebuilds the selected targets and their required analysis
|
bundle. Analysis rebuilds the selected targets and their required analysis
|
||||||
prerequisites, or uses the normal default selection when no artifact names are
|
prerequisites, or uses the normal default selection when no artifact names are
|
||||||
given. The command does not run publish or notify; delivery remains a separate
|
given. The command does not run publish, notify, or cleanup; delivery and
|
||||||
operator action.
|
retention remain separate operator actions.
|
||||||
|
|
||||||
Inspect current artifact evidence, then publish explicitly when the regenerated
|
Inspect current artifact evidence, then publish explicitly when the regenerated
|
||||||
set is ready:
|
set is ready:
|
||||||
@@ -170,13 +209,24 @@ narratio session artifacts 2026-04-04
|
|||||||
narratio publish 2026-04-04
|
narratio publish 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
If planning or execution reports stale, missing, failed, legacy, or tampered
|
If planning or execution reports stale, missing, failed, or tampered
|
||||||
analysis evidence, regenerate the affected target instead of copying an older
|
analysis evidence, regenerate the affected target instead of copying an older
|
||||||
canonical file into place or editing the manifest. See
|
canonical file into place or editing the manifest. See
|
||||||
[Troubleshooting: Analysis artifact evidence is not current](./troubleshooting.md#analysis-artifact-evidence-is-not-current).
|
[Troubleshooting: Analysis artifact evidence is not current](./troubleshooting.md#analysis-artifact-evidence-is-not-current).
|
||||||
|
|
||||||
|
Analyze checkpoints each completed configured artifact before starting later
|
||||||
|
artifact work. After a partial failure or interruption, retry normally: valid
|
||||||
|
earlier checkpoints are reused and execution resumes with failed, stale, or
|
||||||
|
missing work in dependency order. `--force` is unnecessary unless the operator
|
||||||
|
intends to replace otherwise-current artifacts.
|
||||||
|
|
||||||
## Artifact Selection
|
## Artifact Selection
|
||||||
|
|
||||||
|
For a configured artifact family, selecting its family key expands to every
|
||||||
|
concrete character artifact. Select a concrete generated key to operate on one
|
||||||
|
member only. Manifests and plan output retain the concrete key as the durable
|
||||||
|
identity and include the family and character ID as optional provenance.
|
||||||
|
|
||||||
`--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and
|
`--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and
|
||||||
`publish`. For a bounded run or plan, the selected range must contain `analyze`
|
`publish`. For a bounded run or plan, the selected range must contain `analyze`
|
||||||
or `publish`.
|
or `publish`.
|
||||||
@@ -213,6 +263,11 @@ prepared inputs. Their canonical locations are `inputs/party.yml`,
|
|||||||
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
|
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
|
||||||
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
|
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
|
||||||
prepared files cannot change the bytes consumed by an in-flight invocation.
|
prepared files cannot change the bytes consumed by an in-flight invocation.
|
||||||
|
For a canonical party, preparation retains the validated authored party bytes
|
||||||
|
at `inputs/party.yml` and generates `inputs/players.yml` from that roster.
|
||||||
|
The manifest records their checksums separately, with the players input marked
|
||||||
|
as derived from the party; refresh preparation after changing the roster rather
|
||||||
|
than editing either prepared file.
|
||||||
Inspect the effective stable-input inventory and
|
Inspect the effective stable-input inventory and
|
||||||
prepared-file readiness with:
|
prepared-file readiness with:
|
||||||
|
|
||||||
@@ -234,15 +289,13 @@ narratio run-stage prepare 2026-04-04 --force
|
|||||||
|
|
||||||
Starting a replacement clears the previous extraction payload from the current
|
Starting a replacement clears the previous extraction payload from the current
|
||||||
session-stage record. If that replacement fails or self-skips, the current
|
session-stage record. If that replacement fails or self-skips, the current
|
||||||
record does not fall back to the earlier outputs. The earlier run manifest and
|
record does not fall back to the earlier outputs. The earlier immutable bundle
|
||||||
immutable bundle remain available for inspection, but downstream resolution
|
remains available for inspection, but downstream resolution requires a new
|
||||||
requires a new current successful extraction record.
|
current successful extraction record.
|
||||||
|
|
||||||
Atomic Notarius bundle promotion is supported on Linux and macOS. On Windows
|
Notarius extraction validates its run-local bundle and moves it once to the
|
||||||
and other operating systems, extraction fails before copying the bundle into a
|
unique durable bundle path. A pre-existing durable destination is a collision;
|
||||||
temporary promotion tree because Narratio has no verified atomic no-replace
|
no duplicate promotion tree is created.
|
||||||
directory primitive there. This is an extraction limitation, not a broader
|
|
||||||
platform-support guarantee for every Narratio workflow.
|
|
||||||
|
|
||||||
## External Command Lifecycle
|
## External Command Lifecycle
|
||||||
|
|
||||||
@@ -265,12 +318,15 @@ Run-local diagnostics are:
|
|||||||
- `runs/{run_id}/extract/notarius.stderr.log`
|
- `runs/{run_id}/extract/notarius.stderr.log`
|
||||||
- `runs/{run_id}/extract/notarius-output/` before durable promotion
|
- `runs/{run_id}/extract/notarius-output/` before durable promotion
|
||||||
|
|
||||||
The run-record upload is an allowlist derived from the validated run manifest,
|
The run upload is an allowlist derived from the session manifest's declared
|
||||||
not a workspace scan. Each declared source is opened without following
|
current-run outputs, logs, and generated configurations, not a workspace scan.
|
||||||
symlinked ancestors or the leaf, verified as a regular file, and streamed from
|
References to canonical session files and prior-run paths are excluded. Each
|
||||||
that verified descriptor. Unlisted files and unsafe entries are never uploaded.
|
declared source is opened without following symlinked ancestors or the leaf,
|
||||||
The durable bundle is never scanned for implicit publication; only lanes named
|
verified as a regular file, and streamed from that verified descriptor.
|
||||||
by explicit `pipeline.publish.outputs` rules are uploaded.
|
Unlisted files and unsafe entries are never uploaded; a run with no declared
|
||||||
|
current-run diagnostics uploads no run-local files. No distinguished diagnostic
|
||||||
|
file is uploaded. The durable bundle is never scanned for implicit publication;
|
||||||
|
only lanes named by explicit `pipeline.publish.outputs` rules are uploaded.
|
||||||
|
|
||||||
To intentionally replace the current extraction result, run:
|
To intentionally replace the current extraction result, run:
|
||||||
|
|
||||||
@@ -280,6 +336,10 @@ narratio run-stage extract 2026-04-04 --force
|
|||||||
|
|
||||||
Narratio automatically reruns extraction when its recorded invocation contract,
|
Narratio automatically reruns extraction when its recorded invocation contract,
|
||||||
prepared Narratio reference identities, or durable output validation changes.
|
prepared Narratio reference identities, or durable output validation changes.
|
||||||
|
The semantic portion covers Notarius enablement, pipeline identity, declared
|
||||||
|
reference mapping, and output contracts. Executable, timeout, working directory,
|
||||||
|
and private config-file paths are operational and do not invalidate a current
|
||||||
|
result.
|
||||||
It cannot fingerprint configuration files, profiles, prompts, modules, or
|
It cannot fingerprint configuration files, profiles, prompts, modules, or
|
||||||
other references loaded transitively by Notarius itself. Force extraction after
|
other references loaded transitively by Notarius itself. Force extraction after
|
||||||
changing any of those inputs, even when the top-level Narratio and Notarius
|
changing any of those inputs, even when the top-level Narratio and Notarius
|
||||||
@@ -288,6 +348,13 @@ marks successful downstream stages stale. Ordinary extraction failures or
|
|||||||
outcome changes also stale affected downstream stages, while an identical
|
outcome changes also stale affected downstream stages, while an identical
|
||||||
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
|
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
|
||||||
|
|
||||||
|
Publish reuse additionally tracks enabled/run-upload behavior, normalized
|
||||||
|
output rules, and remote backend/bucket/region/endpoint/root identity.
|
||||||
|
Credential environment names, local workspace placement, and run IDs are
|
||||||
|
excluded. Regardless of semantic reuse evidence, executing publish reads
|
||||||
|
independent remote locks directly and rechecks initially unlocked sources
|
||||||
|
before commit selection.
|
||||||
|
|
||||||
## Publish Workflow
|
## Publish Workflow
|
||||||
|
|
||||||
Run publish only:
|
Run publish only:
|
||||||
@@ -302,6 +369,9 @@ Equivalent:
|
|||||||
narratio run-stage publish 2026-04-04 --force
|
narratio run-stage publish 2026-04-04 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This standalone command stops after publish and does not invoke cleanup. A
|
||||||
|
default full `run` continues through notify and cleanup.
|
||||||
|
|
||||||
Publish commit model:
|
Publish commit model:
|
||||||
|
|
||||||
- uploads eligible run files under `{session_prefix}/runs/{run_id}/`, excluding
|
- uploads eligible run files under `{session_prefix}/runs/{run_id}/`, excluding
|
||||||
@@ -325,31 +395,44 @@ commit by version, checksum, size, and storage generation; committed artifacts
|
|||||||
are also checksum- and generation-bound. Readers accept this contract now and
|
are also checksum- and generation-bound. Readers accept this contract now and
|
||||||
strictly reject mismatched or unknown data.
|
strictly reject mismatched or unknown data.
|
||||||
|
|
||||||
Legacy reads are limited to a coherent `current/manifest.json` and
|
A selected commit may carry a `published_output` declaration from an earlier
|
||||||
`current/run_id.txt` pair; a torn pair is rejected. New publication does not
|
run in the same campaign/session. Restore, previous-session caching, status,
|
||||||
write that pair and remote commit state does not carry local
|
and artifact inspection use that declaration's exact immutable key and stable
|
||||||
`current_pointer_written` metadata.
|
source; they do not reconstruct a current-run key or scan the session prefix.
|
||||||
|
Other artifact types remain confined to the selected commit's own run.
|
||||||
|
|
||||||
|
When the pointer is absent, remote state is not published; republish the
|
||||||
|
session to establish a committed snapshot. Remote commit state does not carry
|
||||||
|
local `current_pointer_written` metadata.
|
||||||
|
|
||||||
## Publish Locks
|
## Publish Locks
|
||||||
|
|
||||||
Lock sources:
|
Each mutable lock is an independent strict JSON object at
|
||||||
|
`{session_prefix}/locks/{sha256(source)}.lock`. The lowercase digest is computed
|
||||||
|
from the exact canonical source. Listing validates every object in that
|
||||||
|
reserved prefix and reports locks sorted by source.
|
||||||
|
|
||||||
- static locks in `pipeline.publish.locks`
|
Upgrades do not read, migrate, or delete the retired `{session_prefix}/locks.yml`
|
||||||
- mutable remote locks in `{session_prefix}/locks.yml`
|
object. It has no effect on publication and may be removed manually after
|
||||||
|
verifying lock commands and publication behavior with the upgraded Narratio
|
||||||
|
version. No migration command is required.
|
||||||
|
|
||||||
Effective lock rules:
|
Operational rules:
|
||||||
|
|
||||||
- static and remote locks are merged;
|
- add uses conditional creation and reports an existing lock;
|
||||||
- static locks win on source collisions;
|
- add with `--force` observes the current generation and attempts one
|
||||||
- locked outputs are intentional skips;
|
generation-matched replacement. A concurrent change is reported for an
|
||||||
- lock add/remove commands mutate only remote lock state through generation-bound
|
explicit operator retry;
|
||||||
conditional writes. A command retries a bounded number of concurrent
|
- remove is idempotent and reports whether a lock was present;
|
||||||
conflicts while its invocation context remains active, so it never replaces a
|
- the initial publish lock snapshot controls output planning. A locked source
|
||||||
different lock-document generation; and
|
carries its exact declaration from the prior current commit when available;
|
||||||
- a publish re-reads remote locks immediately before it writes the current
|
a never-published locked source remains absent even when required;
|
||||||
commit pointer. A lock committed before that recheck prevents selecting the
|
- carried bytes are not copied. Publish verifies the prior object's exact key,
|
||||||
new snapshot, even though its already-uploaded immutable objects may remain
|
size, and generation without downloading it; and
|
||||||
available for a later retry.
|
- after immutable uploads and the immutable commit, publish re-reads every
|
||||||
|
initially unlocked participating source. A newly added lock prevents the
|
||||||
|
current pointer from advancing, although the immutable attempt may remain.
|
||||||
|
A removed initial lock may conservatively cause one carry.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
@@ -383,13 +466,22 @@ Default restore scope:
|
|||||||
|
|
||||||
- the committed session manifest and the committed transcript/artifact objects
|
- the committed session manifest and the committed transcript/artifact objects
|
||||||
declared by the selected remote commit
|
declared by the selected remote commit
|
||||||
- `previous/**` when needed by configured previous-session artifact inputs
|
- `previous/**` when needed by configured previous-session artifact inputs;
|
||||||
|
each is selected from its exact committed `published_output` declaration
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
- `--include-audio` to include `audio/**`
|
- `--include-audio` to include `audio/**`
|
||||||
- `--force` to overwrite eligible conflicting regular files; it never replaces
|
- `--force` to overwrite eligible conflicting regular files; it never replaces
|
||||||
directories or other non-regular local targets
|
directories, symlinks, or other non-regular local targets
|
||||||
|
|
||||||
|
Restore treats a local leaf symlink or any symlinked ancestor beneath the
|
||||||
|
selected session root as a conflict, even when the linked content matches the
|
||||||
|
selected remote object. `--force` does not override that conflict.
|
||||||
|
|
||||||
|
Before each install, restore validates the destination again beneath the
|
||||||
|
selected session root. A target changed into a symlink, directory, or other
|
||||||
|
non-regular entry is not overwritten.
|
||||||
|
|
||||||
Restore writes an execution report at `reports/restore-latest.json`.
|
Restore writes an execution report at `reports/restore-latest.json`.
|
||||||
|
|
||||||
@@ -418,6 +510,12 @@ Durable session paths:
|
|||||||
- `config/**`
|
- `config/**`
|
||||||
- `runs/**`
|
- `runs/**`
|
||||||
|
|
||||||
|
`manifest.json` is the versioned session-progress document. Narratio accepts
|
||||||
|
only `narratio.session-manifest.v1`. Do not edit unsupported session state to
|
||||||
|
add missing markers. Move it aside and regenerate it, restore a verified
|
||||||
|
current committed snapshot, or republish healthy regenerated state as the
|
||||||
|
relevant workflow requires.
|
||||||
|
|
||||||
Validated Notarius bundles live below `artifacts/notarius/{run_id}/`; receipt,
|
Validated Notarius bundles live below `artifacts/notarius/{run_id}/`; receipt,
|
||||||
stderr, and pre-promotion output remain in the producing run's `extract`
|
stderr, and pre-promotion output remain in the producing run's `extract`
|
||||||
directory as described in [Extraction Workflow](#extraction-workflow).
|
directory as described in [Extraction Workflow](#extraction-workflow).
|
||||||
@@ -442,6 +540,8 @@ Cache layout (durable S3 audio cache):
|
|||||||
Each cached audio file has an adjacent managed identity record. It binds the
|
Each cached audio file has an adjacent managed identity record. It binds the
|
||||||
file to its remote object version and verified digest; deleting or altering the
|
file to its remote object version and verified digest; deleting or altering the
|
||||||
record simply causes Narratio to download and verify the object again.
|
record simply causes Narratio to download and verify the object again.
|
||||||
|
Remote audio and restored files are fully transferred into sibling temporary
|
||||||
|
files and validated before they replace their canonical workspace paths.
|
||||||
|
|
||||||
### Workspace Permissions
|
### Workspace Permissions
|
||||||
|
|
||||||
@@ -453,18 +553,28 @@ converge on `0664`. Narratio explicitly applies these modes so a restrictive
|
|||||||
caller umask does not remove group write or setgid. It does not change file or
|
caller umask does not remove group write or setgid. It does not change file or
|
||||||
directory ownership: the configured workspace's existing group is inherited.
|
directory ownership: the configured workspace's existing group is inherited.
|
||||||
|
|
||||||
Windows does not implement POSIX mode bits or setgid semantics. Configure the
|
|
||||||
workspace, spool, and cache locations with an ACL that grants the collaborating
|
|
||||||
group read/write access, and configure credential locations with an ACL limited
|
|
||||||
to the intended credential owner. Do not use POSIX mode displays as evidence of
|
|
||||||
Windows access control.
|
|
||||||
|
|
||||||
API keys are credentials, not ordinary workspace data. Store them outside the
|
API keys are credentials, not ordinary workspace data. Store them outside the
|
||||||
shared workspace or in a separately restricted credential location; ordinary
|
shared workspace or in a separately restricted credential location; ordinary
|
||||||
workspace group access must never be treated as authorization to read keys.
|
workspace group access must never be treated as authorization to read keys.
|
||||||
On POSIX, provision a credential directory as `0700` and credential files as
|
Provision a credential directory as `0700` and credential files as `0600`;
|
||||||
`0600`; Narratio rejects group- or other-readable configured credential paths.
|
these permission modes are an operator requirement and are not enforced by
|
||||||
On Windows, restrict the directory and files with ACLs to the credential owner.
|
ConfigKit.
|
||||||
|
|
||||||
|
Every pipeline-loading command bootstraps credentials once, before the first
|
||||||
|
configuration load and before any adapter is constructed. `--secrets-dir`
|
||||||
|
selects an explicit directory (relative paths use caller CWD). Otherwise a
|
||||||
|
present `secrets/` beside the resolved root pipeline is used, while an absent
|
||||||
|
conventional directory is skipped. Explicit missing/empty selections and
|
||||||
|
invalid existing directories fail the command. ConfigKit follows a symlink used
|
||||||
|
as the selected directory, but immediate entries must be regular non-symlink
|
||||||
|
files. Existing environment variables—including empty values—always win.
|
||||||
|
|
||||||
|
S3 `access_key_id` and `secret_access_key` are an optional pair of typed
|
||||||
|
environment references. When both are absent, the AWS SDK retains its default
|
||||||
|
credential provider chain. When both are configured, Narratio supplies their
|
||||||
|
load-time resolved values through an explicit static provider. Audita's optional
|
||||||
|
`llm_api_key` is likewise captured at pipeline load and supplied only through
|
||||||
|
the child process's `AUDITA_LLM_API_KEY` override.
|
||||||
|
|
||||||
External adapter results are individually bounded before Narratio validates or
|
External adapter results are individually bounded before Narratio validates or
|
||||||
materializes them. These per-file limits do not reserve disk space: prevent hard
|
materializes them. These per-file limits do not reserve disk space: prevent hard
|
||||||
@@ -497,17 +607,29 @@ Rules:
|
|||||||
- `clean` deletes work/spool session state;
|
- `clean` deletes work/spool session state;
|
||||||
- cache is preserved unless `--clear-cache` is set;
|
- cache is preserved unless `--clear-cache` is set;
|
||||||
- each deletion is confined beneath its configured workspace, spool, or cache
|
- each deletion is confined beneath its configured workspace, spool, or cache
|
||||||
root and refuses symlinked paths;
|
root; root, traversal, and out-of-root targets are refused;
|
||||||
- automatic post-publish cleanup is gated by successful publish commit plus:
|
- the final cleanup stage is gated by successful publish evidence plus:
|
||||||
- `pipeline.spool.delete_audio_after_publish=true`
|
- `pipeline.spool.delete_audio_after_publish=true`
|
||||||
- `pipeline.workspace.cleanup_after_publish=true`
|
- `pipeline.workspace.cleanup_after_publish=true`
|
||||||
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
|
- the manifest publish record has `uploaded=true`, the published run ID, the
|
||||||
reports incomplete, the remote committed snapshot remains current; rerun
|
exact remote commit key, and the current commit-pointer key.
|
||||||
publish to retry only the outstanding confined local cleanup.
|
|
||||||
|
|
||||||
Post-publish cleanup is evaluated only when `publish` actually executes in the
|
Cleanup derives only the configured run-scoped spool and workspace targets,
|
||||||
current invocation. A bounded range that excludes publish does not replay a
|
uses root-confined deletion, and does not read or mutate object storage. A
|
||||||
cleanup obligation as an unrelated side effect.
|
failure leaves publish succeeded and the remote committed snapshot current.
|
||||||
|
Retry with a normal full run: valid upstream work, including publish and notify,
|
||||||
|
is skipped while the failed cleanup stage runs again. `run-stage cleanup` is
|
||||||
|
also available when the manifest has valid publish evidence. A range ending at
|
||||||
|
`notify` or earlier and the standalone `publish` command never delete local
|
||||||
|
state.
|
||||||
|
|
||||||
|
Older local manifests that contain the retired cleanup-obligation field are
|
||||||
|
intentionally incompatible with strict manifest decoding. If such an
|
||||||
|
obligation is still outstanding, complete it with the pre-upgrade binary.
|
||||||
|
Otherwise verify that the remote publication is committed, move aside or
|
||||||
|
regenerate the incompatible local manifest (or restore the verified remote
|
||||||
|
snapshot), and then use the cleanup stage. Manual JSON editing is not the
|
||||||
|
normal migration path.
|
||||||
|
|
||||||
## Operational Caveats
|
## Operational Caveats
|
||||||
|
|
||||||
@@ -515,4 +637,5 @@ cleanup obligation as an unrelated side effect.
|
|||||||
- Publish requires prerequisite stages through `render` and `analyze` to be succeeded.
|
- Publish requires prerequisite stages through `render` and `analyze` to be succeeded.
|
||||||
- Markdown publish defaults require render outputs (`transcripts/final.md` and `transcripts/final.trimmed.md`).
|
- Markdown publish defaults require render outputs (`transcripts/final.md` and `transcripts/final.trimmed.md`).
|
||||||
- Restore requires configured object storage and committed remote current state.
|
- Restore requires configured object storage and committed remote current state.
|
||||||
- Storage-backed commands load filesystem secrets before object-store initialization.
|
- Pipeline-loading commands bootstrap filesystem secrets once before configuration
|
||||||
|
resolution and object-store initialization.
|
||||||
|
|||||||
@@ -116,8 +116,11 @@ adapter and publish metadata.
|
|||||||
|
|
||||||
Resume and skip decisions are manifest-driven. Filesystem state may be
|
Resume and skip decisions are manifest-driven. Filesystem state may be
|
||||||
inspected and validated, but file presence alone does not replace recorded
|
inspected and validated, but file presence alone does not replace recorded
|
||||||
stage state. Invocation-scoped run records provide an audit of one execution;
|
stage state. Each invocation retains an in-memory command summary and a
|
||||||
they do not replace the session manifest as progress authority.
|
run-local workspace identity, but neither is a durable progress ledger. The
|
||||||
|
physical run directory is created on demand only when an executing stage
|
||||||
|
requires it; a fully reused invocation or a cleanup-only invocation does not
|
||||||
|
leave an empty run directory.
|
||||||
|
|
||||||
Restore treats committed remote current state as its authority. It must plan
|
Restore treats committed remote current state as its authority. It must plan
|
||||||
deterministically, confine remote-to-local paths, protect local conflicts, and
|
deterministically, confine remote-to-local paths, protect local conflicts, and
|
||||||
@@ -143,8 +146,16 @@ Configuration is strict, explicit, centralized, and operator-oriented.
|
|||||||
- YAML decoding rejects unknown fields.
|
- YAML decoding rejects unknown fields.
|
||||||
- Defaults are centralized and testable.
|
- Defaults are centralized and testable.
|
||||||
- Empty configured values do not silently replace meaningful defaults.
|
- Empty configured values do not silently replace meaningful defaults.
|
||||||
- Validation rejects invalid composition before stage execution where
|
- Validation rejects invalid configuration before stage execution where
|
||||||
practical.
|
practical.
|
||||||
|
- Root-owned imports and one selected profile resolve deterministically through
|
||||||
|
the configuration owner; commands do not implement their own merge rules.
|
||||||
|
- Canonical party rosters are campaign-owned. Their derived players projection
|
||||||
|
and concrete character-family artifacts are resolved before runtime stages
|
||||||
|
or adapters receive configuration.
|
||||||
|
- Resume uses stage- or artifact-owned semantic evidence for observable
|
||||||
|
result-affecting configuration; profile identity and an effective digest are
|
||||||
|
provenance, never blanket cache keys.
|
||||||
- Session templating remains narrow and deterministic rather than becoming a
|
- Session templating remains narrow and deterministic rather than becoming a
|
||||||
general configuration language.
|
general configuration language.
|
||||||
- Secret values are supplied indirectly and are not persisted in ordinary
|
- Secret values are supplied indirectly and are not persisted in ordinary
|
||||||
@@ -177,8 +188,8 @@ availability.
|
|||||||
Writes, moves, replacements, and deletions must use narrow, explicit,
|
Writes, moves, replacements, and deletions must use narrow, explicit,
|
||||||
root-confined destinations. Symlinks, traversal, broad roots, and ambiguous
|
root-confined destinations. Symlinks, traversal, broad roots, and ambiguous
|
||||||
relative destinations must not expand the scope of an operation. Cleanup is
|
relative destinations must not expand the scope of an operation. Cleanup is
|
||||||
permitted only through explicit operator action or configured post-publish
|
permitted only through explicit operator action or the configured final cleanup
|
||||||
gates, and it must preserve durable cache unless cache removal is explicitly
|
stage, and it must preserve durable cache unless cache removal is explicitly
|
||||||
requested.
|
requested.
|
||||||
|
|
||||||
Physical layout, retention, and operational lifecycle belong in
|
Physical layout, retention, and operational lifecycle belong in
|
||||||
@@ -194,16 +205,26 @@ the immutable commit manifest, and finally the current commit pointer.
|
|||||||
`current/commit-pointer.json` is the sole mutable selector and must be written
|
`current/commit-pointer.json` is the sole mutable selector and must be written
|
||||||
exactly once, last. Failed, incomplete, skipped, or uncommitted publish attempts
|
exactly once, last. Failed, incomplete, skipped, or uncommitted publish attempts
|
||||||
must not be presented as current remote state. Publish locks remain authoritative
|
must not be presented as current remote state. Publish locks remain authoritative
|
||||||
and are not bypassed by a forced run. Mutable remote locks use provider-enforced
|
and are not bypassed by a forced run. Each mutable remote lock is an independent
|
||||||
generation preconditions and are revalidated immediately before pointer
|
per-source object; lock mutations use provider-enforced generation preconditions.
|
||||||
selection; loss of that check leaves the prior committed snapshot current.
|
Publish revalidates every initially unlocked participating source after writing
|
||||||
|
the immutable commit and before pointer selection. A lock observed then leaves
|
||||||
|
the prior committed snapshot current. A lock created after that final check may
|
||||||
|
race the pointer write; the commit protocol does not claim a stronger distributed
|
||||||
|
transaction boundary.
|
||||||
|
|
||||||
Automatic local cleanup is permitted only after a successful publish commit,
|
Automatic local cleanup is permitted only after a successful publish commit,
|
||||||
only when explicitly configured, and only through the path-safety guardrails.
|
only when explicitly configured, and only through the path-safety guardrails.
|
||||||
It is a durable local obligation bound to that committed run and its exact
|
It is the final ordinary pipeline stage after `notify`. The stage derives its
|
||||||
targets, not an inferred side effect of the current stage list. A cleanup
|
exact run-scoped targets from validated publish metadata and the configured
|
||||||
failure makes the invocation incomplete while leaving the committed remote
|
campaign/session identity; it never infers a target from directory contents or
|
||||||
snapshot authoritative; later invocations resume the recorded obligation.
|
rereads remote storage. A cleanup failure makes the invocation incomplete while
|
||||||
|
leaving publish succeeded and the committed remote snapshot authoritative. A
|
||||||
|
normal later full run reuses valid upstream stages and retries cleanup.
|
||||||
|
|
||||||
|
The standalone `publish` command selects only publish and therefore never
|
||||||
|
invokes cleanup. Operators may select cleanup directly only when the manifest
|
||||||
|
contains valid successful publication evidence.
|
||||||
|
|
||||||
## Security, Privacy, And Diagnostics
|
## Security, Privacy, And Diagnostics
|
||||||
|
|
||||||
@@ -213,20 +234,26 @@ configuration, logs, reports, diagnostics, and Notarius artifacts—is
|
|||||||
intentionally shareable with the configured workspace group. API-key material
|
intentionally shareable with the configured workspace group. API-key material
|
||||||
is sensitive and is not covered by the ordinary workspace-sharing policy.
|
is sensitive and is not covered by the ordinary workspace-sharing policy.
|
||||||
|
|
||||||
On POSIX systems, Narratio-created ordinary workspace directories converge on
|
Narratio supports Linux on amd64 and arm64. macOS arm64 is best effort: it has
|
||||||
|
a cross-build gate and release asset, but no native runtime release gate.
|
||||||
|
Windows and macOS amd64 are unsupported and have no compatibility promise or
|
||||||
|
release asset.
|
||||||
|
|
||||||
|
On supported systems, Narratio-created ordinary workspace directories converge on
|
||||||
setgid `02775` and ordinary workspace files on `0664`, even when the caller's
|
setgid `02775` and ordinary workspace files on `0664`, even when the caller's
|
||||||
umask is restrictive. This preserves the existing workspace group for nested
|
umask is restrictive. This preserves the existing workspace group for nested
|
||||||
creation and atomic replacements without changing ownership. API-key storage
|
creation and atomic replacements without changing ownership. API-key storage
|
||||||
uses a separate restrictive contract. On Windows, POSIX mode bits and setgid
|
uses a separate restrictive contract.
|
||||||
are not authoritative; operators must provide the equivalent shared-group and
|
|
||||||
credential-restricted ACLs described in [Operations](../operations.md#workspace-permissions).
|
|
||||||
|
|
||||||
Raw secrets must not be stored in pipeline, campaign, or session YAML or written
|
Raw secrets must not be stored in pipeline, campaign, or session YAML or written
|
||||||
to manifests, logs, generated configuration, reports, publish metadata,
|
to manifests, logs, generated configuration, reports, publish metadata,
|
||||||
documentation, or examples. Secrets enter through configured environment
|
documentation, or examples. Pipeline configuration names environment variables;
|
||||||
variable names or secret-file references. Diagnostics should avoid transcript
|
command-owned startup may populate previously unset variables from ConfigKit's
|
||||||
and prompt content unless a deliberate, bounded inspection mechanism requires
|
explicit or conventional secrets directory. This bootstrap occurs once per
|
||||||
it.
|
invocation before any typed configuration load or adapter construction and is
|
||||||
|
not hidden inside library loaders. Safe bootstrap diagnostics may identify
|
||||||
|
files and statuses but never values. Diagnostics should avoid transcript and
|
||||||
|
prompt content unless a deliberate, bounded inspection mechanism requires it.
|
||||||
|
|
||||||
Logs, reports, generated invocation files, generated configuration, and render
|
Logs, reports, generated invocation files, generated configuration, and render
|
||||||
debug files are diagnostics, not canonical pipeline products. They should be
|
debug files are diagnostics, not canonical pipeline products. They should be
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ secret values.
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| Product orientation and minimal end-to-end quickstart | `README.md` | What Narratio is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. |
|
| Product orientation and minimal end-to-end quickstart | `README.md` | What Narratio is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. |
|
||||||
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, minimal contributor orientation, baseline validation commands, and links to canonical docs. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
|
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, minimal contributor orientation, baseline validation commands, and links to canonical docs. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
|
||||||
|
| Maintainer release procedure | `docs/release.md` | Version selection, candidate preparation and validation, guarded tag publication, release completion boundary, failure recovery, and optional asynchronous inspection. | Script implementation mechanics, current application contracts, and historical release summaries. |
|
||||||
|
| Historical release summary | `docs/releases/<tag>.md` | Immutable summary, compatibility, upgrade, and changes for one released version. | Current maintainer procedure and current application contract details. |
|
||||||
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
|
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
|
||||||
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
|
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
|
||||||
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
|
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
|
||||||
|
|||||||
104
docs/release.md
Normal file
104
docs/release.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Releasing Narratio
|
||||||
|
|
||||||
|
This document is the maintainer procedure for creating a Narratio source and
|
||||||
|
binary release. The synchronous release boundary is a successful push of one
|
||||||
|
new tag to `origin`; Woodpecker and Gitea publication happen later and do not
|
||||||
|
change that result.
|
||||||
|
|
||||||
|
## Go prerequisite
|
||||||
|
|
||||||
|
Release preparation requires the maintained Go 1.26 baseline. Tag CI currently
|
||||||
|
uses the explicitly pinned `golang:1.26.7` image for candidate validation and
|
||||||
|
asset construction; that asynchronous work does not change the tag-push
|
||||||
|
completion boundary.
|
||||||
|
|
||||||
|
## Choose a version and write its note
|
||||||
|
|
||||||
|
Narratio is past `v1.0.0`. Use an unused stable tag in the exact form
|
||||||
|
`vMAJOR.MINOR.PATCH`:
|
||||||
|
|
||||||
|
- increment `MINOR` for backward-compatible features;
|
||||||
|
- increment `PATCH` for backward-compatible fixes; and
|
||||||
|
- reserve a new `MAJOR` for an intentional breaking documented contract.
|
||||||
|
|
||||||
|
Before preparing the candidate, create
|
||||||
|
`docs/releases/vMAJOR.MINOR.PATCH.md` with this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Narratio vMAJOR.MINOR.PATCH
|
||||||
|
|
||||||
|
This release ...
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
```
|
||||||
|
|
||||||
|
The compatibility section identifies relevant CLI, configuration, artifact,
|
||||||
|
integration, or operating-contract changes. The upgrade section states the
|
||||||
|
required operator action, or explicitly says that no special action is
|
||||||
|
required. Release notes are immutable historical summaries; link to the
|
||||||
|
current canonical documentation for detailed behavior.
|
||||||
|
|
||||||
|
Commit the note and all candidate changes, then use the ordinary development
|
||||||
|
workflow to push that commit to `main`. Do not create a release tag before the
|
||||||
|
candidate is committed and `origin/main` contains the exact same commit.
|
||||||
|
|
||||||
|
## Validate the candidate
|
||||||
|
|
||||||
|
Run the shared checker from any directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/check-release-candidate.sh vMAJOR.MINOR.PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
It validates the version and matching note, module hygiene, formatting,
|
||||||
|
whitespace, uncached tests, race tests, static checks, documentation, examples,
|
||||||
|
and three official cross-build assets. It uses `GOWORK=off`, does not contact
|
||||||
|
application services, CI, or Gitea, and does not create tags or modify tracked
|
||||||
|
source. Fix any failure on `main`, commit it, push it normally, and rerun the
|
||||||
|
checker.
|
||||||
|
|
||||||
|
The checker builds Linux assets for `amd64` and `arm64` and a macOS asset for
|
||||||
|
`arm64`. The macOS cross-build proves compilation; it is not native macOS
|
||||||
|
runtime evidence.
|
||||||
|
|
||||||
|
## Publish the tag
|
||||||
|
|
||||||
|
From a clean checkout on `main` whose `HEAD` equals `origin/main`, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/release.sh vMAJOR.MINOR.PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
The command fetches and checks `origin/main`, re-runs candidate validation,
|
||||||
|
then fetches and checks again before creating an explicitly unsigned lightweight
|
||||||
|
tag for the originally recorded commit. It refuses dirty, divergent, changed,
|
||||||
|
or already-tagged candidates. It pushes only:
|
||||||
|
|
||||||
|
```text
|
||||||
|
refs/tags/vMAJOR.MINOR.PATCH:refs/tags/vMAJOR.MINOR.PATCH
|
||||||
|
```
|
||||||
|
|
||||||
|
It never commits changes, pushes `main`, force-pushes, moves a tag, or pushes
|
||||||
|
all tags. A successful push of that exact ref completes the release command;
|
||||||
|
the command prints the tag and commit, then returns without waiting for CI,
|
||||||
|
querying Gitea, downloading assets, or checking checksums.
|
||||||
|
|
||||||
|
If a failure occurs before the tag is created, correct the candidate on `main`
|
||||||
|
and repeat validation. If the push fails after local tag creation, the local tag
|
||||||
|
is intentionally retained for inspection and the command must not be retried
|
||||||
|
blindly. Once the upstream tag has been pushed, it is immutable. Correct any
|
||||||
|
defect or failed asynchronous publication with a new patch version, a new
|
||||||
|
release note, and the complete procedure again.
|
||||||
|
|
||||||
|
## Optional asynchronous inspection
|
||||||
|
|
||||||
|
After a successful tag push, a human may later inspect the tag-triggered
|
||||||
|
Woodpecker run and the corresponding Gitea release for binaries and checksums.
|
||||||
|
This is optional follow-up only. Automated releasers must not wait for, poll,
|
||||||
|
or treat CI/Gitea completion as a condition of the successful tag push.
|
||||||
@@ -1,7 +1,28 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
This directory contains the maintained release-note text for Narratio releases.
|
This directory contains immutable historical release notes for Narratio.
|
||||||
The corresponding Gitea release is the canonical source for downloadable
|
Future notes are created with the matching stable version and use this minimum
|
||||||
binaries and checksums.
|
structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Narratio vMAJOR.MINOR.PATCH
|
||||||
|
|
||||||
|
This release ...
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [release procedure](../release.md) for creating a candidate and tag.
|
||||||
|
When asynchronous publication succeeds, the corresponding Gitea release is the
|
||||||
|
canonical source for downloadable binaries and checksums.
|
||||||
|
|
||||||
|
- [v2.0.0](v2.0.0.md)
|
||||||
|
- [v1.7.0](v1.7.0.md)
|
||||||
|
- [v1.6.0](v1.6.0.md)
|
||||||
- [v1.5.0](v1.5.0.md)
|
- [v1.5.0](v1.5.0.md)
|
||||||
|
|||||||
74
docs/releases/v1.6.0.md
Normal file
74
docs/releases/v1.6.0.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Narratio v1.6.0
|
||||||
|
|
||||||
|
Narratio v1.6.0 makes large pipeline configurations easier to organize,
|
||||||
|
inspect, and vary while adding character-oriented artifact generation and a
|
||||||
|
guarded, reproducible release procedure.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Pipeline configuration can now be assembled from explicit additive imports and
|
||||||
|
a selected production or testing profile. Campaigns can own a canonical,
|
||||||
|
versioned party roster, and Scriptorium artifact families can expand one
|
||||||
|
definition into concrete per-character artifacts, dependencies, variables, and
|
||||||
|
publish rules.
|
||||||
|
|
||||||
|
New read-only configuration commands expose the fully resolved pipeline,
|
||||||
|
source provenance, semantic digest, and profile differences before a session is
|
||||||
|
run. Stage reuse now records configuration-sensitive semantic evidence so
|
||||||
|
profile or configuration changes cannot silently reuse incompatible work.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible feature release. Existing monolithic pipeline
|
||||||
|
files, concrete Scriptorium artifacts, publish rules, and configurations
|
||||||
|
without profiles remain supported. When profiles are declared and no explicit
|
||||||
|
profile is selected, Narratio uses the configured production default.
|
||||||
|
|
||||||
|
An unversioned party file plus a separate players file remains available as an
|
||||||
|
isolated legacy compatibility path, but it cannot drive artifact families. New
|
||||||
|
campaigns and new party-oriented features should use the `narratio.party.v1`
|
||||||
|
schema. Existing manifests remain readable; missing legacy semantic evidence
|
||||||
|
is treated as stale rather than trusted.
|
||||||
|
|
||||||
|
Narratio continues to consume the documented Notarius D&D pipeline contract.
|
||||||
|
The release workflow still cross-compiles Linux, macOS, and Windows binaries
|
||||||
|
for `amd64` and `arm64`; cross-compilation is not native runtime evidence for
|
||||||
|
macOS or Windows.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No special action is required for existing monolithic configurations that do
|
||||||
|
not adopt profiles or artifact families. On the first run after upgrading,
|
||||||
|
stages recorded by older manifests may regenerate once because those records do
|
||||||
|
not contain the new semantic configuration evidence.
|
||||||
|
|
||||||
|
To adopt the new configuration model, use the maintained
|
||||||
|
`examples/production-testing` bundle as a migration reference: split stable
|
||||||
|
settings into explicit imports, define a production default and optional
|
||||||
|
testing profile, convert campaign party data to `narratio.party.v1`, remove the
|
||||||
|
separate players file, and then introduce character artifact families. Review
|
||||||
|
the result with `narratio config validate`, `config show`, `config sources`, and
|
||||||
|
`config diff` before running a session.
|
||||||
|
|
||||||
|
Campaign, session, previous-session, and run identifiers must satisfy the
|
||||||
|
documented portable identity grammar. Existing manifests or remote state with
|
||||||
|
unsafe legacy identifiers must be migrated before use.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added root-owned, non-recursive additive pipeline imports with strict,
|
||||||
|
source-aware conflict detection.
|
||||||
|
- Added named pipeline profiles with an explicit production default and
|
||||||
|
deliberate command-line selection.
|
||||||
|
- Added `config validate`, `config show`, `config sources`, and `config diff`
|
||||||
|
for read-only inspection of resolved configuration and provenance.
|
||||||
|
- Added the strict `narratio.party.v1` campaign roster, including stable
|
||||||
|
character IDs, player and character names, optional aliases, and classes.
|
||||||
|
- Added deterministic players derivation and canonical party delivery to
|
||||||
|
downstream integrations.
|
||||||
|
- Added character-oriented artifact families, corresponding member
|
||||||
|
dependencies, family selection, and generated publish policies.
|
||||||
|
- Added semantic configuration fingerprints and stage-specific resume checks
|
||||||
|
across transcript and artifact stages.
|
||||||
|
- Added shared release candidate, asset build, and guarded tag-publication
|
||||||
|
scripts, with tag-triggered publication remaining asynchronous.
|
||||||
100
docs/releases/v1.7.0.md
Normal file
100
docs/releases/v1.7.0.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Narratio v1.7.0
|
||||||
|
|
||||||
|
Narratio v1.7.0 completes the maintainability refactor begun after v1.6.0. It
|
||||||
|
simplifies filesystem and process handling, makes resumable work more precise,
|
||||||
|
replaces legacy local and remote state with explicit authorities, and turns
|
||||||
|
cleanup into the final ordinary pipeline stage.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The session manifest is now the sole durable local progress ledger. Analyze
|
||||||
|
checkpoints successful artifacts individually, allowing an interrupted or
|
||||||
|
partially failed analysis to resume at the first incomplete artifact without
|
||||||
|
rerunning successful siblings unless forced.
|
||||||
|
|
||||||
|
Remote publication now commits immutable run state before advancing one
|
||||||
|
verified current pointer. Publish locks are independent per-source objects;
|
||||||
|
locked outputs retain their previously committed declarations rather than
|
||||||
|
being overwritten or silently omitted. Local cleanup runs after notification
|
||||||
|
as a separately recorded, safely retryable stage.
|
||||||
|
|
||||||
|
The release also adopts Go 1.26, simplifies ordinary file operations around
|
||||||
|
Narratio's trusted group-owned workspace model, improves subprocess lifecycle
|
||||||
|
handling, decomposes several large orchestration and validation components,
|
||||||
|
and removes mutable global test seams.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a minor release with deliberate removal of compatibility paths that
|
||||||
|
were deprecated and documented as subject to removal:
|
||||||
|
|
||||||
|
- Campaigns must use one versioned `narratio.party.v1` `party.yml`. Separate
|
||||||
|
players files, session-level party overrides, and unversioned party documents
|
||||||
|
are rejected.
|
||||||
|
- Session manifests must declare `schema_version:
|
||||||
|
narratio.session-manifest.v1` and satisfy the current strict schema. Older
|
||||||
|
unversioned state and manifests containing the retired
|
||||||
|
`post_publish_cleanup` obligation are rejected rather than migrated.
|
||||||
|
- Remote current state must be selected by
|
||||||
|
`current/commit-pointer.json` and its referenced immutable run commit. Legacy
|
||||||
|
current-state layouts are no longer inferred.
|
||||||
|
- The retired aggregate `locks.yml` object is ignored. Publish locks now live
|
||||||
|
as independent strict objects under the session lock prefix.
|
||||||
|
- Run manifests are no longer produced or consumed. The session manifest owns
|
||||||
|
durable stage progress; invocation summaries and run workspaces remain
|
||||||
|
operational data rather than a second progress authority.
|
||||||
|
|
||||||
|
The CLI and current pipeline/configuration formats otherwise remain compatible.
|
||||||
|
Building and maintaining Narratio now requires Go 1.26. Cross-built Linux,
|
||||||
|
macOS, and Windows assets remain available for `amd64` and `arm64`.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Before upgrading a campaign that still uses compatibility data:
|
||||||
|
|
||||||
|
1. Replace legacy party/player inputs with the documented
|
||||||
|
[`narratio.party.v1`](../integrations/party.md) campaign roster.
|
||||||
|
2. If a local manifest contains an outstanding `post_publish_cleanup`
|
||||||
|
obligation, complete it with the pre-upgrade binary. Otherwise verify the
|
||||||
|
remote commit, move aside or regenerate the incompatible manifest, or
|
||||||
|
restore a verified committed snapshot. Do not migrate state by manually
|
||||||
|
editing JSON.
|
||||||
|
3. Republish sessions that do not yet have a valid immutable run commit and
|
||||||
|
`current/commit-pointer.json`; legacy remote layouts are not current state in
|
||||||
|
v1.7.0.
|
||||||
|
4. Recreate any required publication locks with `narratio session locks add`.
|
||||||
|
After verifying the new locks and publication behavior, the ignored legacy
|
||||||
|
`locks.yml` object may be removed manually.
|
||||||
|
|
||||||
|
Existing compatible session manifests and already committed remote snapshots
|
||||||
|
need no special action. Consult [Operations](../operations.md) and
|
||||||
|
[Troubleshooting](../troubleshooting.md) for recovery procedures.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Upgraded the maintained development and release baseline to Go 1.26.
|
||||||
|
- Reworked subprocess execution and Windows process-tree supervision while
|
||||||
|
preserving contextual wait and cleanup failures.
|
||||||
|
- Replaced custom confined ordinary I/O and directory-promotion machinery with
|
||||||
|
simpler standard-library operations while retaining bounded reads, atomic
|
||||||
|
single-file visibility, workspace modes, validated downloads, and
|
||||||
|
root-confined destructive operations.
|
||||||
|
- Removed legacy party, manifest, artifact-discovery, remote-current, and lock
|
||||||
|
compatibility paths.
|
||||||
|
- Consolidated durable progress in the session manifest and removed the run
|
||||||
|
manifest domain model.
|
||||||
|
- Unified runner planning and execution decisions, separated invocation
|
||||||
|
lifecycle responsibilities, and avoided creating empty run workspaces for
|
||||||
|
fully reused or cleanup-only invocations.
|
||||||
|
- Added per-artifact analyze execution, checkpointing, reconciliation, and
|
||||||
|
recovery.
|
||||||
|
- Decomposed configuration validation by domain and made validation diagnostics
|
||||||
|
deterministic without treating incidental ordering or wording as a contract.
|
||||||
|
- Introduced immutable remote publish commits, one final current pointer,
|
||||||
|
explicit output planning, safe cross-run carried outputs, and independent
|
||||||
|
per-artifact remote locks.
|
||||||
|
- Added `cleanup` as the final canonical stage after `notify`, with validated
|
||||||
|
publish evidence, safe retry, explicit selection, and no separate cleanup
|
||||||
|
obligation.
|
||||||
|
- Replaced mutable command test hooks with invocation-scoped dependencies and
|
||||||
|
improved structural test diagnostics.
|
||||||
94
docs/releases/v2.0.0.md
Normal file
94
docs/releases/v2.0.0.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Narratio v2.0.0
|
||||||
|
|
||||||
|
Narratio v2.0.0 replaces its configuration infrastructure with ConfigKit,
|
||||||
|
simplifies the supported release platforms, and makes artifact definitions and
|
||||||
|
their effective dependency graph directly inspectable.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Pipeline roots now use ConfigKit v0.1.2 for strict YAML loading, explicit
|
||||||
|
imports, fixed-path named profiles, provenance, defaults, and typed credential
|
||||||
|
references. The recommended production layout keeps production values in the
|
||||||
|
base, defines each ordinary artifact and artifact family in its own imported
|
||||||
|
file, and keeps testing changes in `profiles/testing.yml`.
|
||||||
|
|
||||||
|
Configured-artifact inputs now create their dependency edges automatically.
|
||||||
|
`narratio config artifacts` reports the finalized concrete catalog, including
|
||||||
|
generated party-family members, without loading a session or initializing
|
||||||
|
runtime adapters.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a breaking configuration and platform release. There is no runtime
|
||||||
|
fallback for the retired Narratio composition/profile catalog, `pipeline.secrets`
|
||||||
|
or `secrets.env_dir`, credential `_env` fields, or the previous YAML
|
||||||
|
presence/default behavior. Defaults apply only to absent values; explicit empty,
|
||||||
|
zero, false, and null values retain their typed meaning and may fail Narratio
|
||||||
|
domain validation.
|
||||||
|
|
||||||
|
The maintained binaries are Linux amd64, Linux arm64, and macOS arm64. Windows
|
||||||
|
and macOS amd64 builds are no longer produced or supported. Existing v1 release
|
||||||
|
notes remain historical descriptions of their releases.
|
||||||
|
|
||||||
|
Inspection commands still avoid session, manifest, workspace, adapter, and
|
||||||
|
remote-service activity, but they are not credential-free loaders: every
|
||||||
|
explicit typed credential reference in the selected effective configuration must
|
||||||
|
resolve; references replaced or removed by that profile need not resolve.
|
||||||
|
Pipelines containing artifact families also require a campaign so the canonical
|
||||||
|
party can be expanded.
|
||||||
|
`config show` is a secret-safe review projection, not a promised reloadable
|
||||||
|
authoring file.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Migrate each operational bundle before running v2:
|
||||||
|
|
||||||
|
1. Replace old composition syntax with root-owned
|
||||||
|
[`configkit.imports`](../config.md#pipeline-imports-and-profiles). Imports are
|
||||||
|
additive base sources; they do not override one another.
|
||||||
|
2. Fold the old production overlay into the base pipeline. Move testing changes
|
||||||
|
to `profiles/testing.yml`; compare the two independent resolutions with
|
||||||
|
`narratio config diff default testing`.
|
||||||
|
3. Follow the maintained
|
||||||
|
[`examples/production-testing`](../../examples/production-testing/pipeline.yml)
|
||||||
|
layout: one explicit artifact entry per `artifacts/<key>.yml` and one family
|
||||||
|
entry per `artifact-families/<key>.yml`. The map key, not the filename, is the
|
||||||
|
stable artifact identity.
|
||||||
|
4. Move credential files to an untracked `secrets/` directory beside
|
||||||
|
`pipeline.yml`, or pass `--secrets-dir`. Rename S3 fields to `access_key_id`
|
||||||
|
and `secret_access_key`, and rename the Audita field to `llm_api_key`.
|
||||||
|
5. For static S3 credentials, configure both S3 references (commonly
|
||||||
|
`OBJECT_STORAGE_KEY_ID` and `OBJECT_STORAGE_KEY`) and make them resolvable.
|
||||||
|
To use instance roles, workload identity, shared SDK files, or another AWS SDK
|
||||||
|
discovery provider, omit both references. A partial pair is invalid.
|
||||||
|
6. Remove a `depends_on` entry when it only duplicates a
|
||||||
|
`narratio.artifact.<key>` input. Keep explicit entries that express ordering
|
||||||
|
without supplying the upstream output as an input.
|
||||||
|
7. Review ConfigKit's stricter explicit-value/null semantics and correct values
|
||||||
|
that previously acquired defaults after being explicitly empty. Do not use
|
||||||
|
`config show` output as a source migration mechanism.
|
||||||
|
8. Run `config validate`, `config show`, `config sources`, and `config artifacts`
|
||||||
|
with the same profile, campaign, and secrets selection intended for runtime.
|
||||||
|
Resolve the campaign-owned party whenever families are present.
|
||||||
|
9. Update deployment and download automation to one of the three maintained
|
||||||
|
platform assets before removing the v1 binary.
|
||||||
|
|
||||||
|
The [configuration reference](../config.md), [CLI reference](../cli.md), and
|
||||||
|
[maintained examples](../../examples/README.md) are the canonical detailed
|
||||||
|
guides.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Adopted ConfigKit v0.1.2 across pipeline, campaign, session, and party loading
|
||||||
|
while keeping Narratio domain validation and post-campaign expansion explicit.
|
||||||
|
- Added ConfigKit imports and fixed `profiles/<name>.yml` pipeline overlays,
|
||||||
|
unified safe effective show/diff/digest projection, and source provenance.
|
||||||
|
- Added invocation-scoped ConfigKit secrets-directory bootstrap and typed S3 and
|
||||||
|
Audita credential references without exposing resolved values.
|
||||||
|
- Inferred, normalized, and validated configured-artifact input dependencies as
|
||||||
|
part of the one concrete graph consumed by analyze planning and resume logic.
|
||||||
|
- Added `config artifacts` with stable concrete identities, definition sources,
|
||||||
|
effective dependencies, and generated family/party provenance.
|
||||||
|
- Added the maintained production-base/testing-overlay artifact authoring layout.
|
||||||
|
- Reduced release assets and active platform support to Linux amd64/arm64 and
|
||||||
|
macOS arm64.
|
||||||
375
docs/roadmap/configkit-adoption.md
Normal file
375
docs/roadmap/configkit-adoption.md
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
# ConfigKit Adoption and Platform Simplification
|
||||||
|
|
||||||
|
**Status:** Accepted; implementation pending
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Narratio currently owns a substantial YAML configuration subsystem for strict
|
||||||
|
decoding, explicit imports, additive composition, profile overlays, presence
|
||||||
|
tracking, provenance, and effective-value inspection. ConfigKit was extracted
|
||||||
|
from this implementation and now provides those generic responsibilities as a
|
||||||
|
maintained library.
|
||||||
|
|
||||||
|
Narratio 2.0 will adopt ConfigKit as its configuration foundation, remove the
|
||||||
|
superseded generic infrastructure, and retain only Narratio-specific configuration
|
||||||
|
policy and domain resolution. The same major release will narrow the supported
|
||||||
|
platform set to the systems that Narratio is expected to run on in practice.
|
||||||
|
|
||||||
|
This roadmap defines the desired contracts and end state. The
|
||||||
|
[implementation plan](implementation.md) owns sequencing and completion gates.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Reduce Narratio-owned configuration code and tests by delegating generic YAML
|
||||||
|
mechanics to ConfigKit.
|
||||||
|
- Establish one strict and explainable configuration lifecycle rather than
|
||||||
|
retaining parallel or compatibility loaders.
|
||||||
|
- Preserve Narratio's application-specific defaults, validation, campaign/session
|
||||||
|
resolution, party handling, artifact-family expansion, digests, and CLI policy.
|
||||||
|
- Make individual artifact definitions easy to organize, identify, inspect, and
|
||||||
|
reuse as dependencies of later artifacts.
|
||||||
|
- Keep raw credentials out of YAML and adopt a predictable secrets-directory
|
||||||
|
convention that works before configuration loading.
|
||||||
|
- Remove unsupported platform code and release obligations that provide no known
|
||||||
|
user value.
|
||||||
|
- Deliver the resulting contract changes as Narratio v2.0.0 with a clear migration
|
||||||
|
guide.
|
||||||
|
|
||||||
|
## Platform Contract
|
||||||
|
|
||||||
|
Narratio's v2 platform matrix is:
|
||||||
|
|
||||||
|
| Platform | Status | Required evidence and deliverables |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Linux amd64 | Primary, supported | Default tests, race checks, static analysis, build validation, and a release asset |
|
||||||
|
| Linux arm64 | Primary, supported | Cross-build validation and a release asset; native validation is used when an appropriate runner is available |
|
||||||
|
| macOS arm64 | Best effort | Cross-build validation and a release asset; native CI and platform-equivalent runtime guarantees are not required release gates |
|
||||||
|
| macOS amd64 | Unsupported | No build gate, release asset, compatibility promise, or platform-specific maintenance |
|
||||||
|
| Windows | Unsupported | No build gate, release asset, compatibility promise, or platform-specific maintenance |
|
||||||
|
|
||||||
|
Windows- and Intel-Mac-specific code, tests, CI entries, release packaging, and
|
||||||
|
documentation are removed when they no longer serve a supported build. Portable
|
||||||
|
code should remain portable where doing so is natural, but accidental compilation
|
||||||
|
on an unsupported target does not create a support commitment.
|
||||||
|
|
||||||
|
Best-effort macOS arm64 support means Narratio intends to keep the target buildable
|
||||||
|
and will consider reported defects when practical. Lack of a native runner or an
|
||||||
|
unverified platform-specific behavior does not block a release. A target for which
|
||||||
|
Narratio publishes an asset must still pass its configured cross-build gate.
|
||||||
|
|
||||||
|
## ConfigKit Dependency and Ownership
|
||||||
|
|
||||||
|
Narratio will depend on the tagged ConfigKit module at an exact reviewed version,
|
||||||
|
initially `gitea.maximumdirect.net/eric/ConfigKit@v0.1.1`. ConfigKit is pre-1.0 and
|
||||||
|
may make compatibility changes in later minor versions, so dependency upgrades are
|
||||||
|
intentional changes accompanied by contract review and focused Narratio integration
|
||||||
|
tests; Narratio does not follow an unpinned branch or automatically accept a newer
|
||||||
|
minor release.
|
||||||
|
|
||||||
|
ConfigKit owns:
|
||||||
|
|
||||||
|
- bounded file, byte, and reader input;
|
||||||
|
- the accepted YAML syntax and strict typed decoding;
|
||||||
|
- import declarations, traversal, containment, and resource limits;
|
||||||
|
- additive composition and conflict attribution;
|
||||||
|
- selected-profile overlay semantics;
|
||||||
|
- explicit input presence and source provenance;
|
||||||
|
- application-default and recorded-resolution coordination;
|
||||||
|
- structured configuration diagnostics;
|
||||||
|
- secret-reference parsing and resolution;
|
||||||
|
- secrets-directory loading and its safe report; and
|
||||||
|
- detached effective configuration inspection supplied by its public result.
|
||||||
|
|
||||||
|
Narratio owns:
|
||||||
|
|
||||||
|
- pipeline, campaign, session, party, and runtime domain types;
|
||||||
|
- configuration-file discovery and CLI selection;
|
||||||
|
- concrete defaults, normalization, and semantic validation;
|
||||||
|
- campaign/session identity and stable-input resolution;
|
||||||
|
- canonical party parsing requirements and authored-byte retention;
|
||||||
|
- character artifact-family expansion and generated ownership attribution;
|
||||||
|
- root-relative interpretation of Narratio-owned runtime paths;
|
||||||
|
- effective pipeline digests and semantic profile diffs;
|
||||||
|
- command output and exit behavior; and
|
||||||
|
- adapter and runtime initialization after configuration succeeds.
|
||||||
|
|
||||||
|
Narratio must use ConfigKit through its public package. It must not import
|
||||||
|
ConfigKit's private YAML implementation or reproduce ConfigKit's parser,
|
||||||
|
composition, profile, provenance, or secret-bootstrap mechanics locally.
|
||||||
|
|
||||||
|
## Configuration Format
|
||||||
|
|
||||||
|
Narratio adopts ConfigKit's configuration conventions directly. The old Narratio
|
||||||
|
`composition:` envelope, declared profile catalog, arbitrary overlay paths, and
|
||||||
|
`composition.default_profile` are removed without a compatibility parser.
|
||||||
|
|
||||||
|
The root pipeline uses `configkit.imports` declarations. Import paths, optional
|
||||||
|
imports, globs, recursion, ordering, composition conflicts, containment, and limits
|
||||||
|
have the meanings defined by the pinned ConfigKit contract. Narratio does not add a
|
||||||
|
second import syntax or narrower application-specific traversal implementation.
|
||||||
|
|
||||||
|
The unoverlaid root and its imports are the production configuration. A named
|
||||||
|
profile is a ConfigKit overlay at `profiles/<name>.yml`; in particular,
|
||||||
|
`profiles/testing.yml` contains inexpensive-model overrides and testing-only
|
||||||
|
artifacts. Omitting `--profile`, or selecting ConfigKit's `default` profile, uses the
|
||||||
|
production base. `--profile testing` applies the testing overlay. Narratio does not
|
||||||
|
maintain a separate configured default-profile mechanism or validate unselected
|
||||||
|
profile files.
|
||||||
|
|
||||||
|
ConfigKit's rules for explicit false, zero, empty, and null values replace
|
||||||
|
Narratio's custom presence tracking. Application defaults and normalization use a
|
||||||
|
ConfigKit definition and recorded resolution where the final value depends on
|
||||||
|
whether input was present. `NormalizeConfig.UnmarshalYAML` and equivalent
|
||||||
|
parser-coupled application hooks are removed.
|
||||||
|
|
||||||
|
Pipeline, campaign, session, and party inputs use the common ConfigKit loading
|
||||||
|
boundary where its schema model can represent their types. Narratio may perform
|
||||||
|
narrow pre- or post-load domain work, such as retaining authored party bytes,
|
||||||
|
rejecting unresolved session-template placeholders, resolving stable inputs, and
|
||||||
|
expanding artifact families. Such work must not recreate a second generic YAML
|
||||||
|
loader.
|
||||||
|
|
||||||
|
## Artifact Definition Ergonomics
|
||||||
|
|
||||||
|
The canonical v2 configuration layout defines each ordinary Scriptorium artifact
|
||||||
|
in its own file under `artifacts/`. The root pipeline imports the directory through
|
||||||
|
ConfigKit, for example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- glob: artifacts/*.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Each file contributes one ordinary entry at
|
||||||
|
`scriptorium.artifacts.<artifact_key>`. The map key is the authoritative Narratio
|
||||||
|
artifact key and therefore makes the artifact's internal identity explicit in the
|
||||||
|
definition itself:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# artifacts/session_recap.yml
|
||||||
|
scriptorium:
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The canonical reusable source ID is derived without another configured identifier:
|
||||||
|
`session_recap` becomes `narratio.artifact.session_recap`. The recommended filename
|
||||||
|
is `<artifact_key>.yml`, but the filename is organizational rather than an identity
|
||||||
|
source. Renaming or moving a file without changing its map key does not change the
|
||||||
|
artifact contract. ConfigKit additive composition rejects duplicate artifact keys.
|
||||||
|
|
||||||
|
The one-artifact-per-file layout is the documented happy path and is used by
|
||||||
|
maintained examples. Narratio does not prohibit an inline artifact or another
|
||||||
|
ConfigKit-compatible source organization merely because it differs from that
|
||||||
|
layout. Artifact identity always comes from the effective map key, never implicitly
|
||||||
|
from a filename or import position.
|
||||||
|
|
||||||
|
Artifact families use the corresponding `artifact-families/*.yml` convention, with
|
||||||
|
one `scriptorium.artifact_families.<family_key>` entry per file. The family key is
|
||||||
|
explicit. Concrete member keys and source IDs continue to use Narratio's existing
|
||||||
|
deterministic family/member naming rule after canonical party resolution.
|
||||||
|
|
||||||
|
Narratio adds a read-only `narratio config artifacts` command using the ordinary
|
||||||
|
pipeline, profile, and campaign selectors. It reports every effective ordinary
|
||||||
|
artifact's key, canonical `narratio.artifact.<key>` source ID, defining source,
|
||||||
|
enabled state, output path, and effective dependencies. Generated family members
|
||||||
|
also report their family origin. Output ordering is deterministic by artifact key,
|
||||||
|
and the command does not require a session or initialize runtime adapters.
|
||||||
|
|
||||||
|
A configured-artifact input creates an implicit dependency on the referenced
|
||||||
|
artifact. For example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
inputs:
|
||||||
|
recap:
|
||||||
|
source: narratio.artifact.session_recap
|
||||||
|
```
|
||||||
|
|
||||||
|
The effective dependency graph is the union of explicit `depends_on` entries and
|
||||||
|
dependencies inferred from `narratio.artifact.<key>` inputs. Inferred and explicit
|
||||||
|
duplicates collapse to one dependency. `depends_on` remains available for an
|
||||||
|
ordering requirement when the upstream output is not supplied as an input.
|
||||||
|
Validation applies the same existence, self-reference, and cycle rules to both
|
||||||
|
kinds of dependency, and diagnostics identify the declaration or input that
|
||||||
|
introduced an invalid edge. Operators no longer need to repeat an artifact input in
|
||||||
|
`depends_on` merely to make that input valid.
|
||||||
|
|
||||||
|
## Effective Inspection, Digests, and Profile Diff
|
||||||
|
|
||||||
|
ConfigKit results are the authority for composed input presence, base/profile
|
||||||
|
origins, diagnostics, and the initial typed effective configuration. Narratio's
|
||||||
|
configuration commands adapt those results to existing application-facing command
|
||||||
|
formats rather than exposing ConfigKit implementation types directly.
|
||||||
|
|
||||||
|
Party-dependent artifact families are expanded only after the selected campaign and
|
||||||
|
canonical party are known. That derived state is intentionally outside ConfigKit's
|
||||||
|
completed load snapshot. Narratio retains a narrow application-owned layer that:
|
||||||
|
|
||||||
|
- records generated family/member and publish-rule attribution;
|
||||||
|
- projects the final post-expansion pipeline for operator inspection;
|
||||||
|
- recomputes the effective pipeline digest after expansion; and
|
||||||
|
- ensures stages and adapters receive only the final concrete configuration.
|
||||||
|
|
||||||
|
Narratio continues to own semantic `config diff` behavior because ConfigKit does
|
||||||
|
not provide configuration diffs or fingerprints. Each side of a profile comparison
|
||||||
|
may be loaded independently through ConfigKit. The former guarantee that both sides
|
||||||
|
come from one parsed source snapshot is removed; operators must not rely on a
|
||||||
|
meaningful comparison while concurrently modifying its source files. Equality is
|
||||||
|
still based on normalized effective values rather than YAML formatting or source
|
||||||
|
layout.
|
||||||
|
|
||||||
|
## Credential and Secrets Convention
|
||||||
|
|
||||||
|
Raw API keys and other credential values remain prohibited in pipeline, campaign,
|
||||||
|
session, generated configuration, manifests, diagnostics, examples, and logs.
|
||||||
|
Credential-bearing configuration fields use ConfigKit secret references: YAML
|
||||||
|
contains an environment-variable name, while the resolved value remains available
|
||||||
|
only through ConfigKit's trusted typed boundary. Fields should use credential names
|
||||||
|
rather than legacy names ending in `_env` when the v2 schema is updated.
|
||||||
|
|
||||||
|
The `pipeline.secrets.env_dir` stanza is removed. Narratio selects a secrets
|
||||||
|
directory before parsing the pipeline using this precedence:
|
||||||
|
|
||||||
|
1. an explicitly supplied `--secrets-dir PATH`; otherwise
|
||||||
|
2. the `secrets` subdirectory beside the selected root pipeline file.
|
||||||
|
|
||||||
|
The conventional directory is derived from the resolved root pipeline path, never
|
||||||
|
from the current working directory, an imported file, or a selected profile. A
|
||||||
|
relative explicit CLI path is resolved from the caller's current working directory,
|
||||||
|
consistent with an ordinary command-line path argument.
|
||||||
|
|
||||||
|
If an explicitly selected directory is missing or invalid, the command fails with a
|
||||||
|
clear bootstrap diagnostic. If the conventional directory is absent, Narratio
|
||||||
|
performs no directory bootstrap and continues; pre-existing environment variables
|
||||||
|
may satisfy all references. If the conventional directory exists but ConfigKit
|
||||||
|
rejects or cannot read it, the command fails. Already-set environment variables,
|
||||||
|
including empty values, retain ConfigKit's documented precedence.
|
||||||
|
|
||||||
|
Narratio explicitly calls `configkit.LoadSecretsDir` at most once for a command,
|
||||||
|
before the first ConfigKit load that may resolve secret references and before any
|
||||||
|
concurrent work or runtime client initialization. This application-level convention
|
||||||
|
does not make ConfigKit's ordinary `Load` mutate process-global state. The bootstrap
|
||||||
|
report is rendered or summarized through a safe Narratio command boundary without
|
||||||
|
secret values.
|
||||||
|
|
||||||
|
Narratio adopts ConfigKit's secret-directory rules and limits without restoring its
|
||||||
|
old permission-mode, selected-directory-symlink, invalid-entry, byte-limit, or
|
||||||
|
newline-trimming behavior through compatibility wrappers. Configuration inspection
|
||||||
|
may require the referenced environment variables or the selected secrets directory
|
||||||
|
because an ordinary successful ConfigKit load resolves effective typed secret
|
||||||
|
references. Inspection output may show non-secret reference names where appropriate,
|
||||||
|
but must never expose resolved values.
|
||||||
|
|
||||||
|
## Validation and Diagnostics
|
||||||
|
|
||||||
|
Narratio's semantic rules remain recognizable application code registered with the
|
||||||
|
appropriate ConfigKit definition. Validation continues to reject invalid pipeline,
|
||||||
|
campaign, session, party, adapter, publish, and artifact relationships before
|
||||||
|
runtime work where practical. Validation order and exact prose are not compatibility
|
||||||
|
contracts.
|
||||||
|
|
||||||
|
ConfigKit diagnostic codes, paths, and source locations should be preserved through
|
||||||
|
the application error boundary where useful. Narratio adds command context and exit
|
||||||
|
classification but does not parse ConfigKit error strings or expose raw parser
|
||||||
|
errors. Secret values must remain absent from success and failure diagnostics.
|
||||||
|
|
||||||
|
## Compatibility and Migration
|
||||||
|
|
||||||
|
This work intentionally changes supported platforms, pipeline composition syntax,
|
||||||
|
profile layout and selection, secret configuration, YAML semantics, diagnostics,
|
||||||
|
and some inspection prerequisites. It is released as Narratio v2.0.0.
|
||||||
|
|
||||||
|
There is no runtime compatibility layer for the old `composition` or
|
||||||
|
`pipeline.secrets` syntax. Maintained examples are converted to the ConfigKit
|
||||||
|
format, and the v2 release documentation provides a concise migration guide that
|
||||||
|
covers:
|
||||||
|
|
||||||
|
- converting imports to `configkit.imports` declarations;
|
||||||
|
- folding the production overlay into the base configuration;
|
||||||
|
- placing testing overrides in `profiles/testing.yml`;
|
||||||
|
- moving ordinary artifacts and artifact families into the recommended individual
|
||||||
|
definition files and using their map keys as canonical identities;
|
||||||
|
- removing redundant `depends_on` entries when a configured-artifact input now
|
||||||
|
supplies the same dependency;
|
||||||
|
- moving secret files to the conventional sibling `secrets/` directory or using
|
||||||
|
`--secrets-dir`;
|
||||||
|
- renaming credential-reference fields where applicable;
|
||||||
|
- ConfigKit's stricter YAML and explicit-value semantics;
|
||||||
|
- the independent-load behavior of `config diff`; and
|
||||||
|
- the removal of Windows and macOS amd64 artifacts.
|
||||||
|
|
||||||
|
Obsolete application implementation, tests, documentation, examples, CI paths, and
|
||||||
|
release machinery are removed rather than left deprecated inside the v2 codebase.
|
||||||
|
|
||||||
|
## Required Changes
|
||||||
|
|
||||||
|
- Add and pin the ConfigKit module dependency and establish a small application
|
||||||
|
integration boundary for definitions, result adaptation, and diagnostics.
|
||||||
|
- Replace Narratio's generic YAML parsing, composition, import, profile, presence,
|
||||||
|
and provenance implementation with ConfigKit public APIs.
|
||||||
|
- Express existing defaults, presence-sensitive normalization, and semantic
|
||||||
|
validation through ConfigKit definitions without moving Narratio domain policy
|
||||||
|
into the library.
|
||||||
|
- Adapt pipeline/campaign/session/party loading and preserve the existing
|
||||||
|
application resolution steps that occur after their independent typed loads.
|
||||||
|
- Rework effective configuration, sources, and diff commands around ConfigKit
|
||||||
|
results plus Narratio's post-party generated-attribution layer.
|
||||||
|
- Add the maintained per-artifact and per-family file layouts, the read-only
|
||||||
|
`config artifacts` catalog, and effective dependency inference from configured
|
||||||
|
artifact inputs.
|
||||||
|
- Convert credential configuration to typed references, add the common
|
||||||
|
`--secrets-dir` option, implement conventional-directory selection, and replace
|
||||||
|
the custom API-key directory loader with `configkit.LoadSecretsDir`.
|
||||||
|
- Update adapters to consume resolved credentials or safe environment reference
|
||||||
|
names from typed configuration without independently rediscovering secret values.
|
||||||
|
- Remove Windows and macOS amd64 build targets, release assets, CI entries,
|
||||||
|
platform-specific implementation, and obsolete tests and documentation.
|
||||||
|
- Convert maintained examples and add v2 migration and release documentation.
|
||||||
|
- Retain focused Narratio tests for domain policy and assembled application
|
||||||
|
behavior while deleting parser/composer/bootstrap mechanism tests now owned by
|
||||||
|
ConfigKit. Tests remain deterministic, offline, credential-free, and aligned with
|
||||||
|
the repository testing policy.
|
||||||
|
- Update canonical architecture, configuration, CLI, operations, development,
|
||||||
|
internal, troubleshooting, and release documentation only as the corresponding
|
||||||
|
behavior is implemented.
|
||||||
|
|
||||||
|
## Target End State
|
||||||
|
|
||||||
|
Narratio v2.0.0 has one configuration infrastructure dependency and no duplicate
|
||||||
|
YAML composition subsystem. Operators maintain a production base configuration,
|
||||||
|
optional explicit imports, and fixed named overlays such as `testing`. ConfigKit
|
||||||
|
provides strict parsing, composition, typed loading, provenance, diagnostics, and
|
||||||
|
credential resolution; Narratio supplies domain meaning and its post-campaign
|
||||||
|
artifact expansion.
|
||||||
|
|
||||||
|
Operators can add an artifact by creating one discoverable YAML file whose map key
|
||||||
|
is its stable Narratio identity. `config artifacts` exposes that key, its canonical
|
||||||
|
source ID, origin, output, and effective dependency set. A later artifact can reuse
|
||||||
|
the output through `narratio.artifact.<key>` without repeating the same edge in
|
||||||
|
`depends_on`, while explicit ordering-only dependencies remain supported.
|
||||||
|
|
||||||
|
Secrets normally live in a predictable `secrets/` directory beside the selected
|
||||||
|
pipeline, with `--secrets-dir` available for deployment-specific placement. Raw
|
||||||
|
credential values never enter YAML or inspection output. The bootstrap is explicit
|
||||||
|
in application startup and ordinary ConfigKit loading remains free of process-global
|
||||||
|
mutation.
|
||||||
|
|
||||||
|
Release and CI maintenance covers Linux amd64 and arm64 as the primary platforms
|
||||||
|
and macOS arm64 on a best-effort basis. Windows and macOS amd64 carry no remaining
|
||||||
|
support or release burden. The repository contains no legacy configuration parser,
|
||||||
|
profile compatibility layer, obsolete platform implementation, or redundant tests
|
||||||
|
for behavior owned by ConfigKit.
|
||||||
|
|
||||||
|
## Deferred Work
|
||||||
|
|
||||||
|
- A ConfigKit reference-only inspection or typed prepare/finalize lifecycle may be
|
||||||
|
considered upstream if credential-free inspection becomes a demonstrated shared
|
||||||
|
requirement. Narratio does not implement a private substitute in this migration.
|
||||||
|
- ConfigKit hot reload, remote providers, automatic configuration migration, and
|
||||||
|
application command frameworks remain outside Narratio's scope.
|
||||||
|
- Native macOS arm64 CI may be added later when a reliable runner is available; it
|
||||||
|
is not required for v2.0.0.
|
||||||
522
docs/roadmap/implementation.md
Normal file
522
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
# ConfigKit Adoption Implementation Plan
|
||||||
|
|
||||||
|
Status: Complete
|
||||||
|
|
||||||
|
Implement the [ConfigKit adoption roadmap](configkit-adoption.md) in the order
|
||||||
|
below. The target is Narratio v2.0.0: ConfigKit v0.1.1, the reduced platform
|
||||||
|
matrix, and the artifact-authoring workflow described there. Each stage is a
|
||||||
|
bounded implementation assignment for a gpt-5.6-terra coding agent. Mark a stage
|
||||||
|
complete only after its changes and acceptance checks succeed; record a short
|
||||||
|
completion and verification note beside its status.
|
||||||
|
|
||||||
|
## Instructions for every stage
|
||||||
|
|
||||||
|
- Read `docs/development.md` and all three policies under `docs/policy/`.
|
||||||
|
Read the relevant roadmap section and the stage-specific sources listed below.
|
||||||
|
Paths identify starting points, not an exhaustive edit allowlist; inspect callers
|
||||||
|
and tests before changing a package boundary.
|
||||||
|
- Use the tagged ConfigKit public API. The local `../ConfigKit` checkout is useful
|
||||||
|
reference material, but verify its version before relying on it. The module
|
||||||
|
cache for v0.1.1 is an alternative. Do not require a sibling checkout, a local
|
||||||
|
`replace`, a Go workspace, an upstream patch, or an unpinned revision to build.
|
||||||
|
- Keep Narratio domain policy in `internal/config`, startup and selectors in
|
||||||
|
`internal/app`, and credential delivery in the owning adapters. Do not build
|
||||||
|
a general configuration framework around ConfigKit.
|
||||||
|
- Keep each stage buildable and run focused behavioral tests for its changed
|
||||||
|
boundary. Temporary migration helpers may exist between stages only where
|
||||||
|
named here; they must not introduce runtime format auto-detection or fallback.
|
||||||
|
- Update each canonical current-behavior document when its behavior changes.
|
||||||
|
The roadmap remains the owner of unimplemented work. Preserve historical
|
||||||
|
release notes; their descriptions of old platforms and formats are history.
|
||||||
|
- Tests use real ConfigKit and temporary files, with fake external adapters or
|
||||||
|
storage where needed. Use synthetic credentials and isolate environment
|
||||||
|
mutation; such tests must not run in parallel. Retain domain and security
|
||||||
|
coverage, remove superseded mechanism tests, and avoid exact diagnostic prose
|
||||||
|
or private callback choreography assertions.
|
||||||
|
- Do not tag or publish v2.0.0 as part of this plan. Release preparation belongs
|
||||||
|
here; release authorization and tag publication are a later task. The existing
|
||||||
|
release completion boundary remains successful upstream tag push, with no CI
|
||||||
|
polling or waiting.
|
||||||
|
|
||||||
|
## Decisions that apply across stages
|
||||||
|
|
||||||
|
The roadmap is authoritative for product scope. These implementation choices
|
||||||
|
make that scope concrete:
|
||||||
|
|
||||||
|
- Enable ConfigKit imports and profile selection for pipeline files only.
|
||||||
|
Campaign, session, and party are single-source definitions with imports and
|
||||||
|
profiles disabled. Preserve their domain schemas and source-relative inputs.
|
||||||
|
Omitted or empty profile and the exact name `default` select the base; other
|
||||||
|
names use ConfigKit's fixed `profiles/<name>.yml` convention.
|
||||||
|
- Defaults apply to absent fields. Explicit empty/zero/false/null values follow
|
||||||
|
ConfigKit decoding and then domain validation; do not refill invalid explicit
|
||||||
|
values with legacy defaults. Optional null stage sections remain absent;
|
||||||
|
required sections fail domain validation rather than being resurrected.
|
||||||
|
- Keep ConfigKit metadata/result snapshots separate from runtime-only Narratio
|
||||||
|
state. ConfigKit discards ignored and unexported fields when detaching values;
|
||||||
|
attach root paths, retained party bytes, family catalogs, and digest metadata
|
||||||
|
after loading. No validator may mutate bound configuration.
|
||||||
|
- Use one Narratio-owned safe effective projection for the final expanded
|
||||||
|
pipeline. It contains configuration values and credential reference names,
|
||||||
|
never resolved credential bytes. It feeds show, semantic diff, digest, and
|
||||||
|
catalog data. ConfigKit metadata remains the authority for input provenance;
|
||||||
|
Narratio adds only domain-generated attribution.
|
||||||
|
- Rename `storage.s3.access_key_id_env` to `access_key_id`,
|
||||||
|
`storage.s3.secret_access_key_env` to `secret_access_key`, and
|
||||||
|
`audita.llm_api_key_env` to `llm_api_key`. Each is an optional `*configkit.Secret`.
|
||||||
|
An explicit reference always resolves during loading, even if the associated
|
||||||
|
stage will not execute. Require both S3 fields together or neither. With neither,
|
||||||
|
retain the AWS SDK default credential chain; do not synthesize mandatory secret
|
||||||
|
references that would break role-based authentication or local-only inspection.
|
||||||
|
With no Audita reference, preserve its existing downstream credential discovery.
|
||||||
|
Explicit references replace Narratio's old per-adapter environment lookup.
|
||||||
|
- Keep absent S3 credentials out of defaults; migration examples explicitly name
|
||||||
|
the existing AWS variables when static credentials are intended. A non-nil
|
||||||
|
missing/empty reference fails instead of silently falling back. This preserves
|
||||||
|
provider-managed credentials while making explicit configuration trustworthy.
|
||||||
|
- `config artifacts` uses the existing configuration command selectors and text
|
||||||
|
output convention. No new JSON mode, artifact creation wizard, inferred filename
|
||||||
|
identity, or profile catalog is added.
|
||||||
|
- Preserve the roadmap's unique base artifact definition rule explicitly:
|
||||||
|
ConfigKit rejects conflicting leaves but can compose disjoint children of the
|
||||||
|
same mapping. Use its public source metadata to reject an ordinary artifact key
|
||||||
|
declared by multiple base files, even if their fields are disjoint. Apply the
|
||||||
|
same rule to family keys. A selected profile may override or extend a definition.
|
||||||
|
This is a narrow Narratio declaration rule, not a different merge algorithm.
|
||||||
|
- Relax only the obsolete configuration/secrets filesystem restrictions identified
|
||||||
|
by the roadmap. Do not remove session restore, cleanup, publication, workspace
|
||||||
|
confinement, or ordinary shared-workspace mode guarantees as collateral work.
|
||||||
|
|
||||||
|
## Stage 1 — Reduce the platform and release matrix
|
||||||
|
|
||||||
|
Status: Complete — Reduced CI and release output to Linux amd64/arm64 and macOS
|
||||||
|
arm64, removed Windows-only runtime implementations and accommodations, and
|
||||||
|
verified focused tests, all three cross-builds, exact asset names, and embedded
|
||||||
|
version reporting.
|
||||||
|
|
||||||
|
Read: roadmap “Platform Contract”; `scripts/build-release-assets.sh`,
|
||||||
|
`scripts/check-release-candidate.sh`, `.woodpecker/`, `internal/releasecheck`,
|
||||||
|
`docs/release.md`, and platform-specific files in application, file operations,
|
||||||
|
and subprocess packages.
|
||||||
|
|
||||||
|
1. Make Linux amd64, Linux arm64, and macOS arm64 the only maintained build and
|
||||||
|
release targets. Remove Windows suffix/packaging branches and macOS amd64
|
||||||
|
targets from scripts, workflows, tests, and current documentation.
|
||||||
|
2. Delete Windows-only implementations and tests; simplify paired build-tag
|
||||||
|
files where the remaining implementations can be shared. Preserve portable
|
||||||
|
code when retaining it adds no special support machinery. Preserve functioning
|
||||||
|
Unix subprocess cancellation, locking, and file operations on both supported OSes.
|
||||||
|
3. Retain the local Linux verification gates and cross-build gates for all three
|
||||||
|
published assets. Do not introduce a native macOS runner requirement or claim
|
||||||
|
that cross-compilation verifies runtime behavior.
|
||||||
|
4. Update architecture/operations platform statements and contributor/release
|
||||||
|
guidance. Keep existing release protections and the no-CI-wait boundary intact.
|
||||||
|
|
||||||
|
Acceptance: focused platform/subprocess/fileops/releasecheck tests pass; build all
|
||||||
|
three targets with `CGO_ENABLED=0`; verify the asset builder produces exactly the
|
||||||
|
three expected versioned binaries and the host binary reports the supplied version.
|
||||||
|
Existing release tests still prove guarded publication. No current support promise
|
||||||
|
or asset list retains Windows or macOS amd64.
|
||||||
|
|
||||||
|
## Stage 2 — Separate configuration normalization from validation
|
||||||
|
|
||||||
|
Status: Complete — Added explicit deterministic pipeline normalization before
|
||||||
|
validation, made Notarius validation read-only on accepted and rejected inputs,
|
||||||
|
documented the boundary, and verified config race tests plus app/stage consumers.
|
||||||
|
|
||||||
|
Read: `internal/config/load.go`, `defaults.go`, `normalize.go`, `validate*.go`,
|
||||||
|
`artifact_families.go`, and ConfigKit's “Recorded application resolution”,
|
||||||
|
“Semantic validation”, and “Defaults and typed decoding” contracts.
|
||||||
|
|
||||||
|
1. Separate value-producing normalization from read-only validation, including
|
||||||
|
Notarius reference/output normalization currently performed by
|
||||||
|
`validateNotarius`. Audit all validators for map, slice, pointer, and scalar
|
||||||
|
mutations, including changes that would occur only on error paths.
|
||||||
|
2. Keep reusable domain normalization ordinary package code. Validation checks the
|
||||||
|
already normalized candidate and must work for both loaded and programmatically
|
||||||
|
constructed values without silently fixing them.
|
||||||
|
3. Inventory defaults in `load.go`: static defaults, per-map-entry/list-element
|
||||||
|
defaults, and inherited or presence-sensitive defaults. Retain named constants
|
||||||
|
in `defaults.go`. This inventory should guide code and concise comments, not
|
||||||
|
become a permanent parallel defaults document.
|
||||||
|
4. Keep the current loader calling normalization explicitly before validation
|
||||||
|
during this preparatory stage. The ConfigKit resolver replaces that loading
|
||||||
|
choreography in Stage 4. Preserve family expansion as an explicit domain step.
|
||||||
|
|
||||||
|
Acceptance: focused configuration tests prove normalization is deterministic and
|
||||||
|
validation does not mutate accepted or rejected inputs. Cover representative
|
||||||
|
Notarius normalization/collision handling and existing artifact/publish rules.
|
||||||
|
Do not add a separate mutation test for every trivial validator.
|
||||||
|
|
||||||
|
## Stage 3 — Replace composition-dependent effective projection
|
||||||
|
|
||||||
|
Status: Complete — Introduced one typed safe projection for effective YAML,
|
||||||
|
semantic values, source paths, and the versioned v3 digest; removed composition
|
||||||
|
parsing from those consumers and verified ordering, expansion, relocation,
|
||||||
|
nil/empty, exact-integer, CLI diff, and semantic-resume behavior.
|
||||||
|
|
||||||
|
Read: `internal/config/effective_pipeline.go`, `effective_digest.go`,
|
||||||
|
`pipeline_composition.go`, `internal/app/config_commands.go`, the config-diff
|
||||||
|
implementation, and `docs/internal/configuration.md`.
|
||||||
|
|
||||||
|
1. Introduce one package-owned, schema-specific safe projection of the effective
|
||||||
|
pipeline. Use explicit typed projections with stable configuration field names;
|
||||||
|
preserve null versus empty containers and exact integers. Ordinary nested
|
||||||
|
configuration types may be reused where they contain no runtime state.
|
||||||
|
Credential fields must be projected explicitly as reference names when Stage 7
|
||||||
|
changes their types. Do not introduce a generic reflection schema engine.
|
||||||
|
2. Render this projection as deterministic YAML using the existing YAML encoder;
|
||||||
|
encoding output is not a replacement YAML loader. Produce semantic records
|
||||||
|
directly from the safe projection using standard JSON encoding/decoding with
|
||||||
|
number preservation if a plain value tree is useful. Do not reparse YAML or
|
||||||
|
reconstruct `compositionNode` trees to compare or hash effective values.
|
||||||
|
3. Make show, diff values, and digest use that common projection. Mapping order is
|
||||||
|
immaterial; sequences remain atomic for diff; nil and empty remain distinct.
|
||||||
|
Exclude family declarations once expanded, all runtime metadata, profile names,
|
||||||
|
source locations, and raw credentials. Preserve logical Notarius path handling
|
||||||
|
so moving an equivalent bundle does not change its digest.
|
||||||
|
4. Version the changed digest encoding explicitly using the package's digest
|
||||||
|
version constant. Digest/profile metadata remains provenance, never a blanket
|
||||||
|
resume cache key. Keep stage/artifact semantic evidence behavior intact.
|
||||||
|
5. Decouple enumeration of effective field paths from the legacy composition
|
||||||
|
renderer. Input ownership can still come from the current loader until Stage 4.
|
||||||
|
|
||||||
|
Acceptance: show/diff/digest tests cover equivalent monolithic/split values,
|
||||||
|
different map insertion order, null/empty distinction, family-expanded outputs,
|
||||||
|
and bundle relocation. A meaningful artifact change changes the digest; formatting
|
||||||
|
and profile naming alone do not. Existing semantic resume tests pass. No effective
|
||||||
|
projection consumer needs the custom composition parser/renderer.
|
||||||
|
|
||||||
|
## Stage 4 — Cut pipeline loading over to ConfigKit
|
||||||
|
|
||||||
|
Status: Complete — Replaced pipeline composition with ConfigKit v0.1.1 imports,
|
||||||
|
fixed-path profile selection, fresh typed defaults, recorded resolution, source
|
||||||
|
metadata adaptation, and duplicate base artifact/family enforcement; removed the
|
||||||
|
legacy parser and verified all tests plus the configuration race suite.
|
||||||
|
|
||||||
|
Read: ConfigKit v0.1.1 `docs/config.md`, `docs/consumers/README.md`, and its
|
||||||
|
Narratio-shaped compiled example; Narratio `internal/config/load.go`,
|
||||||
|
`composition.go`, `pipeline_composition.go`, and profile/composition tests.
|
||||||
|
|
||||||
|
1. Pin `gitea.maximumdirect.net/eric/ConfigKit v0.1.1` in `go.mod`/`go.sum`.
|
||||||
|
Implement a small pipeline definition using `Load`, `File`, enabled imports,
|
||||||
|
`Options.Profile`, defaults, recorded resolution, and read-only validation.
|
||||||
|
2. Migrate static defaults to fresh default values. Apply map-entry, list-element,
|
||||||
|
and inherited defaults with `Resolution.Default`/`IsExplicit`; use recorded
|
||||||
|
normalization for transformations from Stage 2. Do not call old post-decode
|
||||||
|
zero-filling defaults. Remove `NormalizeConfig.UnmarshalYAML` and its private
|
||||||
|
presence bit. Include publication list-entry defaults, optional section
|
||||||
|
allocation, and trim's Scriptorium requirement in this conversion.
|
||||||
|
3. Perform file-local semantic checks in the definition. Defer checks needing
|
||||||
|
canonical party expansion or campaign/session context to the existing explicit
|
||||||
|
resolution boundary; do not reject references to future concrete family members
|
||||||
|
merely because the campaign has not loaded yet. Run full checks after expansion.
|
||||||
|
4. Replace pipeline composition and profile loaders. `configkit.imports` is the
|
||||||
|
only directive; no `composition` or `_configkit` alias is accepted. Pass profile
|
||||||
|
selection directly under the agreed convention; unselected overlays are not read.
|
||||||
|
`LoadPipelineProfilePair` may remain a thin pair of independent loads, with no
|
||||||
|
shared parse cache or paired source-snapshot promise.
|
||||||
|
5. Adapt ConfigKit sources and metadata to Narratio's existing reporting records.
|
||||||
|
Translate paths structurally using public path APIs, not error-message parsing.
|
||||||
|
Preserve multiple contributing locations for composite/profile-owned mappings
|
||||||
|
and distinguish defaults/resolution from authored values.
|
||||||
|
Check artifact/family map-entry ownership for duplicate base declarations using
|
||||||
|
those locations; ignore synthetic defaults and allow the selected overlay.
|
||||||
|
6. Attach runtime metadata only after load succeeds. Preserve root-relative runtime
|
||||||
|
path interpretation and capture logical paths before making them absolute.
|
||||||
|
Retain family declarations and original ConfigKit metadata for later expansion.
|
||||||
|
7. Replace generic mechanism tests with a small integration set proving actual
|
||||||
|
Narratio wiring. Convert fixtures affected by this stage. Delete
|
||||||
|
`composition.go`, obsolete pipeline traversal/overlay/presence machinery, and
|
||||||
|
their redundant tests once the callers are migrated.
|
||||||
|
|
||||||
|
Acceptance: a real root with imports and a testing overlay loads; duplicate claims
|
||||||
|
fail with useful source attribution, including two base files contributing disjoint
|
||||||
|
fields to one artifact or family key; base/default selection works; malformed
|
||||||
|
unselected profiles do not fail base loading. Omitted defaults and representative
|
||||||
|
explicit false/zero/empty/null inputs obey the new semantics. Invalid domain values
|
||||||
|
still fail. Commands use the new pipeline loader with no format fallback. Update
|
||||||
|
`docs/config.md` and configuration internals for the implemented boundary.
|
||||||
|
|
||||||
|
## Stage 5 — Migrate campaign, session, and canonical party loading
|
||||||
|
|
||||||
|
Status: Complete — Migrated campaign, session, and canonical-party documents to
|
||||||
|
independent ConfigKit definitions, preserved bounded session inspection, exact
|
||||||
|
party bytes and metadata-derived authored order, and retained identity, source,
|
||||||
|
family-expansion, and validation behavior; verified all tests and focused races.
|
||||||
|
|
||||||
|
Read: `internal/config/load.go`, `party.go`, `party_resolution.go`,
|
||||||
|
`artifact_families.go`, session-template tests, `internal/app/config_loader.go`,
|
||||||
|
and ConfigKit source/schema/version contracts.
|
||||||
|
|
||||||
|
1. Use separate ConfigKit definitions for campaign, session, and party. Replace
|
||||||
|
`decodeStrictYAML*` and the custom party YAML-node parser. Model the party input
|
||||||
|
as the existing string schema version and character-keyed typed map, with the
|
||||||
|
existing `alias` list, ordered class list, and optional positive class level.
|
||||||
|
2. Keep party domain checks: valid IDs, required display names and classes,
|
||||||
|
trimmed/control-free names, case-insensitive name/alias ambiguity, and duplicate
|
||||||
|
class checks. Validate the required version through `VersionRule`. Keep an
|
||||||
|
exact independent copy of authored bytes outside ConfigKit's schema object.
|
||||||
|
3. Preserve authored character order using successful metadata source coordinates
|
||||||
|
for the single party input when constructing `CanonicalParty.Characters`.
|
||||||
|
Preserve class/alias sequence order naturally. Family expansion and players
|
||||||
|
projection continue using their existing deterministic ID sorting. Do not keep
|
||||||
|
a second YAML parse just to recover mapping order.
|
||||||
|
4. Preserve the narrow session placeholder rejection before typed load, expected
|
||||||
|
session/previous-session identity checks, campaign selection, stable-input
|
||||||
|
precedence, and local versus remotely downloaded session provenance.
|
||||||
|
Retain the initially loaded pipeline/campaign during remote session acquisition.
|
||||||
|
5. Where bytes must be retained or inspected before `ConfigKit.Bytes`, bound the
|
||||||
|
initial read too, using the existing fileops bounded-read helper and a named
|
||||||
|
package constant of 8 MiB matching the pinned source limit. Otherwise prefer
|
||||||
|
`ConfigKit.File`. Do not read an unbounded file and rely on later rejection.
|
||||||
|
6. Complete post-party family and publish-rule expansion, then full cross-config
|
||||||
|
validation and digest refresh. Keep generated origins outside the library
|
||||||
|
snapshot. Remove unused decoder plumbing and obsolete party parsing tests.
|
||||||
|
|
||||||
|
Acceptance: retained authored party bytes are identical; domain errors, aliases,
|
||||||
|
class order, family origins, and derived players are correct. Local and remote
|
||||||
|
session loads preserve identity, input path bases, and the original pipeline.
|
||||||
|
Unknown fields and disabled directives fail through ConfigKit. Focused config,
|
||||||
|
prepare/extract, and application loading tests pass.
|
||||||
|
|
||||||
|
## Stage 6 — Move secrets bootstrap before all configuration loads
|
||||||
|
|
||||||
|
Status: Complete — Added invocation-scoped ConfigKit secrets bootstrap before
|
||||||
|
all pipeline loads and adapter construction, exposed consistent explicit and
|
||||||
|
conventional directory selection across the CLI, removed the retired pipeline
|
||||||
|
schema and custom loader, and verified focused, full, and race test suites.
|
||||||
|
|
||||||
|
Read: roadmap “Credential and Secrets Convention”; ConfigKit's bootstrap
|
||||||
|
contract; `internal/app/secrets_env*.go`, `config_loader.go`, `config_commands.go`,
|
||||||
|
`object_store.go`, command dependencies, and command argument owners.
|
||||||
|
|
||||||
|
1. Add a shared `--secrets-dir` selector to every CLI path that loads a pipeline,
|
||||||
|
including configuration inspection/diff, execution aliases, planning, publish,
|
||||||
|
and session/storage operations. Route all through one command-owned bootstrap
|
||||||
|
context. Help/version and commands that do not load configuration do not bootstrap.
|
||||||
|
2. Discover the root pipeline first. An explicit relative directory is based on
|
||||||
|
caller CWD; otherwise choose `secrets/` beside the resolved root pipeline.
|
||||||
|
An explicitly empty flag is invalid. Missing conventional directories are
|
||||||
|
skipped; missing explicit directories and unreadable/invalid existing directories
|
||||||
|
fail. Use ConfigKit's selected-directory symlink behavior without custom rejection.
|
||||||
|
3. Call `LoadSecretsDir` at most once per command before any ConfigKit load or
|
||||||
|
adapter creation, including both loads of config diff and remote session lookup.
|
||||||
|
Use invocation state, not a package-global `sync.Once`. A new command gets its
|
||||||
|
own bootstrap; library config loaders themselves never mutate the environment.
|
||||||
|
4. Present safe bootstrap warnings/failures through stderr or the existing safe
|
||||||
|
diagnostic boundary, never into YAML show output. Preserve the partial report
|
||||||
|
on bootstrap failure. Do not add rollback or reinterpret ConfigKit file rules.
|
||||||
|
5. Remove late bootstrap calls in object-store and runner composition, the custom
|
||||||
|
directory reader and permission platform files, `pipeline.secrets`, its defaults
|
||||||
|
and validators, and their obsolete tests. Convert affected fixtures immediately.
|
||||||
|
|
||||||
|
Acceptance: test absent conventional directory, explicit override from another
|
||||||
|
CWD, invalid explicit directory, already-set empty/nonempty environment values,
|
||||||
|
and bootstrap failure before adapter construction. A diff invocation and a remote
|
||||||
|
session acquisition each bootstrap once. Config inspection requires no session,
|
||||||
|
workspace writes, manifests, or runtime clients. Update CLI, config, operations,
|
||||||
|
architecture, and troubleshooting to the new bootstrap/permission contract.
|
||||||
|
|
||||||
|
## Stage 7 — Resolve typed credentials and protect all output boundaries
|
||||||
|
|
||||||
|
Status: Complete — Migrated S3 and Audita credentials to optional ConfigKit
|
||||||
|
typed references, passed resolved values directly to runtime adapters, preserved
|
||||||
|
the AWS default provider chain when unconfigured, and verified reference-only
|
||||||
|
inspection, provenance, generated configuration, manifest, and diagnostic
|
||||||
|
boundaries with focused, full, and race test suites.
|
||||||
|
|
||||||
|
Read: ConfigKit “Typed secret references” and rendering contracts;
|
||||||
|
`internal/config/config.go`, `defaults.go`, `validate_adapters.go`, the safe
|
||||||
|
projection from Stage 3, `internal/adapters/storage/s3_backend.go`,
|
||||||
|
`internal/adapters/audita/subprocess.go`, and their composition callers.
|
||||||
|
|
||||||
|
1. Implement the three renamed optional typed fields and S3 pair policy above.
|
||||||
|
Reject old `_env` schema keys; remove their default-name and lookup helpers.
|
||||||
|
Convert maintained fixtures with synthetic environment setup where references
|
||||||
|
are present. Do not add credentials to unrelated local pipeline fixtures.
|
||||||
|
2. Pass resolved S3 values to the SDK adapter's explicit static provider only when
|
||||||
|
the pair is configured. With neither, preserve its default provider chain.
|
||||||
|
Pass Audita the resolved value and safe reference name; deliver the value only
|
||||||
|
through the child environment's existing `AUDITA_LLM_API_KEY` override.
|
||||||
|
Do not re-read the environment for an explicitly resolved reference.
|
||||||
|
3. Adapt every serialization, digest, source/diff output, diagnostic, generated
|
||||||
|
invocation config, manifest, and provenance caller to safe reference names.
|
||||||
|
Never YAML-marshal ConfigKit's opaque secret directly or call `Value()` in an
|
||||||
|
inspection path. Secret changes alone must not alter effective digests or
|
||||||
|
invalidate artifact evidence; reference-name changes may change provenance.
|
||||||
|
4. Keep ConfigKit errors structured and safe. Domain validation messages must
|
||||||
|
avoid raw credential values; do not unwrap private parser errors or interpolate
|
||||||
|
whole trusted configuration structs into error messages.
|
||||||
|
5. Preserve downstream tool ownership for Notarius/Scriptorium credentials that
|
||||||
|
Narratio does not configure. Do not add a parallel credential schema for them.
|
||||||
|
|
||||||
|
Acceptance: use distinctive synthetic secrets to prove no leakage in show, sources,
|
||||||
|
diff, catalog-ready projections, success/failure diagnostics, manifests, or generated
|
||||||
|
Audita configuration. Verify the child receives the credential and an environment
|
||||||
|
change after loading does not change explicit delivery. Test S3 paired references,
|
||||||
|
partial-pair rejection, missing effective references, and nil SDK fallback offline.
|
||||||
|
Update adapter and credential documentation and remove obsolete lookup tests.
|
||||||
|
|
||||||
|
## Stage 8 — Infer and normalize artifact dependencies once
|
||||||
|
|
||||||
|
Status: Complete — Finalized one normalized artifact graph from explicit ordering
|
||||||
|
edges and configured-artifact inputs (including rewritten family members), retained
|
||||||
|
edge origins for diagnostics, and verified focused/full tests plus the full race
|
||||||
|
suite and an assembled input-only analyze workflow.
|
||||||
|
|
||||||
|
Read: `internal/config/validate_scriptorium.go`, `artifact_families.go`,
|
||||||
|
`internal/artifactpolicy`, `internal/artifacts`, analyze planning/execution, and
|
||||||
|
their artifact dependency and semantic-evidence tests.
|
||||||
|
|
||||||
|
1. At the final configuration resolution boundary, union explicit `depends_on`
|
||||||
|
with configured-artifact input references. Use the existing source-ID parser;
|
||||||
|
do not infer edges from filenames, arbitrary strings, or non-artifact inputs.
|
||||||
|
2. Infer after family member inputs become concrete `narratio.artifact.<key>`
|
||||||
|
sources. Normalize, deduplicate, and sort the effective dependency list once.
|
||||||
|
Keep explicit ordering-only dependencies valid. Repeated finalization must not
|
||||||
|
accumulate edges or alter ordering. Store source-edge attribution separately
|
||||||
|
from the effective graph for diagnostics and generated provenance.
|
||||||
|
3. Apply existing existence, self-reference, enabled-dependency, output, and cycle
|
||||||
|
validation to the union. Remove the error requiring an input to be repeated in
|
||||||
|
`depends_on`. Retain diagnostics pointing to the explicit list entry or the
|
||||||
|
input that contributed an invalid edge, including family origin where relevant.
|
||||||
|
4. Ensure execution, targeted analysis, planning, prerequisite closure, reuse,
|
||||||
|
forced regeneration, and semantic evidence consume this same concrete graph.
|
||||||
|
No adapter or command may infer a second graph. Refresh final projection/digest
|
||||||
|
after inference; equivalent explicit and inferred declarations compare equally.
|
||||||
|
|
||||||
|
Acceptance: cover input-only dependency ordering, duplicate explicit/inferred edges,
|
||||||
|
ordering-only edges, missing/self/cyclic references, and rewritten family inputs.
|
||||||
|
Use an assembled fake-artifact workflow to prove upstream output is supplied to a
|
||||||
|
dependent artifact and failure/resume retains successful predecessors. Existing
|
||||||
|
force/selection and invalidation tests remain valid. Update config and analyze
|
||||||
|
internals with the implemented inference rule.
|
||||||
|
|
||||||
|
## Stage 9 — Add the effective artifact catalog command
|
||||||
|
|
||||||
|
Status: Complete — Added the read-only `config artifacts` catalog over finalized
|
||||||
|
configuration, including effective dependencies and definition/family/party
|
||||||
|
provenance; documented it and verified focused, full, and race test suites.
|
||||||
|
|
||||||
|
Read: `internal/app/config_commands.go`, config diff/source rendering, the safe
|
||||||
|
projection and family-origin catalogs, and `docs/cli.md`.
|
||||||
|
|
||||||
|
1. Add `narratio config artifacts` to dispatch and help. Reuse ordinary pipeline,
|
||||||
|
profile, campaign, campaign-file, and secrets-directory parsing/resolution.
|
||||||
|
Resolve a campaign when families require it or when explicitly selected; a
|
||||||
|
pipeline with only ordinary artifacts can be inspected without a campaign.
|
||||||
|
2. Render one stable record per effective concrete artifact in key order with
|
||||||
|
key, `narratio.artifact.<key>` source ID, defining source location(s), enabled
|
||||||
|
state, output path, and sorted effective dependencies. Family members additionally
|
||||||
|
identify family key, character ID, defining family source, and party source.
|
||||||
|
3. Derive identity from the map key. A profile may contribute to an existing
|
||||||
|
artifact, so report contributing definition locations instead of inventing
|
||||||
|
a single filename owner. Use a deterministic escaped tabular text rendering;
|
||||||
|
include a header and no records for an empty catalog. Propagate writer errors.
|
||||||
|
4. Use the same finalized state as show/sources/diff and runtime configuration.
|
||||||
|
No session download, adapter initialization, manifest access, or workspace
|
||||||
|
materialization is allowed. Secret bootstrap remains the agreed startup exception.
|
||||||
|
|
||||||
|
Acceptance: CLI tests cover ordinary and generated artifacts, disabled entries,
|
||||||
|
profile-added/overridden artifacts, empty catalog, no-campaign ordinary inspection,
|
||||||
|
required-campaign failure for families, stable ordering, and no runtime side effects.
|
||||||
|
Check identity stays the same after moving an artifact file without changing its key.
|
||||||
|
Document the command in its canonical CLI owner and link to config layout examples.
|
||||||
|
|
||||||
|
## Stage 10 — Finish the operator layout and v2 migration documentation
|
||||||
|
|
||||||
|
Status: Complete
|
||||||
|
|
||||||
|
Completion: Reworked the maintained bundle around an unprofiled production base,
|
||||||
|
one-entry artifact and family imports, and a testing overlay; updated the example
|
||||||
|
harness and inspection workflows; consolidated the v2 configuration guidance; and
|
||||||
|
added the v2.0.0 operator migration checklist. Focused documentation/example tests,
|
||||||
|
the offline inspection walkthrough, and the full normal/race suites pass.
|
||||||
|
|
||||||
|
Read: roadmap “Artifact Definition Ergonomics” and “Compatibility and Migration”;
|
||||||
|
`examples/README.md`, existing example bundles and their loader test,
|
||||||
|
`docs/config.md`, `docs/operations.md`, `docs/release.md`, and internal configuration
|
||||||
|
and artifact documentation.
|
||||||
|
|
||||||
|
1. Convert maintained pipeline examples to a production base with explicit
|
||||||
|
`configkit.imports`, `artifacts/<key>.yml`, `artifact-families/<key>.yml`, and
|
||||||
|
`profiles/testing.yml`. Each recommended artifact/family file defines exactly
|
||||||
|
one explicit map entry. Fold old production overlays into the base.
|
||||||
|
2. Include an input-only dependency example and at least two character-family
|
||||||
|
examples sharing a campaign-owned party roster. Keep supported inline artifacts
|
||||||
|
legal; file layout is guidance, not a filename-based validation requirement.
|
||||||
|
3. Update the example validation harness to load root configurations and selected
|
||||||
|
testing profiles, not attempt to load each imported fragment as a full pipeline.
|
||||||
|
Use synthetic environment setup for explicit references. No example includes
|
||||||
|
real credentials or depends on local tools/cloud services to validate.
|
||||||
|
4. Consolidate canonical documentation updates from earlier stages. Remove current
|
||||||
|
references to old composition/profile catalogs, default-profile configuration,
|
||||||
|
secrets.env_dir, credential `_env` fields, credential-free inspection guarantees,
|
||||||
|
paired diff snapshots, and removed platform support. Explain explicit-value and
|
||||||
|
null changes without promising reloadable inspection output.
|
||||||
|
5. Add `docs/releases/v2.0.0.md` using the release-note structure, with a concrete
|
||||||
|
migration checklist linking to the canonical config/CLI docs and examples.
|
||||||
|
Include static S3-reference migration versus SDK credential discovery, new
|
||||||
|
inspection prerequisites, artifact input dependency inference, and platform
|
||||||
|
removals. Do not claim publication or edit older immutable release notes.
|
||||||
|
|
||||||
|
Acceptance: `go test ./internal/doccheck` and
|
||||||
|
`go test ./internal/config -run '^TestExamplesLoadAndValidate$'` pass. Walk through
|
||||||
|
the new operator examples with config validate/show/sources/diff/artifacts offline.
|
||||||
|
Their identities, family members, origins, and testing overrides agree.
|
||||||
|
|
||||||
|
## Stage 11 — Verify the complete migration and remove remaining scaffolding
|
||||||
|
|
||||||
|
Status: Complete
|
||||||
|
|
||||||
|
Completion: Audited the accepted roadmap and the full change set from `1558c9c`;
|
||||||
|
no legacy configuration loader, profile/composition traversal, presence store,
|
||||||
|
custom secrets loader, or production YAML decoder remains. The migration removes a
|
||||||
|
net 1,235 production and 843 test Go lines; remaining configuration code owns domain
|
||||||
|
defaults, normalization, validation, expansion, provenance, and safe projections.
|
||||||
|
Go 1.26.7 module hygiene, tests, race tests, vet, build, documentation, examples,
|
||||||
|
and the v2.0.0 release-candidate checker pass. Linux amd64 was tested natively;
|
||||||
|
Linux arm64 and macOS arm64 release assets were cross-built, with native macOS
|
||||||
|
arm64 validation remaining best effort and outside the release gate.
|
||||||
|
|
||||||
|
Read: the whole feature roadmap, completed stage notes, and the current test and
|
||||||
|
release procedures. Review the diff from the commit introducing this plan.
|
||||||
|
|
||||||
|
1. Audit every roadmap requirement against current code and executable evidence.
|
||||||
|
Remove temporary migration adapters and any residual generic YAML parser,
|
||||||
|
composer, profile traversal, explicit-presence store, old secrets loader,
|
||||||
|
decoder hooks, or unused tests. `gopkg.in/yaml.v3` may remain for output encoding
|
||||||
|
and unrelated external formats; it must not supply a second configuration loader.
|
||||||
|
2. Confirm result-owned snapshots versus final expanded configuration are handled
|
||||||
|
consistently; every inspection/runtime path sees normalized inferred dependencies
|
||||||
|
and generated publish rules. Recheck secret safety and semantic resume boundaries.
|
||||||
|
3. Inspect current platform conditions and asset lists. Retain historical notes but
|
||||||
|
no active Windows/macOS amd64 obligation. Compare owned production/test code
|
||||||
|
before and after; report removals and any remaining complexity with reasons.
|
||||||
|
Do not chase a LOC quota by removing useful domain checks or regressions.
|
||||||
|
4. Run the repository gates with the maintained Go 1.26 toolchain:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
go test ./internal/doccheck
|
||||||
|
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Run `scripts/check-release-candidate.sh v2.0.0` after the release note and all
|
||||||
|
candidate changes exist. This also verifies module hygiene and all three
|
||||||
|
release assets with `GOWORK=off`. Resolve failures; do not bypass checks or
|
||||||
|
publish a tag. Record native versus cross-build evidence accurately.
|
||||||
|
|
||||||
|
Acceptance: all gates pass and every roadmap outcome has direct evidence. No
|
||||||
|
dependency on the sibling ConfigKit checkout remains. Record completion and any
|
||||||
|
best-effort platform limitations, then hand off for a separate release decision.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None required before implementation. The agreed roadmap and the explicit choices
|
||||||
|
above resolve the migration decisions. Upstream credential-free inspection and
|
||||||
|
native macOS arm64 CI remain deferred, not prerequisites for this plan.
|
||||||
331
docs/roadmap/remediation.md
Normal file
331
docs/roadmap/remediation.md
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
# ConfigKit Migration Review — Required Remediations
|
||||||
|
|
||||||
|
Reviewed 2026-09-07 at `f7a40af`, against the changes after `1558c9c`, the
|
||||||
|
[feature roadmap](configkit-adoption.md), and the completed
|
||||||
|
[implementation plan](implementation.md).
|
||||||
|
|
||||||
|
The main migration is implemented. The following findings are reproducible gaps
|
||||||
|
in the intended behavior, not proposals to restore retired compatibility or
|
||||||
|
filesystem restrictions. P2 denotes a correctness issue to fix before release;
|
||||||
|
P3 denotes an operator-facing completeness issue also required by the accepted
|
||||||
|
plan. No application code was changed during this review.
|
||||||
|
|
||||||
|
## 1. Derive explicit publish destinations after family expansion
|
||||||
|
|
||||||
|
Status: Resolved — P2
|
||||||
|
|
||||||
|
Resolution: Missing destinations are now derived through one shared domain helper
|
||||||
|
both during ConfigKit resolution and again after concrete family expansion. Explicit
|
||||||
|
destinations remain unchanged, while unknown sources still fail read-only publish
|
||||||
|
validation. Regression coverage verifies resolved runtime and effective inspection
|
||||||
|
output for generated members, explicit overrides, and unknown sources.
|
||||||
|
|
||||||
|
**Evidence:** `internal/config/pipeline_configkit.go:208` derives missing publish
|
||||||
|
destinations during the ConfigKit resolver. A concrete family member does not
|
||||||
|
exist yet, so the resolver ignores the failed derivation. After family expansion,
|
||||||
|
`internal/config/artifact_families.go:141` calls the now-read-only publish
|
||||||
|
validator, which rejects the still-empty destination. The previous implementation
|
||||||
|
could derive it during that later validation.
|
||||||
|
|
||||||
|
**Reproduction:** In an otherwise valid pipeline, define a family `meta` over
|
||||||
|
`party.characters`, with output pattern `artifacts/{character_id}.md`. Select a
|
||||||
|
valid campaign whose party contains `alice`, and add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
publish:
|
||||||
|
outputs:
|
||||||
|
- source: narratio.artifact.meta_alice
|
||||||
|
```
|
||||||
|
|
||||||
|
`config validate --config <pipeline> --campaign-file <campaign>` fails with
|
||||||
|
`pipeline.publish.outputs[0].dest is required when destination cannot be derived
|
||||||
|
from "narratio.artifact.meta_alice"`. Adding `dest: artifacts/alice.md` makes the
|
||||||
|
same configuration pass. The destination is therefore available and derivable;
|
||||||
|
the lifecycle omits the required final normalization step.
|
||||||
|
|
||||||
|
**Required change:** Complete publish destination resolution after concrete
|
||||||
|
artifacts exist, before publish validation and final projection/digest generation.
|
||||||
|
Share the domain derivation logic and keep validation read-only. Preserve
|
||||||
|
explicit destinations and generated family publish-rule collision checks. Do not
|
||||||
|
require redundant `dest` fields or revive mutation inside validators.
|
||||||
|
|
||||||
|
**Acceptance:** Test an explicit publish source naming a generated member with
|
||||||
|
omitted destination, an explicit destination override, and a genuinely unknown
|
||||||
|
source. Validate both effective inspection and resolved runtime configuration.
|
||||||
|
|
||||||
|
## 2. Stop re-defaulting an explicitly empty Notarius working directory
|
||||||
|
|
||||||
|
Status: Resolved — P2
|
||||||
|
|
||||||
|
Resolution: Removed both legacy blank-value fallbacks after ConfigKit resolution.
|
||||||
|
An omitted working directory is still derived by the presence-aware resolver, an
|
||||||
|
explicit relative directory is still rooted at the pipeline, and explicit empty or
|
||||||
|
whitespace-only values remain empty and fail enabled-Notarius validation. Existing
|
||||||
|
relocation-invariant digest coverage remains green.
|
||||||
|
|
||||||
|
**Evidence:** ConfigKit's resolver correctly uses presence-aware defaulting at
|
||||||
|
`internal/config/pipeline_configkit.go:180`. However,
|
||||||
|
`internal/config/load.go:454` subsequently replaces any blank working directory
|
||||||
|
with the config file's directory, irrespective of input presence.
|
||||||
|
`internal/config/effective_digest.go` also applies a blank-value fallback while
|
||||||
|
capturing logical paths. These retained legacy fallbacks bypass the new rule that
|
||||||
|
defaults apply only to absent fields.
|
||||||
|
|
||||||
|
**Reproduction:** Set `notarius.enabled: true`, a valid config path, pipeline ID
|
||||||
|
and output map, and `notarius.working_directory: ""`. With
|
||||||
|
`config_path: notarius/config.yml`, `config show` succeeds and reports
|
||||||
|
`working_directory: notarius`; validation never sees the explicit invalid blank.
|
||||||
|
|
||||||
|
**Required change:** Let the ConfigKit resolver own the absent-field default.
|
||||||
|
The subsequent runtime and logical-path conversions must preserve explicit
|
||||||
|
emptiness so enabled-Notarius validation can reject it. Remove duplicate
|
||||||
|
value-based defaulting rather than adding another private presence mechanism.
|
||||||
|
|
||||||
|
**Acceptance:** Omission still derives the expected directory; explicit empty
|
||||||
|
and whitespace-only values fail when Notarius is enabled; a configured relative
|
||||||
|
directory resolves from the pipeline root. Preserve digest relocation invariance.
|
||||||
|
|
||||||
|
## 3. Enforce unique base definitions even when a profile removes them
|
||||||
|
|
||||||
|
Status: Resolved — P2
|
||||||
|
|
||||||
|
Resolution: Loading now discovers base declarations through ConfigKit's public
|
||||||
|
historical-path enumeration and metadata history from the selected result,
|
||||||
|
without separately resolving the base (see finding 7). Duplicate ordinary or
|
||||||
|
family definitions therefore fail even when an overlay nulls their container or
|
||||||
|
all of Scriptorium. A single base owner
|
||||||
|
with a legitimate profile override remains supported.
|
||||||
|
|
||||||
|
**Evidence:** `rejectDuplicateBaseDefinitions` at
|
||||||
|
`internal/config/pipeline_configkit.go:354` enumerates only artifact/family keys
|
||||||
|
remaining in the final typed configuration. It inspects history for those keys
|
||||||
|
but never examines base declarations removed by an overlay.
|
||||||
|
|
||||||
|
**Reproduction:** Import `first.yml` defining
|
||||||
|
`scriptorium.artifacts.recap.prompt_id` and `second.yml` defining
|
||||||
|
`scriptorium.artifacts.recap.output_path`. Base validation correctly rejects the
|
||||||
|
two-file declaration. Add `profiles/clear.yml` containing:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scriptorium:
|
||||||
|
artifacts: null
|
||||||
|
```
|
||||||
|
|
||||||
|
The same root with `--profile clear` passes. A selected profile can thus hide the
|
||||||
|
base ownership conflict, contrary to the plan's unique base-definition rule.
|
||||||
|
This concerns disjoint contributions to one artifact, not ConfigKit's generic
|
||||||
|
conflicting-leaf checks, which still work.
|
||||||
|
|
||||||
|
**Required change:** Discover the complete base declaration set through public
|
||||||
|
ConfigKit metadata/history, including declarations displaced by an overlay, and
|
||||||
|
validate ownership independently of final effective-key survival. Keep legitimate
|
||||||
|
profile overrides and profile-only definitions supported. Do not reparse source
|
||||||
|
YAML or add a second composer.
|
||||||
|
|
||||||
|
**Acceptance:** Cover duplicate ordinary and family definitions hidden by a null
|
||||||
|
artifact/family container or null Scriptorium section. Base and selected-profile
|
||||||
|
loads must reject the same base conflict, while a single base declaration with a
|
||||||
|
valid overlay remains accepted.
|
||||||
|
|
||||||
|
## 4. Preserve null versus empty collections in effective YAML
|
||||||
|
|
||||||
|
Status: Resolved — P2
|
||||||
|
|
||||||
|
Resolution: Effective YAML is now emitted from the credential-safe JSON value
|
||||||
|
projection through an output-only YAML-node conversion. Explicit nulls, empty maps,
|
||||||
|
empty slices, booleans, strings, and exact JSON numbers retain their semantic types.
|
||||||
|
Public show/diff tests cover null and empty maps and slices, while digest,
|
||||||
|
credential-redaction, deterministic output, and exact-integer coverage remain intact.
|
||||||
|
|
||||||
|
**Evidence:** `internal/config/effective_pipeline.go:154` marshals the typed
|
||||||
|
projection directly with `yaml.Marshal`. The encoder emits nil slices as `[]`
|
||||||
|
and nil maps as `{}`, while the JSON projection used by diff and digest preserves
|
||||||
|
them as `null`. The shared input type does not guarantee shared output semantics.
|
||||||
|
|
||||||
|
**Reproduction:** Set `audita.modules: null` in a valid base and
|
||||||
|
`audita.modules: []` in `profiles/empty.yml`. Both `config show` invocations emit
|
||||||
|
`modules: []`, but `config diff default empty` reports:
|
||||||
|
|
||||||
|
```text
|
||||||
|
changed audita.modules null []
|
||||||
|
```
|
||||||
|
|
||||||
|
The commands disagree about the effective value. The same renderer also supplies
|
||||||
|
the resolved pipeline diagnostic written by prepare.
|
||||||
|
|
||||||
|
**Required change:** Render YAML from a credential-safe projection that explicitly
|
||||||
|
preserves nulls and empty collections, consistently with the semantic projection.
|
||||||
|
Keep exact integers and safe credential reference names. An output-only conversion
|
||||||
|
is appropriate; do not restore YAML decoding or a configuration parser. This
|
||||||
|
requires truthful inspection, not a guarantee that inspection output is reloadable.
|
||||||
|
|
||||||
|
**Acceptance:** Verify null versus empty maps and slices through the public show
|
||||||
|
boundary, and agreement with diff/digest distinctions. Retain credential-leakage
|
||||||
|
and exact-integer regression coverage.
|
||||||
|
|
||||||
|
## 5. Complete provenance for generated artifacts and inferred dependencies
|
||||||
|
|
||||||
|
Status: Resolved — P3
|
||||||
|
|
||||||
|
Resolution: Generated catalog records now collect the complete base/profile family
|
||||||
|
definition set from ConfigKit history. Effective source ownership for materialized
|
||||||
|
dependency lists is rebuilt narrowly from retained explicit-list and input-edge
|
||||||
|
origins, including mixed and generated-family dependencies. CLI tests verify a
|
||||||
|
profiled family catalog agrees with generated source attribution and that inferred
|
||||||
|
ordinary edges name their input contributor instead of the central default.
|
||||||
|
|
||||||
|
Two new reporting paths lose available declaration information:
|
||||||
|
|
||||||
|
- **Profile contributions to family members:**
|
||||||
|
`internal/config/artifact_catalog.go:52` fills a generated member's definitions
|
||||||
|
from the single `member.Source`. That source is chosen by the first matching
|
||||||
|
non-default family field in `artifactFamilySource`, not the full set of effective
|
||||||
|
contributors. Define family `meta` in the base and override its `prompt_id` in
|
||||||
|
`profiles/testing.yml`. `config artifacts --profile testing` reports only the
|
||||||
|
base file for `meta_alice`, even though `config sources` correctly attributes
|
||||||
|
that member's prompt to the testing overlay.
|
||||||
|
- **Inferred ordinary dependencies:** With `second.inputs.prior.source` set to
|
||||||
|
`narratio.artifact.first` and no explicit dependency list, `config sources`
|
||||||
|
reports `scriptorium.artifacts.second.depends_on` as `default / default`.
|
||||||
|
`finalizeArtifactDependencies` stores the input-edge origin, but the effective
|
||||||
|
ownership projection retains the pre-inference default attribution from
|
||||||
|
`internal/config/effective_digest.go:55` and does not consult that origin.
|
||||||
|
|
||||||
|
**Impact:** Operators inspecting a testing profile or following a dependency can
|
||||||
|
be directed to the wrong file or told a user-derived value is a central default.
|
||||||
|
Runtime ordering and generated artifact values were correct in these reproductions.
|
||||||
|
|
||||||
|
**Required change:** Use ConfigKit's contributing locations for family definition
|
||||||
|
sets, as for ordinary artifacts, and add narrow generated attribution for inferred
|
||||||
|
dependency lists using their retained edge origins. Preserve family/character/party
|
||||||
|
identity and deterministic ordering. Avoid introducing a second general provenance
|
||||||
|
store or choosing a file based on the first alphabetically visited field.
|
||||||
|
|
||||||
|
**Acceptance:** A profiled family catalog exposes both relevant files; an inferred
|
||||||
|
edge points back to its input declaration; mixed explicit/inferred dependencies
|
||||||
|
retain their relevant contributors. Verify catalog and sources agree after expansion.
|
||||||
|
|
||||||
|
## 6. Surface successful secrets bootstrap reports, including skipped empty variables
|
||||||
|
|
||||||
|
Status: Resolved — P3
|
||||||
|
|
||||||
|
Resolution: Successful bootstrap reports now flow once through the command
|
||||||
|
diagnostics channel, while direct internal command calls default that channel to
|
||||||
|
discard. ConfigKit's value-free report makes skipped existing empty variables
|
||||||
|
explicit and remains available before later typed-load failures; structured output
|
||||||
|
stays isolated on stdout. Command-level coverage verifies successful reporting,
|
||||||
|
skipped-empty reporting, value redaction, clean config-show output, and one report
|
||||||
|
for a two-profile diff.
|
||||||
|
|
||||||
|
**Evidence:** `internal/app/secrets_bootstrap.go:36` discards the report whenever
|
||||||
|
`LoadSecretsDir` returns no error. Reports are only rendered when directory
|
||||||
|
bootstrap itself fails. This does not implement the roadmap's safe bootstrap
|
||||||
|
reporting boundary or Stage 6's warning presentation requirement.
|
||||||
|
|
||||||
|
**Reproduction:** Place a synthetic key in `secrets/NARRATIO_REVIEW_KEY`, reference
|
||||||
|
it from `audita.llm_api_key`, and invoke config validation with
|
||||||
|
`NARRATIO_REVIEW_KEY=` in the environment. ConfigKit correctly skips the file
|
||||||
|
because the variable already exists, then typed loading fails with `secret.empty`.
|
||||||
|
Narratio shows only that load failure, omitting the bootstrap report's
|
||||||
|
`skipped_existing` and `ExistingEmpty` information. A successful load likewise
|
||||||
|
discards all bootstrap results.
|
||||||
|
|
||||||
|
**Required change:** Retain and render or summarize the safe report once per
|
||||||
|
invocation through a diagnostic output channel, including when a later typed load
|
||||||
|
fails. Make skipped-existing-empty outcomes explicit. Preserve process environment
|
||||||
|
precedence and the existing partial report on failure; keep values out of all
|
||||||
|
diagnostics and keep structured/YAML stdout uncontaminated. Do not add bootstrap
|
||||||
|
rollback, retries, or repeated directory loading.
|
||||||
|
|
||||||
|
**Acceptance:** Command-level tests demonstrate safe success/report output and a
|
||||||
|
useful skipped-empty diagnostic, without leaking a synthetic secret or polluting
|
||||||
|
show output. A two-profile diff still bootstraps and reports only once.
|
||||||
|
|
||||||
|
## 7. Do not resolve unused base credentials when loading a profile
|
||||||
|
|
||||||
|
Status: Resolved — P2
|
||||||
|
|
||||||
|
Resolution: Upgraded to released ConfigKit v0.1.2 and replaced the extra base load
|
||||||
|
with `Metadata.HistoricalPaths()` enumeration of declaration paths in the selected
|
||||||
|
result. Ownership checks still inspect base-layer history for both ordinary
|
||||||
|
artifacts and families, even when overlays remove them. Regression tests verify
|
||||||
|
that a profile can replace an unset production credential with a resolvable testing
|
||||||
|
reference or remove it with null. Existing hidden-duplicate and legitimate-override
|
||||||
|
tests remain intact. No local dependency replacement or second composer is needed.
|
||||||
|
|
||||||
|
Follow-up review: 2026-09-07 at `3ce1981`. The original six reproductions are
|
||||||
|
addressed, but the fix for finding 3 introduces this regression.
|
||||||
|
|
||||||
|
**Evidence:** `internal/config/pipeline_configkit.go:49-57` loads the base again
|
||||||
|
without a profile to check duplicate base definitions. This calls the complete
|
||||||
|
typed ConfigKit loader, including secret resolution, domain resolution, and
|
||||||
|
validation—not just declaration ownership inspection. Consequently, loading a
|
||||||
|
valid testing profile also requires credentials from the unselected base.
|
||||||
|
|
||||||
|
**Reproduction:** In an otherwise valid base, set `audita.llm_api_key` to
|
||||||
|
`NARRATIO_REVIEW_PROD_KEY`. In `profiles/testing.yml`, replace it with
|
||||||
|
`NARRATIO_REVIEW_TEST_KEY`. Leave the production variable unset and supply only
|
||||||
|
the testing variable with a synthetic value. `config validate --config <root>
|
||||||
|
--profile testing` fails with `secret.unset` at the base credential declaration.
|
||||||
|
Supplying both variables makes it pass. A profile that instead sets
|
||||||
|
`audita.llm_api_key: null` also fails when the unused production variable is
|
||||||
|
unset. Both failures were reproduced through the current CLI.
|
||||||
|
|
||||||
|
**Impact:** Operators cannot use an independent testing credential environment
|
||||||
|
or remove a production credential requirement through a profile. Commands fail
|
||||||
|
despite having all credentials required by their effective configuration. The
|
||||||
|
extra full load also unnecessarily repeats configuration processing.
|
||||||
|
|
||||||
|
**Required change:** Enforce unique base ownership without requiring the
|
||||||
|
unselected base to succeed as a separately resolved runtime configuration.
|
||||||
|
Inspect complete declaration history through ConfigKit's public metadata,
|
||||||
|
including declarations displaced by overlays. If additional upstream metadata
|
||||||
|
support is needed, address that explicitly rather than requiring unused secrets,
|
||||||
|
suppressing arbitrary load failures, reparsing YAML, or restoring a second
|
||||||
|
composer. Preserve the duplicate-definition protection added for finding 3.
|
||||||
|
|
||||||
|
**Acceptance:** Add public-boundary regression tests for a profile replacing an
|
||||||
|
unset production credential with an available testing credential, and for a
|
||||||
|
profile removing that credential with null. Both must load successfully without
|
||||||
|
the production variable. Retain failures for duplicate base ordinary/family
|
||||||
|
definitions hidden by null overlays, along with legitimate profile override and
|
||||||
|
profile-only definition coverage. Resolve secrets only for the selected effective
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
## Follow-up verification
|
||||||
|
|
||||||
|
After adopting ConfigKit v0.1.2 and completing finding 7, the full
|
||||||
|
`scripts/check-release-candidate.sh v2.0.0` check passed: module hygiene,
|
||||||
|
formatting/whitespace, uncached tests and race tests, vet, build, documentation,
|
||||||
|
maintained examples, and Linux amd64/arm64 plus macOS arm64 release cross-builds.
|
||||||
|
`go mod verify` also passed. The new credential regressions failed before the
|
||||||
|
ownership-check fix and passed afterward. No local module replacements, release
|
||||||
|
tags, or pushes were used.
|
||||||
|
|
||||||
|
At `3ce1981`, `go test ./...`, `go test -race ./...`, `go vet ./...`, and
|
||||||
|
`go build ./...` passed. The new finding was reproduced with a freshly built CLI,
|
||||||
|
temporary configuration files, and synthetic credentials; no external adapters
|
||||||
|
or cloud services were invoked. That review pass changed only this review record.
|
||||||
|
|
||||||
|
## Original verification and scope
|
||||||
|
|
||||||
|
The following checks passed at the reviewed commit:
|
||||||
|
|
||||||
|
- `go test ./...`
|
||||||
|
- `go test -race ./...`
|
||||||
|
- `go vet ./...`
|
||||||
|
- `go build ./...`
|
||||||
|
- The release asset builder for `v2.0.0`, producing exactly Linux amd64, Linux
|
||||||
|
arm64, and macOS arm64 assets; the Linux amd64 binary reported the expected version.
|
||||||
|
|
||||||
|
The existing full suites include documentation and maintained-example checks.
|
||||||
|
The findings above were additionally reproduced through a freshly built CLI with
|
||||||
|
isolated temporary YAML fixtures and synthetic credentials. No live downstream
|
||||||
|
tools or cloud services were invoked. Linux arm64 and macOS arm64 were cross-built,
|
||||||
|
not tested natively. No tag was created or pushed.
|
||||||
|
|
||||||
|
The migration removes a net 1,235 production Go lines and 843 test Go lines versus
|
||||||
|
`1558c9c` (physical diff lines, including comments and whitespace). ConfigKit owns
|
||||||
|
generic loading/composition; Narratio retains domain resolution, orchestration,
|
||||||
|
safe inspection, and adapter delivery. These findings require completing that
|
||||||
|
design, not reversing the simplification.
|
||||||
@@ -56,7 +56,8 @@ Symptom:
|
|||||||
|
|
||||||
Likely cause:
|
Likely cause:
|
||||||
|
|
||||||
- stale field name, typo, invalid enum, or invalid duration/path format.
|
- stale field name, typo, invalid enum, invalid duration/path format, or an
|
||||||
|
unsupported local manifest schema/state marker.
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
@@ -68,9 +69,69 @@ Safe fix:
|
|||||||
|
|
||||||
- align config with [Configuration](./config.md) and the
|
- align config with [Configuration](./config.md) and the
|
||||||
[maintained examples](../examples/README.md).
|
[maintained examples](../examples/README.md).
|
||||||
|
- for a manifest schema error, move the unsupported local state aside and
|
||||||
|
regenerate it; restore a verified current committed snapshot or republish
|
||||||
|
healthy regenerated state when appropriate. Do not add version or stage
|
||||||
|
markers by hand.
|
||||||
|
- if an older manifest is rejected for its retired cleanup-obligation field,
|
||||||
|
finish outstanding cleanup with the pre-upgrade binary. Otherwise verify the
|
||||||
|
remote commit, move aside or regenerate the incompatible manifest, and use
|
||||||
|
the cleanup stage. Do not treat manual JSON editing as the normal migration
|
||||||
|
path.
|
||||||
|
|
||||||
Relevant reference: [Configuration](./config.md).
|
Relevant reference: [Configuration](./config.md).
|
||||||
|
|
||||||
|
## Party schema version rejected
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- configuration loading reports that `party.schema_version` is required or
|
||||||
|
unsupported.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- the campaign `party.yml` is an unversioned legacy document, or names a
|
||||||
|
schema other than `narratio.party.v1`.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- replace the file with one exact, single-document
|
||||||
|
[`narratio.party.v1` party](./integrations/party.md); do not add a version
|
||||||
|
marker to an otherwise incompatible legacy structure. The versioned party is
|
||||||
|
the only configured roster authority.
|
||||||
|
|
||||||
|
Relevant reference: [Canonical Party Input](./integrations/party.md).
|
||||||
|
|
||||||
|
## Unexpected imported or profile value
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- an effective configuration value differs from the root file, or a duplicate
|
||||||
|
ownership/configuration error is hard to locate.
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio config sources --config /path/pipeline.yml --profile testing
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `--campaign` or `--campaign-file` when the pipeline has party-driven
|
||||||
|
artifact families. The output identifies each effective logical field's root,
|
||||||
|
import, profile, default, campaign, party, or family source without printing
|
||||||
|
the field value or credential contents.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- move a duplicated base field so it has one owner;
|
||||||
|
- correct the selected profile or its overlay; or
|
||||||
|
- correct the campaign party/family declaration that owns generated values.
|
||||||
|
|
||||||
|
To review what would actually change before switching profiles, run `config
|
||||||
|
diff` with the same pipeline and campaign selectors. It compares normalized
|
||||||
|
effective values rather than YAML formatting or source-file layout.
|
||||||
|
|
||||||
|
Relevant reference: [Configuration inspection](./config.md#read-only-effective-pipeline-inspection).
|
||||||
|
|
||||||
## Audio mode conflict
|
## Audio mode conflict
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
@@ -141,8 +202,9 @@ Safe fix:
|
|||||||
|
|
||||||
- widen the bounded range to include the first reported stage, or recover that
|
- widen the bounded range to include the first reported stage, or recover that
|
||||||
stage explicitly with `run-stage` before retrying. The failed check does not
|
stage explicitly with `run-stage` before retrying. The failed check does not
|
||||||
create a run record or modify the manifest. Narratio does not resume-validate
|
create a run-local directory or modify the manifest. Narratio does not
|
||||||
excluded prefix stages, and stages after `--through` are not prerequisites.
|
resume-validate excluded prefix stages, and stages after `--through` are not
|
||||||
|
prerequisites.
|
||||||
|
|
||||||
If prerequisite statuses are terminal but a selected stage reports a missing,
|
If prerequisite statuses are terminal but a selected stage reports a missing,
|
||||||
unsafe, or checksum-inconsistent artifact, repair the artifact at the stage
|
unsafe, or checksum-inconsistent artifact, repair the artifact at the stage
|
||||||
@@ -252,29 +314,6 @@ Narratio validates selector structure and prepared evidence, while Notarius
|
|||||||
owns slot declarations, media compatibility, and generated-handoff conflicts.
|
owns slot declarations, media compatibility, and generated-handoff conflicts.
|
||||||
Relevant reference: [Notarius integration](./integrations/notarius.md).
|
Relevant reference: [Notarius integration](./integrations/notarius.md).
|
||||||
|
|
||||||
## Atomic Notarius promotion unsupported
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
|
|
||||||
- extraction fails with `atomic no-replace directory promotion is unsupported`
|
|
||||||
before a durable bundle or temporary promotion tree is created.
|
|
||||||
|
|
||||||
Likely cause:
|
|
||||||
|
|
||||||
- Narratio is running on an operating system other than Linux, macOS, or
|
|
||||||
Windows, where the required atomic no-replace directory primitive has not
|
|
||||||
been implemented and verified.
|
|
||||||
|
|
||||||
Safe fix:
|
|
||||||
|
|
||||||
- run extraction on Linux, macOS, or Windows. Do not replace the atomic commit
|
|
||||||
with a manual copy or move; the session manifest must never observe a partial
|
|
||||||
or overwritten bundle.
|
|
||||||
|
|
||||||
This is an extraction-specific platform boundary, not a support statement for
|
|
||||||
unrelated Narratio workflows. See
|
|
||||||
[Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
|
||||||
|
|
||||||
## Notarius receipt or index incompatible
|
## Notarius receipt or index incompatible
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
@@ -332,8 +371,8 @@ Likely causes:
|
|||||||
Safe fix:
|
Safe fix:
|
||||||
|
|
||||||
- allow the automatic rerun after verifying the current configuration. Treat
|
- allow the automatic rerun after verifying the current configuration. Treat
|
||||||
an unsafe path or symlink error as filesystem corruption or tampering and
|
an unsafe path or bundle symlink error as filesystem corruption or tampering
|
||||||
investigate it rather than replacing files manually.
|
and investigate it rather than replacing files manually.
|
||||||
|
|
||||||
## Notarius transitive configuration changed
|
## Notarius transitive configuration changed
|
||||||
|
|
||||||
@@ -365,8 +404,7 @@ Symptom:
|
|||||||
|
|
||||||
Likely causes:
|
Likely causes:
|
||||||
|
|
||||||
- the per-artifact record is stale, missing, failed, unselected, malformed, or
|
- the per-artifact record is stale, missing, failed, unselected, or malformed;
|
||||||
from the legacy aggregate-only manifest contract;
|
|
||||||
- a configured prompt/profile, dependency, input identity, output path, or
|
- a configured prompt/profile, dependency, input identity, output path, or
|
||||||
effective variable changed; or
|
effective variable changed; or
|
||||||
- the recorded output is missing, unsafe, empty, or has a size/checksum that no
|
- the recorded output is missing, unsafe, empty, or has a size/checksum that no
|
||||||
@@ -395,33 +433,82 @@ narratio analyze 2026-04-04 --artifacts session_recap
|
|||||||
Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection)
|
Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection)
|
||||||
and [Artifact Internals](./internal/artifacts.md#resolution-rules).
|
and [Artifact Internals](./internal/artifacts.md#resolution-rules).
|
||||||
|
|
||||||
## Legacy aggregate analysis requires regeneration
|
## Analysis manifest checkpoint failed
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
|
|
||||||
- a manifest from an older Narratio version reports aggregate analyze success
|
- analyze reports that it could not save the session manifest after completing
|
||||||
and the old files are present, but configured artifact sources remain
|
an artifact.
|
||||||
unavailable.
|
|
||||||
|
|
||||||
Likely cause:
|
Likely causes:
|
||||||
|
|
||||||
- the manifest has no supported per-artifact analyze state. Aggregate output
|
- the workspace is unwritable or out of space;
|
||||||
lists do not establish current configured-artifact authority.
|
- the manifest path or its parent directory has unsafe permissions or type; or
|
||||||
|
- a transient local filesystem error interrupted atomic manifest replacement.
|
||||||
|
|
||||||
Safe fix:
|
Safe fix:
|
||||||
|
|
||||||
- regenerate the required artifacts. A partial selection makes only its
|
- correct the reported filesystem or permission problem;
|
||||||
targets and prerequisites eligible for current state; unselected legacy
|
- inspect `session artifacts` to identify the last durable current records; and
|
||||||
files intentionally remain unavailable. Run full analysis later when every
|
- rerun analyze normally. Narratio reuses earlier successful checkpoints and
|
||||||
enabled configured artifact must become current.
|
retries the failed or non-current artifact and its required dependents.
|
||||||
|
|
||||||
|
Do not treat a newly created canonical artifact file as current when its
|
||||||
|
checkpoint failed, and do not edit the manifest to advertise it. If the error
|
||||||
|
recurs, preserve the manifest and run-local diagnostics before repairing the
|
||||||
|
workspace.
|
||||||
|
|
||||||
|
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
|
||||||
|
and [Manifest Internals](./internal/manifest.md#persistence-semantics).
|
||||||
|
|
||||||
|
## Built-in transcript or bounds source is unavailable
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- analyze, extract, render, or publish says a built-in source is unavailable
|
||||||
|
even though a file exists at its usual session path.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- the producing stage has not succeeded, its output lacks the exact registered
|
||||||
|
source, run ID, or checksum, its evidence is duplicated or stale, or the
|
||||||
|
durable bytes no longer match the manifest checksum.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- do not copy or rename the file to make it discoverable. Rerun the producing
|
||||||
|
stage to regenerate both the bytes and current manifest evidence, then retry
|
||||||
|
the consumer.
|
||||||
|
|
||||||
|
## An older manifest schema is rejected
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- Narratio rejects a local session manifest because `schema_version` is missing
|
||||||
|
or unsupported; or
|
||||||
|
- restore rejects a committed remote session manifest for the same reason.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- the manifest predates the supported canonical state contract. Narratio
|
||||||
|
rejects it at the schema boundary rather than interpreting aggregate output
|
||||||
|
lists or incidental files as current authority.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- for unsupported local state, move the old manifest aside and regenerate, or
|
||||||
|
restore a verified current committed snapshot when one is authoritative;
|
||||||
|
- for unsupported committed remote state, regenerate healthy local state as
|
||||||
|
needed and republish the session.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio analyze 2026-04-04 --artifacts session_recap
|
narratio run 2026-04-04
|
||||||
narratio analyze 2026-04-04
|
narratio publish 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
After current records exist, inspect them and publish explicitly. Do not delete
|
After current records exist, inspect them and publish explicitly. Do not copy
|
||||||
the legacy files merely to influence selection; availability is manifest-owned.
|
old files into canonical paths merely to influence selection; availability is
|
||||||
|
manifest-owned.
|
||||||
|
|
||||||
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
|
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
|
||||||
and [Manifest Internals](./internal/manifest.md#analyze-owned-artifact-state).
|
and [Manifest Internals](./internal/manifest.md#analyze-owned-artifact-state).
|
||||||
@@ -461,7 +548,9 @@ Symptom:
|
|||||||
Likely causes:
|
Likely causes:
|
||||||
|
|
||||||
- missing `session.previous_session_id`;
|
- missing `session.previous_session_id`;
|
||||||
- previous artifact not restored/published for source session.
|
- the selected previous-session commit does not declare the required canonical
|
||||||
|
source as a `published_output`; or
|
||||||
|
- the declared published object is unavailable or fails identity validation.
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
@@ -519,6 +608,8 @@ Symptom:
|
|||||||
Likely cause:
|
Likely cause:
|
||||||
|
|
||||||
- local durable files differ from remote restore sources.
|
- local durable files differ from remote restore sources.
|
||||||
|
- a restore target or one of its ancestors is a symlink or another non-regular
|
||||||
|
filesystem entry.
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
@@ -529,7 +620,11 @@ narratio session restore 2026-04-04 --dry-run
|
|||||||
Safe fix:
|
Safe fix:
|
||||||
|
|
||||||
- review conflicts;
|
- review conflicts;
|
||||||
- rerun with `--force` only when remote state should overwrite local.
|
- rerun with `--force` only when remote state should overwrite an eligible
|
||||||
|
regular local file; repair a symlink or other non-regular conflict first.
|
||||||
|
|
||||||
|
If the filesystem changed after the plan was printed, rerun restore after
|
||||||
|
repairing the destination; installation performs the same safety check again.
|
||||||
|
|
||||||
Relevant reference: [Operations: Restore Workflow](./operations.md#restore-workflow).
|
Relevant reference: [Operations: Restore Workflow](./operations.md#restore-workflow).
|
||||||
|
|
||||||
@@ -622,7 +717,8 @@ Symptom:
|
|||||||
|
|
||||||
Likely causes:
|
Likely causes:
|
||||||
|
|
||||||
- invalid `pipeline.secrets.env_dir`;
|
- missing, unreadable, or invalid explicit `--secrets-dir`;
|
||||||
|
- invalid entries in the conventional `secrets/` directory beside the root pipeline;
|
||||||
- missing credential environment variables;
|
- missing credential environment variables;
|
||||||
- invalid S3 endpoint/bucket settings.
|
- invalid S3 endpoint/bucket settings.
|
||||||
|
|
||||||
@@ -635,7 +731,7 @@ env | sed 's/=.*//' | grep -E 'OBJECT_STORAGE|AWS|AUDITA|SCRIPTORIUM'
|
|||||||
|
|
||||||
Safe fix:
|
Safe fix:
|
||||||
|
|
||||||
- correct secret-file path and permissions;
|
- correct `--secrets-dir`, secret-file type/name, and operator-managed permissions;
|
||||||
- provide required env vars;
|
- provide required env vars;
|
||||||
- keep secret values out of YAML.
|
- keep secret values out of YAML.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,26 @@ in the [configuration reference](../docs/config.md).
|
|||||||
WhisperX URL.
|
WhisperX URL.
|
||||||
- [Production-shaped pipeline](pipeline.production.yml): S3 storage, publish,
|
- [Production-shaped pipeline](pipeline.production.yml): S3 storage, publish,
|
||||||
external tools, and configured Scriptorium artifacts.
|
external tools, and configured Scriptorium artifacts.
|
||||||
|
- [Production-base/testing-overlay bundle](production-testing/pipeline.yml):
|
||||||
|
explicit `conf.d` imports, one ordinary artifact per `artifacts/<key>.yml`,
|
||||||
|
one family per `artifact-families/<key>.yml`, and the fixed
|
||||||
|
`profiles/testing.yml` overlay. Production values live in the base. The
|
||||||
|
bundle demonstrates an input-only artifact dependency, two character families
|
||||||
|
sharing the campaign party, and a testing-only disabled artifact. Inspect it
|
||||||
|
offline with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
narratio config validate --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
|
||||||
|
narratio config show --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing
|
||||||
|
narratio config sources --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing
|
||||||
|
narratio config artifacts --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing
|
||||||
|
narratio config diff default testing --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
These inspection commands load configuration and resolve explicit credential
|
||||||
|
references, but do not load a session or create runtime state. This bundle is
|
||||||
|
intentionally local-storage and credential-reference free so the walkthrough
|
||||||
|
needs no tools or cloud services.
|
||||||
- [Full annotated pipeline](pipeline.full.annotated.yml): every implemented
|
- [Full annotated pipeline](pipeline.full.annotated.yml): every implemented
|
||||||
pipeline section with explanatory comments.
|
pipeline section with explanatory comments.
|
||||||
- [Extraction subset pipeline](pipeline.extraction-subset.yml): a focused
|
- [Extraction subset pipeline](pipeline.extraction-subset.yml): a focused
|
||||||
@@ -37,14 +57,23 @@ with the sample campaign and a compatible local- or S3-audio session.
|
|||||||
- The sample campaign references its local
|
- The sample campaign references its local
|
||||||
[speakers](campaigns/sample-campaign/speakers.yml),
|
[speakers](campaigns/sample-campaign/speakers.yml),
|
||||||
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
||||||
[glossary](campaigns/sample-campaign/glossary.yml),
|
[glossary](campaigns/sample-campaign/glossary.yml), and canonical
|
||||||
[players](campaigns/sample-campaign/players.yml), and
|
[party](campaigns/sample-campaign/party.yml) fixture, plus an optional
|
||||||
[party](campaigns/sample-campaign/party.yml) fixtures, plus an optional
|
|
||||||
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
|
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
|
||||||
follows the Notarius v0.6 contract.
|
follows the Notarius v0.6 contract. Narratio derives the players projection
|
||||||
|
from this party source.
|
||||||
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
|
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
|
||||||
reserves the expected filename and directory shape. Replace it with a real
|
reserves the expected filename and directory shape. Replace it with a real
|
||||||
FLAC file before running transcription.
|
FLAC file before running transcription.
|
||||||
|
|
||||||
The examples contain environment-variable names but no credential values. They
|
The examples contain environment-variable names but no credential values. Set
|
||||||
use fictional campaign content and reserved example domains.
|
`OBJECT_STORAGE_KEY_ID`, `OBJECT_STORAGE_KEY`, and `AUDITA_LLM_API_KEY` to
|
||||||
|
nonempty synthetic or real values before loading the production-shaped and full
|
||||||
|
annotated pipelines. They use fictional campaign content and reserved example
|
||||||
|
domains.
|
||||||
|
|
||||||
|
For a deployed bundle, keep credential files out of source control in a
|
||||||
|
`secrets/` directory beside the root `pipeline.yml`, one file per referenced
|
||||||
|
environment-variable name, or select another directory with `--secrets-dir`.
|
||||||
|
Static S3 credentials are optional: omit both S3 credential references to use
|
||||||
|
the AWS SDK discovery chain.
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
spell_catalog_file: ./spell_catalog.json
|
spell_catalog_file: ./spell_catalog.json
|
||||||
|
|||||||
@@ -1,2 +1,26 @@
|
|||||||
- name: Example Hero
|
schema_version: narratio.party.v1
|
||||||
type: pc
|
|
||||||
|
characters:
|
||||||
|
arannis:
|
||||||
|
player:
|
||||||
|
name: Rowan Hale
|
||||||
|
character:
|
||||||
|
name: Arannis
|
||||||
|
alias:
|
||||||
|
- Ari
|
||||||
|
- The Grey Owl
|
||||||
|
classes:
|
||||||
|
- name: wizard
|
||||||
|
level: 8
|
||||||
|
brenna:
|
||||||
|
player:
|
||||||
|
name: Rowan Hale
|
||||||
|
character:
|
||||||
|
name: Brenna
|
||||||
|
alias:
|
||||||
|
- Shield of Dawn
|
||||||
|
classes:
|
||||||
|
- name: paladin
|
||||||
|
level: 6
|
||||||
|
- name: warlock
|
||||||
|
level: 2
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
- name: Example Player
|
|
||||||
role: player
|
|
||||||
@@ -7,9 +7,8 @@ workspace:
|
|||||||
# Optional: remove run-scoped workdir after successful publish commit.
|
# Optional: remove run-scoped workdir after successful publish commit.
|
||||||
cleanup_after_publish: false
|
cleanup_after_publish: false
|
||||||
|
|
||||||
# Optional: local secret file loader (directory of ENV_VAR_NAME files).
|
# Credential files, when used, belong in a sibling secrets/ directory or in the
|
||||||
# secrets:
|
# directory selected with --secrets-dir; they are not pipeline configuration.
|
||||||
# env_dir: ./secrets
|
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
# Defaults to "local". Use "s3" explicitly for publish + S3 audio workflows.
|
# Defaults to "local". Use "s3" explicitly for publish + S3 audio workflows.
|
||||||
@@ -24,8 +23,8 @@ storage:
|
|||||||
endpoint: ""
|
endpoint: ""
|
||||||
force_path_style: false
|
force_path_style: false
|
||||||
# Optional; defaults shown explicitly.
|
# Optional; defaults shown explicitly.
|
||||||
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
access_key_id: OBJECT_STORAGE_KEY_ID
|
||||||
secret_access_key_env: OBJECT_STORAGE_KEY
|
secret_access_key: OBJECT_STORAGE_KEY
|
||||||
|
|
||||||
campaigns:
|
campaigns:
|
||||||
# Optional; defaults to /usr/local/share/narratio/campaigns.
|
# Optional; defaults to /usr/local/share/narratio/campaigns.
|
||||||
@@ -94,7 +93,7 @@ audita:
|
|||||||
# Optional overrides; defaults shown explicitly where applicable.
|
# Optional overrides; defaults shown explicitly where applicable.
|
||||||
binary: audita
|
binary: audita
|
||||||
timeout: 3h
|
timeout: 3h
|
||||||
llm_api_key_env: AUDITA_LLM_API_KEY
|
llm_api_key: AUDITA_LLM_API_KEY
|
||||||
modules: [glossary, homophones, spoken_word, grammar]
|
modules: [glossary, homophones, spoken_word, grammar]
|
||||||
base_url: ""
|
base_url: ""
|
||||||
model: ""
|
model: ""
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ storage:
|
|||||||
bucket: my-dnd-archive
|
bucket: my-dnd-archive
|
||||||
root_prefix: dnd
|
root_prefix: dnd
|
||||||
region: us-east-1
|
region: us-east-1
|
||||||
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
access_key_id: OBJECT_STORAGE_KEY_ID
|
||||||
secret_access_key_env: OBJECT_STORAGE_KEY
|
secret_access_key: OBJECT_STORAGE_KEY
|
||||||
|
|
||||||
campaigns:
|
campaigns:
|
||||||
root: /usr/local/share/narratio/campaigns
|
root: /usr/local/share/narratio/campaigns
|
||||||
@@ -57,7 +57,7 @@ seriatim:
|
|||||||
audita:
|
audita:
|
||||||
binary: audita
|
binary: audita
|
||||||
timeout: 3h
|
timeout: 3h
|
||||||
llm_api_key_env: AUDITA_LLM_API_KEY
|
llm_api_key: AUDITA_LLM_API_KEY
|
||||||
modules: [glossary, homophones, spoken_word, grammar]
|
modules: [glossary, homophones, spoken_word, grammar]
|
||||||
output_schema: audita-v1
|
output_schema: audita-v1
|
||||||
work_dir_retention: auto
|
work_dir_retention: auto
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
scriptorium:
|
||||||
|
artifact_families:
|
||||||
|
character_items:
|
||||||
|
enabled: true
|
||||||
|
for_each: party.characters
|
||||||
|
prompt_id: dnd.character_items
|
||||||
|
profile_id: production-placeholder
|
||||||
|
output_path_pattern: artifacts/characters/{character_id}/items.md
|
||||||
|
inputs:
|
||||||
|
character_meta:
|
||||||
|
source: narratio.member_artifact.character_meta
|
||||||
|
required: true
|
||||||
|
member_vars:
|
||||||
|
character_id: character_id
|
||||||
|
character_name: character.name
|
||||||
|
aliases: character.alias_summary
|
||||||
|
publish:
|
||||||
|
enabled: true
|
||||||
|
required: false
|
||||||
|
dest_pattern: artifacts/characters/{character_id}/items.md
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
scriptorium:
|
||||||
|
artifact_families:
|
||||||
|
character_meta:
|
||||||
|
enabled: true
|
||||||
|
for_each: party.characters
|
||||||
|
prompt_id: dnd.character_meta
|
||||||
|
profile_id: production-placeholder
|
||||||
|
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||||
|
member_vars:
|
||||||
|
character_id: character_id
|
||||||
|
character_name: character.name
|
||||||
|
player_name: player.name
|
||||||
|
class_summary: character.class_summary
|
||||||
11
examples/production-testing/artifacts/player_handout.yml
Normal file
11
examples/production-testing/artifacts/player_handout.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
scriptorium:
|
||||||
|
artifacts:
|
||||||
|
player_handout:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.player_handout
|
||||||
|
profile_id: production-placeholder
|
||||||
|
output_path: artifacts/player-handout.md
|
||||||
|
inputs:
|
||||||
|
recap:
|
||||||
|
source: narratio.artifact.session_recap
|
||||||
|
required: true
|
||||||
11
examples/production-testing/artifacts/session_recap.yml
Normal file
11
examples/production-testing/artifacts/session_recap.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
scriptorium:
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
profile_id: production-placeholder
|
||||||
|
output_path: artifacts/session-recap.md
|
||||||
|
inputs:
|
||||||
|
transcript:
|
||||||
|
source: narratio.transcript.final_trimmed
|
||||||
|
required: true
|
||||||
12
examples/production-testing/conf.d/platform.yml
Normal file
12
examples/production-testing/conf.d/platform.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
workspace:
|
||||||
|
root: ./workspace
|
||||||
|
|
||||||
|
campaigns:
|
||||||
|
root: ../../campaigns
|
||||||
|
default_campaign_id: sample-campaign
|
||||||
|
|
||||||
|
cache:
|
||||||
|
root: ./cache
|
||||||
|
|
||||||
|
spool:
|
||||||
|
root: ./spool
|
||||||
7
examples/production-testing/conf.d/publish.yml
Normal file
7
examples/production-testing/conf.d/publish.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
publish:
|
||||||
|
enabled: true
|
||||||
|
upload_run: false
|
||||||
|
outputs:
|
||||||
|
- source: narratio.transcript.final_markdown
|
||||||
|
dest: transcripts/final.md
|
||||||
|
required: true
|
||||||
2
examples/production-testing/conf.d/storage.yml
Normal file
2
examples/production-testing/conf.d/storage.yml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
storage:
|
||||||
|
backend: local
|
||||||
18
examples/production-testing/conf.d/transcript.yml
Normal file
18
examples/production-testing/conf.d/transcript.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.example.com/transcribe
|
||||||
|
language: en
|
||||||
|
|
||||||
|
seriatim:
|
||||||
|
binary: seriatim
|
||||||
|
output_schema: seriatim-intermediate
|
||||||
|
|
||||||
|
audita:
|
||||||
|
binary: audita
|
||||||
|
modules: [glossary, grammar]
|
||||||
|
output_schema: audita-v1
|
||||||
|
model: narratio-production-model-placeholder
|
||||||
|
validation_model: narratio-production-validator-placeholder
|
||||||
|
|
||||||
|
normalize:
|
||||||
|
output_path: transcripts/final.json
|
||||||
|
output_schema: seriatim-intermediate
|
||||||
14
examples/production-testing/pipeline.yml
Normal file
14
examples/production-testing/pipeline.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Copyable production-base/testing-overlay entry point. ConfigKit expands only
|
||||||
|
# these explicit path and glob imports; Narratio never scans directories itself.
|
||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: conf.d/platform.yml
|
||||||
|
- path: conf.d/storage.yml
|
||||||
|
- path: conf.d/transcript.yml
|
||||||
|
- path: conf.d/publish.yml
|
||||||
|
- glob: artifacts/*.yml
|
||||||
|
- glob: artifact-families/*.yml
|
||||||
|
|
||||||
|
scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
config_path: ./scriptorium/config.yml
|
||||||
18
examples/production-testing/profiles/testing.yml
Normal file
18
examples/production-testing/profiles/testing.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
audita:
|
||||||
|
model: narratio-testing-model-placeholder
|
||||||
|
validation_model: narratio-testing-validator-placeholder
|
||||||
|
|
||||||
|
scriptorium:
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
profile_id: testing-placeholder
|
||||||
|
player_handout:
|
||||||
|
profile_id: testing-placeholder
|
||||||
|
testing_notes:
|
||||||
|
enabled: false
|
||||||
|
output_path: artifacts/testing-notes.md
|
||||||
|
artifact_families:
|
||||||
|
character_meta:
|
||||||
|
profile_id: testing-placeholder
|
||||||
|
character_items:
|
||||||
|
profile_id: testing-placeholder
|
||||||
6
go.mod
6
go.mod
@@ -1,12 +1,15 @@
|
|||||||
module gitea.maximumdirect.net/eric/narratio
|
module gitea.maximumdirect.net/eric/narratio
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gitea.maximumdirect.net/eric/ConfigKit v0.1.2
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
||||||
github.com/aws/smithy-go v1.25.1
|
github.com/aws/smithy-go v1.25.1
|
||||||
|
github.com/google/go-cmp v0.7.0
|
||||||
|
golang.org/x/sync v0.22.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@@ -26,4 +29,5 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
gitea.maximumdirect.net/eric/ConfigKit v0.1.2 h1:uZ5Mvf271SLItXEvUpQYfAvCOVYj9RaoG0auCcbCcSU=
|
||||||
|
gitea.maximumdirect.net/eric/ConfigKit v0.1.2/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||||
@@ -34,6 +36,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOIt
|
|||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
|
||||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,17 +83,17 @@ func materializePlaceholders(req PolishRequest) error {
|
|||||||
"merged_transcript_path": req.MergedTranscriptPath,
|
"merged_transcript_path": req.MergedTranscriptPath,
|
||||||
"output_path": req.OutputProcessedPath,
|
"output_path": req.OutputProcessedPath,
|
||||||
}
|
}
|
||||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
|
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
|
||||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StdoutLogPath != "" {
|
if req.StdoutLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("audita noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("audita noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StderrLogPath != "" {
|
if req.StderrLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("audita noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("audita noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +123,7 @@ func writeJSONIfRequested(path string, payload any) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal placeholder json for %q: %w", path, err)
|
return fmt.Errorf("marshal placeholder json for %q: %w", path, err)
|
||||||
}
|
}
|
||||||
if err := subprocess.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write placeholder json %q: %w", path, err)
|
return fmt.Errorf("write placeholder json %q: %w", path, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
19
internal/adapters/audita/generated_config.go
Normal file
19
internal/adapters/audita/generated_config.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package audita
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeGeneratedYAML(path string, payload any) error {
|
||||||
|
data, err := yaml.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal generated YAML for %q: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
|
||||||
|
return fmt.Errorf("write generated YAML %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -24,7 +23,8 @@ const MaxReportOutputBytes int64 = 16 * 1024 * 1024
|
|||||||
type SubprocessRunnerConfig struct {
|
type SubprocessRunnerConfig struct {
|
||||||
Binary string
|
Binary string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
LLMAPIKeyEnv string
|
LLMAPIKey string
|
||||||
|
LLMAPIKeyReference string
|
||||||
Modules []string
|
Modules []string
|
||||||
BaseURL string
|
BaseURL string
|
||||||
Model string
|
Model string
|
||||||
@@ -43,7 +43,8 @@ type SubprocessRunnerConfig struct {
|
|||||||
type SubprocessRunner struct {
|
type SubprocessRunner struct {
|
||||||
binary string
|
binary string
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
llmAPIKeyEnv string
|
llmAPIKey string
|
||||||
|
llmAPIKeyReference string
|
||||||
modules []string
|
modules []string
|
||||||
baseURL string
|
baseURL string
|
||||||
model string
|
model string
|
||||||
@@ -62,7 +63,8 @@ type SubprocessRunner struct {
|
|||||||
func NewSubprocessRunnerFromConfigValues(
|
func NewSubprocessRunnerFromConfigValues(
|
||||||
binary string,
|
binary string,
|
||||||
timeout string,
|
timeout string,
|
||||||
llmAPIKeyEnv string,
|
llmAPIKey string,
|
||||||
|
llmAPIKeyReference string,
|
||||||
modules []string,
|
modules []string,
|
||||||
baseURL string,
|
baseURL string,
|
||||||
model string,
|
model string,
|
||||||
@@ -86,7 +88,8 @@ func NewSubprocessRunnerFromConfigValues(
|
|||||||
return NewSubprocessRunner(SubprocessRunnerConfig{
|
return NewSubprocessRunner(SubprocessRunnerConfig{
|
||||||
Binary: binary,
|
Binary: binary,
|
||||||
Timeout: parsedTimeout,
|
Timeout: parsedTimeout,
|
||||||
LLMAPIKeyEnv: llmAPIKeyEnv,
|
LLMAPIKey: llmAPIKey,
|
||||||
|
LLMAPIKeyReference: llmAPIKeyReference,
|
||||||
Modules: modules,
|
Modules: modules,
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
Model: model,
|
Model: model,
|
||||||
@@ -110,6 +113,9 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error)
|
|||||||
if cfg.Timeout <= 0 {
|
if cfg.Timeout <= 0 {
|
||||||
return nil, fmt.Errorf("audita timeout must be > 0")
|
return nil, fmt.Errorf("audita timeout must be > 0")
|
||||||
}
|
}
|
||||||
|
if (cfg.LLMAPIKey == "") != (strings.TrimSpace(cfg.LLMAPIKeyReference) == "") {
|
||||||
|
return nil, fmt.Errorf("audita credential value and reference must be provided together")
|
||||||
|
}
|
||||||
for i, module := range cfg.Modules {
|
for i, module := range cfg.Modules {
|
||||||
if strings.TrimSpace(module) == "" {
|
if strings.TrimSpace(module) == "" {
|
||||||
return nil, fmt.Errorf("audita module at index %d is empty", i)
|
return nil, fmt.Errorf("audita module at index %d is empty", i)
|
||||||
@@ -152,7 +158,8 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error)
|
|||||||
return &SubprocessRunner{
|
return &SubprocessRunner{
|
||||||
binary: strings.TrimSpace(cfg.Binary),
|
binary: strings.TrimSpace(cfg.Binary),
|
||||||
timeout: cfg.Timeout,
|
timeout: cfg.Timeout,
|
||||||
llmAPIKeyEnv: strings.TrimSpace(cfg.LLMAPIKeyEnv),
|
llmAPIKey: cfg.LLMAPIKey,
|
||||||
|
llmAPIKeyReference: strings.TrimSpace(cfg.LLMAPIKeyReference),
|
||||||
modules: modules,
|
modules: modules,
|
||||||
baseURL: strings.TrimSpace(cfg.BaseURL),
|
baseURL: strings.TrimSpace(cfg.BaseURL),
|
||||||
model: strings.TrimSpace(cfg.Model),
|
model: strings.TrimSpace(cfg.Model),
|
||||||
@@ -195,16 +202,10 @@ func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishRe
|
|||||||
}
|
}
|
||||||
args := r.buildArgs(req, reqModules)
|
args := r.buildArgs(req, reqModules)
|
||||||
|
|
||||||
credentialEnvVar := strings.TrimSpace(r.llmAPIKeyEnv)
|
credentialPresent := r.llmAPIKeyReference != ""
|
||||||
credentialPresent := false
|
|
||||||
env := map[string]string{}
|
env := map[string]string{}
|
||||||
if credentialEnvVar != "" {
|
if credentialPresent {
|
||||||
credential, ok := os.LookupEnv(credentialEnvVar)
|
env["AUDITA_LLM_API_KEY"] = r.llmAPIKey
|
||||||
if !ok || strings.TrimSpace(credential) == "" {
|
|
||||||
return PolishResult{}, fmt.Errorf("audita: required credential environment variable %s is not set", credentialEnvVar)
|
|
||||||
}
|
|
||||||
env["AUDITA_LLM_API_KEY"] = credential
|
|
||||||
credentialPresent = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.GeneratedConfigPath != "" {
|
if req.GeneratedConfigPath != "" {
|
||||||
@@ -269,7 +270,7 @@ func (r *SubprocessRunner) Run(ctx context.Context, req PolishRequest) (PolishRe
|
|||||||
"total_llm_concurrency": r.totalLLMConcurrency,
|
"total_llm_concurrency": r.totalLLMConcurrency,
|
||||||
"proposal_llm_concurrency": r.proposalLLMConcurrency,
|
"proposal_llm_concurrency": r.proposalLLMConcurrency,
|
||||||
"validation_llm_concurrency": r.validationLLMConcurrency,
|
"validation_llm_concurrency": r.validationLLMConcurrency,
|
||||||
"credential_env_var": r.llmAPIKeyEnv,
|
"credential_env_var": r.llmAPIKeyReference,
|
||||||
"credential_present": credentialPresent,
|
"credential_present": credentialPresent,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
@@ -299,7 +300,7 @@ func (r *SubprocessRunner) failureResult(req PolishRequest, modules []string, ru
|
|||||||
"total_llm_concurrency": r.totalLLMConcurrency,
|
"total_llm_concurrency": r.totalLLMConcurrency,
|
||||||
"proposal_llm_concurrency": r.proposalLLMConcurrency,
|
"proposal_llm_concurrency": r.proposalLLMConcurrency,
|
||||||
"validation_llm_concurrency": r.validationLLMConcurrency,
|
"validation_llm_concurrency": r.validationLLMConcurrency,
|
||||||
"credential_env_var": r.llmAPIKeyEnv,
|
"credential_env_var": r.llmAPIKeyReference,
|
||||||
"credential_present": credentialPresent,
|
"credential_present": credentialPresent,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -375,10 +376,10 @@ func (r *SubprocessRunner) writeInvocationConfig(req PolishRequest, args []strin
|
|||||||
"output_path": req.OutputProcessedPath,
|
"output_path": req.OutputProcessedPath,
|
||||||
"report_path": req.ReportPath,
|
"report_path": req.ReportPath,
|
||||||
"work_dir": req.WorkDir,
|
"work_dir": req.WorkDir,
|
||||||
"credential_env_var": r.llmAPIKeyEnv,
|
"credential_env_var": r.llmAPIKeyReference,
|
||||||
"credential_present": credentialPresent,
|
"credential_present": credentialPresent,
|
||||||
}
|
}
|
||||||
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
|
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateProcessedOutput(path string) error {
|
func validateProcessedOutput(path string) error {
|
||||||
@@ -425,7 +426,7 @@ func validateJSONFile(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readAuditaResult(path string, limit int64, category string) ([]byte, error) {
|
func readAuditaResult(path string, limit int64, category string) ([]byte, error) {
|
||||||
data, err := fileops.ReadRegularFile(path, limit)
|
data, err := fileops.ReadFileBounded(path, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("audita %s result exceeds or cannot be read within %d-byte limit: %w", category, limit, err)
|
return nil, fmt.Errorf("audita %s result exceeds or cannot be read within %d-byte limit: %w", category, limit, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
|
func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "success")
|
t.Setenv("AUDITA_HELPER_MODE", "success")
|
||||||
@@ -31,7 +27,8 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
|
|||||||
runner, err := NewSubprocessRunner(SubprocessRunnerConfig{
|
runner, err := NewSubprocessRunner(SubprocessRunnerConfig{
|
||||||
Binary: wrapper,
|
Binary: wrapper,
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
|
LLMAPIKey: "super-secret",
|
||||||
|
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
|
||||||
Modules: []string{"glossary", "homophones", "glossary"},
|
Modules: []string{"glossary", "homophones", "glossary"},
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
@@ -48,6 +45,7 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewSubprocessRunner() error = %v", err)
|
t.Fatalf("NewSubprocessRunner() error = %v", err)
|
||||||
}
|
}
|
||||||
|
t.Setenv("OPENAI_KEY_SOURCE", "changed-after-resolution")
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
req := PolishRequest{
|
req := PolishRequest{
|
||||||
@@ -132,47 +130,31 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerMissingConfiguredCredentialFails(t *testing.T) {
|
func TestSubprocessRunnerRejectsIncompleteResolvedCredential(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
_, err := NewSubprocessRunner(SubprocessRunnerConfig{
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
}
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
LLMAPIKeyReference: "MISSING_AUDITA_KEY",
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
|
||||||
LLMAPIKeyEnv: "MISSING_AUDITA_KEY",
|
|
||||||
Modules: []string{"glossary"},
|
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
|
||||||
Report: false,
|
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, false)
|
if err == nil || !strings.Contains(err.Error(), "credential value and reference must be provided together") {
|
||||||
|
t.Fatalf("NewSubprocessRunner() error = %v, want credential pair error", err)
|
||||||
_, err := runner.Run(context.Background(), req)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "required credential environment variable MISSING_AUDITA_KEY is not set") {
|
|
||||||
t.Fatalf("error = %q, want missing credential guidance", err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerUnconfiguredCredentialEnvOmitsCredential(t *testing.T) {
|
func TestSubprocessRunnerUnconfiguredCredentialEnvOmitsCredential(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "success")
|
t.Setenv("AUDITA_HELPER_MODE", "success")
|
||||||
recordPath := filepath.Join(t.TempDir(), "record.json")
|
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "",
|
LLMAPIKey: "",
|
||||||
Modules: []string{"glossary"},
|
Modules: []string{"glossary"},
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
Report: false,
|
Report: false,
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, false)
|
req := auditaReqForTest(t, false)
|
||||||
|
|
||||||
@@ -190,9 +172,6 @@ func TestSubprocessRunnerUnconfiguredCredentialEnvOmitsCredential(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerOmitsUnspecifiedParentEnvironment(t *testing.T) {
|
func TestSubprocessRunnerOmitsUnspecifiedParentEnvironment(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "success")
|
t.Setenv("AUDITA_HELPER_MODE", "success")
|
||||||
t.Setenv("AUDITA_INHERITED_MARKER", "inherited-from-parent")
|
t.Setenv("AUDITA_INHERITED_MARKER", "inherited-from-parent")
|
||||||
@@ -200,13 +179,13 @@ func TestSubprocessRunnerOmitsUnspecifiedParentEnvironment(t *testing.T) {
|
|||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
Modules: []string{"glossary"},
|
Modules: []string{"glossary"},
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
Report: false,
|
Report: false,
|
||||||
LLMAPIKeyEnv: "",
|
LLMAPIKey: "",
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, false)
|
req := auditaReqForTest(t, false)
|
||||||
if _, err := runner.Run(context.Background(), req); err != nil {
|
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||||
@@ -220,21 +199,18 @@ func TestSubprocessRunnerOmitsUnspecifiedParentEnvironment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerOmitsModulesFlagWhenNotConfigured(t *testing.T) {
|
func TestSubprocessRunnerOmitsModulesFlagWhenNotConfigured(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "success")
|
t.Setenv("AUDITA_HELPER_MODE", "success")
|
||||||
recordPath := filepath.Join(t.TempDir(), "record.json")
|
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "",
|
LLMAPIKey: "",
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
Report: false,
|
Report: false,
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, false)
|
req := auditaReqForTest(t, false)
|
||||||
if _, err := runner.Run(context.Background(), req); err != nil {
|
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||||
@@ -249,19 +225,16 @@ func TestSubprocessRunnerOmitsModulesFlagWhenNotConfigured(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerOmitsBaseURLAndModelFlagsWhenNotConfigured(t *testing.T) {
|
func TestSubprocessRunnerOmitsBaseURLAndModelFlagsWhenNotConfigured(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "success")
|
t.Setenv("AUDITA_HELPER_MODE", "success")
|
||||||
recordPath := filepath.Join(t.TempDir(), "record.json")
|
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "",
|
LLMAPIKey: "",
|
||||||
Report: false,
|
Report: false,
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, false)
|
req := auditaReqForTest(t, false)
|
||||||
if _, err := runner.Run(context.Background(), req); err != nil {
|
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||||
@@ -279,22 +252,20 @@ func TestSubprocessRunnerOmitsBaseURLAndModelFlagsWhenNotConfigured(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
|
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "fail")
|
t.Setenv("AUDITA_HELPER_MODE", "fail")
|
||||||
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
|
LLMAPIKey: "super-secret",
|
||||||
Modules: []string{"glossary"},
|
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
Modules: []string{"glossary"},
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Report: true,
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
|
Report: true,
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, true)
|
req := auditaReqForTest(t, true)
|
||||||
_, err := runner.Run(context.Background(), req)
|
_, err := runner.Run(context.Background(), req)
|
||||||
@@ -310,25 +281,26 @@ func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
|
|||||||
if !strings.Contains(err.Error(), req.StdoutLogPath) || !strings.Contains(err.Error(), req.StderrLogPath) {
|
if !strings.Contains(err.Error(), req.StdoutLogPath) || !strings.Contains(err.Error(), req.StderrLogPath) {
|
||||||
t.Fatalf("error = %q, want stdout/stderr log paths", err.Error())
|
t.Fatalf("error = %q, want stdout/stderr log paths", err.Error())
|
||||||
}
|
}
|
||||||
|
if strings.Contains(err.Error(), "super-secret") {
|
||||||
|
t.Fatalf("failure diagnostic exposed credential value: %q", err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerSubprocessFailureAddsStderrDescriptorHint(t *testing.T) {
|
func TestSubprocessRunnerSubprocessFailureAddsStderrDescriptorHint(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "fail_bad_fd")
|
t.Setenv("AUDITA_HELPER_MODE", "fail_bad_fd")
|
||||||
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
|
LLMAPIKey: "super-secret",
|
||||||
Modules: []string{"glossary"},
|
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
Modules: []string{"glossary"},
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Report: true,
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
|
Report: true,
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, true)
|
req := auditaReqForTest(t, true)
|
||||||
_, err := runner.Run(context.Background(), req)
|
_, err := runner.Run(context.Background(), req)
|
||||||
@@ -341,22 +313,20 @@ func TestSubprocessRunnerSubprocessFailureAddsStderrDescriptorHint(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
|
func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "missing_output")
|
t.Setenv("AUDITA_HELPER_MODE", "missing_output")
|
||||||
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
|
LLMAPIKey: "super-secret",
|
||||||
Modules: []string{"glossary"},
|
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
Modules: []string{"glossary"},
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Report: false,
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
|
Report: false,
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, false)
|
req := auditaReqForTest(t, false)
|
||||||
_, err := runner.Run(context.Background(), req)
|
_, err := runner.Run(context.Background(), req)
|
||||||
@@ -369,22 +339,20 @@ func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
|
func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "invalid_output")
|
t.Setenv("AUDITA_HELPER_MODE", "invalid_output")
|
||||||
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
|
LLMAPIKey: "super-secret",
|
||||||
Modules: []string{"glossary"},
|
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
Modules: []string{"glossary"},
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Report: false,
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
|
Report: false,
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, false)
|
req := auditaReqForTest(t, false)
|
||||||
_, err := runner.Run(context.Background(), req)
|
_, err := runner.Run(context.Background(), req)
|
||||||
@@ -397,22 +365,20 @@ func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerSegmentsMissingFails(t *testing.T) {
|
func TestSubprocessRunnerSegmentsMissingFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "segments_missing")
|
t.Setenv("AUDITA_HELPER_MODE", "segments_missing")
|
||||||
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
|
LLMAPIKey: "super-secret",
|
||||||
Modules: []string{"glossary"},
|
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
Modules: []string{"glossary"},
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Report: false,
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
|
Report: false,
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, false)
|
req := auditaReqForTest(t, false)
|
||||||
_, err := runner.Run(context.Background(), req)
|
_, err := runner.Run(context.Background(), req)
|
||||||
@@ -425,22 +391,20 @@ func TestSubprocessRunnerSegmentsMissingFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
|
func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
t.Setenv("AUDITA_HELPER_MODE", "invalid_report")
|
t.Setenv("AUDITA_HELPER_MODE", "invalid_report")
|
||||||
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
t.Setenv("OPENAI_KEY_SOURCE", "super-secret")
|
||||||
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|
||||||
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
Binary: writeAuditaHelperWrapper(t),
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
Timeout: mustParseAuditaDuration(t, "2s"),
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
LLMAPIKeyEnv: "OPENAI_KEY_SOURCE",
|
LLMAPIKey: "super-secret",
|
||||||
Modules: []string{"glossary"},
|
LLMAPIKeyReference: "OPENAI_KEY_SOURCE",
|
||||||
BaseURL: "https://openrouter.ai/api/v1",
|
Modules: []string{"glossary"},
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
BaseURL: "https://openrouter.ai/api/v1",
|
||||||
Report: true,
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
|
Report: true,
|
||||||
})
|
})
|
||||||
req := auditaReqForTest(t, true)
|
req := auditaReqForTest(t, true)
|
||||||
_, err := runner.Run(context.Background(), req)
|
_, err := runner.Run(context.Background(), req)
|
||||||
@@ -453,21 +417,22 @@ func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerConstructorValidation(t *testing.T) {
|
func TestSubprocessRunnerConstructorValidation(t *testing.T) {
|
||||||
_, err := NewSubprocessRunnerFromConfigValues("", "3h", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
|
_, err := NewSubprocessRunnerFromConfigValues("", "3h", "synthetic-secret", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected binary validation error")
|
t.Fatal("expected binary validation error")
|
||||||
}
|
}
|
||||||
_, err = NewSubprocessRunnerFromConfigValues("audita", "bad", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
|
_, err = NewSubprocessRunnerFromConfigValues("audita", "bad", "synthetic-secret", "AUDITA_LLM_API_KEY", []string{"glossary"}, "https://openrouter.ai/api/v1", "openrouter/google/gemma-4-31b-it", "", "", "", "", nil, nil, "", nil, true)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected timeout parse error")
|
t.Fatal("expected timeout parse error")
|
||||||
}
|
}
|
||||||
_, err = NewSubprocessRunner(SubprocessRunnerConfig{
|
_, err = NewSubprocessRunner(SubprocessRunnerConfig{
|
||||||
Binary: "audita",
|
Binary: "audita",
|
||||||
Timeout: mustParseAuditaDuration(t, "1s"),
|
Timeout: mustParseAuditaDuration(t, "1s"),
|
||||||
LLMAPIKeyEnv: "AUDITA_LLM_API_KEY",
|
LLMAPIKey: "synthetic-secret",
|
||||||
Modules: []string{"glossary"},
|
LLMAPIKeyReference: "AUDITA_LLM_API_KEY",
|
||||||
BaseURL: "://",
|
Modules: []string{"glossary"},
|
||||||
Model: "openrouter/google/gemma-4-31b-it",
|
BaseURL: "://",
|
||||||
|
Model: "openrouter/google/gemma-4-31b-it",
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected base url validation error")
|
t.Fatal("expected base url validation error")
|
||||||
|
|||||||
@@ -671,7 +671,7 @@ func sumWarningOccurrences(values []WarningSummary) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeBoundedJSON(path string, limit int64, destination any) error {
|
func decodeBoundedJSON(path string, limit int64, destination any) error {
|
||||||
data, err := fileops.ReadRegularFile(path, limit)
|
data, err := fileops.ReadFileBounded(path, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("notarius JSON result exceeds or cannot be read within %d-byte limit: %w", limit, err)
|
return fmt.Errorf("notarius JSON result exceeds or cannot be read within %d-byte limit: %w", limit, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,7 +142,7 @@ func (f *FakeRunner) RenderArtifact(ctx context.Context, req RenderArtifactReque
|
|||||||
|
|
||||||
func materializeRunPlaceholders(req RunArtifactRequest) error {
|
func materializeRunPlaceholders(req RunArtifactRequest) error {
|
||||||
if req.OutputPath != "" {
|
if req.OutputPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("scriptorium noop/fake run artifact\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.OutputPath, []byte("scriptorium noop/fake run artifact\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write run output %q: %w", req.OutputPath, err)
|
return fmt.Errorf("write run output %q: %w", req.OutputPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,17 +154,17 @@ func materializeRunPlaceholders(req RunArtifactRequest) error {
|
|||||||
"prompt_id": req.PromptID,
|
"prompt_id": req.PromptID,
|
||||||
"output_path": req.OutputPath,
|
"output_path": req.OutputPath,
|
||||||
}
|
}
|
||||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
|
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
|
||||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StdoutLogPath != "" {
|
if req.StdoutLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake run stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake run stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StderrLogPath != "" {
|
if req.StderrLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake run stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake run stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,7 +173,7 @@ func materializeRunPlaceholders(req RunArtifactRequest) error {
|
|||||||
|
|
||||||
func materializeRenderPlaceholders(req RenderArtifactRequest) error {
|
func materializeRenderPlaceholders(req RenderArtifactRequest) error {
|
||||||
if req.OutputPath != "" {
|
if req.OutputPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.OutputPath, []byte("{\"schema\":\"scriptorium.render.v1\",\"placeholder\":true}\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.OutputPath, []byte("{\"schema\":\"scriptorium.render.v1\",\"placeholder\":true}\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write render output %q: %w", req.OutputPath, err)
|
return fmt.Errorf("write render output %q: %w", req.OutputPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,17 +185,17 @@ func materializeRenderPlaceholders(req RenderArtifactRequest) error {
|
|||||||
"prompt_id": req.PromptID,
|
"prompt_id": req.PromptID,
|
||||||
"output_path": req.OutputPath,
|
"output_path": req.OutputPath,
|
||||||
}
|
}
|
||||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
|
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
|
||||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StdoutLogPath != "" {
|
if req.StdoutLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("scriptorium noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StderrLogPath != "" {
|
if req.StderrLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("scriptorium noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
internal/adapters/scriptorium/generated_config.go
Normal file
19
internal/adapters/scriptorium/generated_config.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package scriptorium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeGeneratedYAML(path string, payload any) error {
|
||||||
|
data, err := yaml.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal generated YAML for %q: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
|
||||||
|
return fmt.Errorf("write generated YAML %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -342,11 +342,11 @@ func writeInvocationConfig(path string, payload invocationPayload) error {
|
|||||||
"render_format": payload.RenderFormat,
|
"render_format": payload.RenderFormat,
|
||||||
"render_prompt_logged": payload.RenderPromptStore,
|
"render_prompt_logged": payload.RenderPromptStore,
|
||||||
}
|
}
|
||||||
return subprocess.WriteYAMLAtomic(path, data, fileops.WorkspaceFileMode)
|
return writeGeneratedYAML(path, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateNonEmptyOutput(path string) error {
|
func validateNonEmptyOutput(path string) error {
|
||||||
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
|
data, err := fileops.ReadFileBounded(path, MaxOutputFileBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("scriptorium artifact output exceeds or cannot be read within %d-byte limit: %w", MaxOutputFileBytes, err)
|
return fmt.Errorf("scriptorium artifact output exceeds or cannot be read within %d-byte limit: %w", MaxOutputFileBytes, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *testing.T) {
|
func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success")
|
t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success")
|
||||||
@@ -95,9 +91,6 @@ func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *tes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerRunExitCodeOneFails(t *testing.T) {
|
func TestSubprocessRunnerRunExitCodeOneFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit1")
|
t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit1")
|
||||||
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -118,9 +111,6 @@ func TestSubprocessRunnerRunExitCodeOneFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerRunExitCodeTwoReturnsValidationFailureAndPreservesOutput(t *testing.T) {
|
func TestSubprocessRunnerRunExitCodeTwoReturnsValidationFailureAndPreservesOutput(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit2_with_output")
|
t.Setenv("SCRIPTORIUM_HELPER_MODE", "fail_exit2_with_output")
|
||||||
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -144,9 +134,6 @@ func TestSubprocessRunnerRunExitCodeTwoReturnsValidationFailureAndPreservesOutpu
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerRunMissingOutputOnSuccessFails(t *testing.T) {
|
func TestSubprocessRunnerRunMissingOutputOnSuccessFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success_missing_output")
|
t.Setenv("SCRIPTORIUM_HELPER_MODE", "run_success_missing_output")
|
||||||
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SCRIPTORIUM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -166,9 +153,6 @@ func TestSubprocessRunnerRunMissingOutputOnSuccessFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerRenderSuccess(t *testing.T) {
|
func TestSubprocessRunnerRenderSuccess(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
t.Setenv("GO_WANT_SCRIPTORIUM_HELPER", "1")
|
||||||
t.Setenv("SCRIPTORIUM_HELPER_MODE", "render_success")
|
t.Setenv("SCRIPTORIUM_HELPER_MODE", "render_success")
|
||||||
recordPath := filepath.Join(t.TempDir(), "record.json")
|
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -261,7 +260,7 @@ func (f *FakeRunner) Render(ctx context.Context, req RenderRequest) (RenderResul
|
|||||||
|
|
||||||
func materializePlaceholders(req MergeRequest) error {
|
func materializePlaceholders(req MergeRequest) error {
|
||||||
if req.OutputMergedTranscriptPath != "" {
|
if req.OutputMergedTranscriptPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write merged transcript %q: %w", req.OutputMergedTranscriptPath, err)
|
return fmt.Errorf("write merged transcript %q: %w", req.OutputMergedTranscriptPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,22 +271,22 @@ func materializePlaceholders(req MergeRequest) error {
|
|||||||
"input_transcript_paths": req.InputTranscriptPaths,
|
"input_transcript_paths": req.InputTranscriptPaths,
|
||||||
"output_path": req.OutputMergedTranscriptPath,
|
"output_path": req.OutputMergedTranscriptPath,
|
||||||
}
|
}
|
||||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
|
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
|
||||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StdoutLogPath != "" {
|
if req.StdoutLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StderrLogPath != "" {
|
if req.StderrLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.ReportPath != "" {
|
if req.ReportPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write report %q: %w", req.ReportPath, err)
|
return fmt.Errorf("write report %q: %w", req.ReportPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +295,7 @@ func materializePlaceholders(req MergeRequest) error {
|
|||||||
|
|
||||||
func materializeTrimPlaceholders(req TrimRequest) error {
|
func materializeTrimPlaceholders(req TrimRequest) error {
|
||||||
if req.OutputTrimmedPath != "" {
|
if req.OutputTrimmedPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.OutputTrimmedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.OutputTrimmedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write trimmed transcript %q: %w", req.OutputTrimmedPath, err)
|
return fmt.Errorf("write trimmed transcript %q: %w", req.OutputTrimmedPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,17 +308,17 @@ func materializeTrimPlaceholders(req TrimRequest) error {
|
|||||||
"output_path": req.OutputTrimmedPath,
|
"output_path": req.OutputTrimmedPath,
|
||||||
"keep_selector": req.KeepSelector,
|
"keep_selector": req.KeepSelector,
|
||||||
}
|
}
|
||||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
|
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
|
||||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StdoutLogPath != "" {
|
if req.StdoutLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake trim stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake trim stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StderrLogPath != "" {
|
if req.StderrLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake trim stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake trim stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,7 +327,7 @@ func materializeTrimPlaceholders(req TrimRequest) error {
|
|||||||
|
|
||||||
func materializeNormalizePlaceholders(req NormalizeRequest) error {
|
func materializeNormalizePlaceholders(req NormalizeRequest) error {
|
||||||
if req.OutputNormalizedPath != "" {
|
if req.OutputNormalizedPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.OutputNormalizedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.OutputNormalizedPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write normalized transcript %q: %w", req.OutputNormalizedPath, err)
|
return fmt.Errorf("write normalized transcript %q: %w", req.OutputNormalizedPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,22 +343,22 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
|
|||||||
if req.ReportPath != "" {
|
if req.ReportPath != "" {
|
||||||
payload["report_path"] = req.ReportPath
|
payload["report_path"] = req.ReportPath
|
||||||
}
|
}
|
||||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
|
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
|
||||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StdoutLogPath != "" {
|
if req.StdoutLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake normalize stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake normalize stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StderrLogPath != "" {
|
if req.StderrLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake normalize stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake normalize stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.ReportPath != "" {
|
if req.ReportPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.ReportPath, []byte(`{"schema":"seriatim.report.v1","placeholder":true}`), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write report %q: %w", req.ReportPath, err)
|
return fmt.Errorf("write report %q: %w", req.ReportPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +367,7 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
|
|||||||
|
|
||||||
func materializeRenderPlaceholders(req RenderRequest) error {
|
func materializeRenderPlaceholders(req RenderRequest) error {
|
||||||
if req.OutputRenderedPath != "" {
|
if req.OutputRenderedPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write rendered transcript %q: %w", req.OutputRenderedPath, err)
|
return fmt.Errorf("write rendered transcript %q: %w", req.OutputRenderedPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,17 +384,17 @@ func materializeRenderPlaceholders(req RenderRequest) error {
|
|||||||
"include_segment_ids": req.IncludeSegmentIDs,
|
"include_segment_ids": req.IncludeSegmentIDs,
|
||||||
"include_metadata": req.IncludeMetadata,
|
"include_metadata": req.IncludeMetadata,
|
||||||
}
|
}
|
||||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode); err != nil {
|
if err := writeGeneratedYAML(req.GeneratedConfigPath, payload); err != nil {
|
||||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StdoutLogPath != "" {
|
if req.StdoutLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.StderrLogPath != "" {
|
if req.StderrLogPath != "" {
|
||||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
if err := fileops.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), fileops.WorkspaceFileMode); err != nil {
|
||||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
internal/adapters/seriatim/generated_config.go
Normal file
19
internal/adapters/seriatim/generated_config.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package seriatim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeGeneratedYAML(path string, payload any) error {
|
||||||
|
data, err := yaml.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal generated YAML for %q: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := fileops.WriteFileAtomic(path, data, fileops.WorkspaceFileMode); err != nil {
|
||||||
|
return fmt.Errorf("write generated YAML %q: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -553,7 +553,7 @@ func (r *SubprocessRunner) writeMergeInvocationConfig(req MergeRequest, args []s
|
|||||||
payload["coalesce_gap"] = *r.coalesceGap
|
payload["coalesce_gap"] = *r.coalesceGap
|
||||||
}
|
}
|
||||||
|
|
||||||
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
|
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildTrimArgs(req TrimRequest) []string {
|
func buildTrimArgs(req TrimRequest) []string {
|
||||||
@@ -605,7 +605,7 @@ func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, ti
|
|||||||
"output_path": req.OutputTrimmedPath,
|
"output_path": req.OutputTrimmedPath,
|
||||||
"keep_selector": req.KeepSelector,
|
"keep_selector": req.KeepSelector,
|
||||||
}
|
}
|
||||||
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
|
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary string, timeout time.Duration, outputSchema string) error {
|
func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary string, timeout time.Duration, outputSchema string) error {
|
||||||
@@ -620,7 +620,7 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary
|
|||||||
"output_schema": outputSchema,
|
"output_schema": outputSchema,
|
||||||
"report_path": req.ReportPath,
|
"report_path": req.ReportPath,
|
||||||
}
|
}
|
||||||
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
|
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeRenderInvocationConfig(req RenderRequest, args []string, binary string, timeout time.Duration, format string) error {
|
func writeRenderInvocationConfig(req RenderRequest, args []string, binary string, timeout time.Duration, format string) error {
|
||||||
@@ -638,7 +638,7 @@ func writeRenderInvocationConfig(req RenderRequest, args []string, binary string
|
|||||||
"include_segment_ids": req.IncludeSegmentIDs,
|
"include_segment_ids": req.IncludeSegmentIDs,
|
||||||
"include_metadata": req.IncludeMetadata,
|
"include_metadata": req.IncludeMetadata,
|
||||||
}
|
}
|
||||||
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, fileops.WorkspaceFileMode)
|
return writeGeneratedYAML(req.GeneratedConfigPath, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateJSONFile(path string) error {
|
func validateJSONFile(path string) error {
|
||||||
@@ -692,7 +692,7 @@ func validateNonEmptyTextFile(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readSeriatimResult(path, category string) ([]byte, error) {
|
func readSeriatimResult(path, category string) ([]byte, error) {
|
||||||
data, err := fileops.ReadRegularFile(path, MaxOutputFileBytes)
|
data, err := fileops.ReadFileBounded(path, MaxOutputFileBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("seriatim %s exceeds or cannot be read within %d-byte limit: %w", category, MaxOutputFileBytes, err)
|
return nil, fmt.Errorf("seriatim %s exceeds or cannot be read within %d-byte limit: %w", category, MaxOutputFileBytes, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSubprocessRunnerSuccessWithReportArgsAndEnv(t *testing.T) {
|
func TestSubprocessRunnerSuccessWithReportArgsAndEnv(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "success")
|
t.Setenv("SERIATIM_HELPER_MODE", "success")
|
||||||
@@ -131,9 +127,6 @@ func TestSubprocessRunnerSuccessWithReportArgsAndEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
|
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -153,9 +146,6 @@ func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
|
func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
|
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -172,9 +162,6 @@ func TestSubprocessRunnerMissingOutputFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
|
func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "invalid_output")
|
t.Setenv("SERIATIM_HELPER_MODE", "invalid_output")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -191,9 +178,6 @@ func TestSubprocessRunnerInvalidOutputJSONFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
|
func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "invalid_report")
|
t.Setenv("SERIATIM_HELPER_MODE", "invalid_report")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -210,9 +194,6 @@ func TestSubprocessRunnerInvalidReportJSONFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerTrimSuccessInvocationAndProvenance(t *testing.T) {
|
func TestSubprocessRunnerTrimSuccessInvocationAndProvenance(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "trim_success")
|
t.Setenv("SERIATIM_HELPER_MODE", "trim_success")
|
||||||
@@ -297,9 +278,6 @@ func TestSubprocessRunnerTrimSuccessInvocationAndProvenance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerTrimSubprocessFailure(t *testing.T) {
|
func TestSubprocessRunnerTrimSubprocessFailure(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -319,9 +297,6 @@ func TestSubprocessRunnerTrimSubprocessFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerTrimMissingOutputFails(t *testing.T) {
|
func TestSubprocessRunnerTrimMissingOutputFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
|
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -338,9 +313,6 @@ func TestSubprocessRunnerTrimMissingOutputFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerTrimInvalidOutputJSONFails(t *testing.T) {
|
func TestSubprocessRunnerTrimInvalidOutputJSONFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "invalid_output")
|
t.Setenv("SERIATIM_HELPER_MODE", "invalid_output")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -357,9 +329,6 @@ func TestSubprocessRunnerTrimInvalidOutputJSONFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerTrimOutputMissingSegmentsFails(t *testing.T) {
|
func TestSubprocessRunnerTrimOutputMissingSegmentsFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "trim_missing_segments")
|
t.Setenv("SERIATIM_HELPER_MODE", "trim_missing_segments")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -376,9 +345,6 @@ func TestSubprocessRunnerTrimOutputMissingSegmentsFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerNormalizeSuccessInvocationAndProvenance(t *testing.T) {
|
func TestSubprocessRunnerNormalizeSuccessInvocationAndProvenance(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "normalize_success")
|
t.Setenv("SERIATIM_HELPER_MODE", "normalize_success")
|
||||||
@@ -453,9 +419,6 @@ func TestSubprocessRunnerNormalizeSuccessInvocationAndProvenance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerNormalizeSubprocessFailure(t *testing.T) {
|
func TestSubprocessRunnerNormalizeSubprocessFailure(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -475,9 +438,6 @@ func TestSubprocessRunnerNormalizeSubprocessFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerNormalizeMissingOutputFails(t *testing.T) {
|
func TestSubprocessRunnerNormalizeMissingOutputFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "normalize_missing_output")
|
t.Setenv("SERIATIM_HELPER_MODE", "normalize_missing_output")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -494,9 +454,6 @@ func TestSubprocessRunnerNormalizeMissingOutputFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerNormalizeInvalidOutputJSONFails(t *testing.T) {
|
func TestSubprocessRunnerNormalizeInvalidOutputJSONFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "normalize_invalid_output")
|
t.Setenv("SERIATIM_HELPER_MODE", "normalize_invalid_output")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -513,9 +470,6 @@ func TestSubprocessRunnerNormalizeInvalidOutputJSONFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerNormalizeOutputMissingSegmentsFails(t *testing.T) {
|
func TestSubprocessRunnerNormalizeOutputMissingSegmentsFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "normalize_missing_segments")
|
t.Setenv("SERIATIM_HELPER_MODE", "normalize_missing_segments")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -532,9 +486,6 @@ func TestSubprocessRunnerNormalizeOutputMissingSegmentsFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerNormalizeReportMissingFails(t *testing.T) {
|
func TestSubprocessRunnerNormalizeReportMissingFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "normalize_report_missing")
|
t.Setenv("SERIATIM_HELPER_MODE", "normalize_report_missing")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -551,9 +502,6 @@ func TestSubprocessRunnerNormalizeReportMissingFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) {
|
func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "normalize_invalid_report")
|
t.Setenv("SERIATIM_HELPER_MODE", "normalize_invalid_report")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -570,9 +518,6 @@ func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) {
|
func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
|
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
|
||||||
@@ -639,9 +584,6 @@ func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) {
|
func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
|
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
|
||||||
recordPath := filepath.Join(t.TempDir(), "record.json")
|
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||||
@@ -663,9 +605,6 @@ func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) {
|
func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -682,9 +621,6 @@ func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) {
|
func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
|
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
@@ -701,9 +637,6 @@ func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerRenderEmptyOutputFails(t *testing.T) {
|
func TestSubprocessRunnerRenderEmptyOutputFails(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||||
t.Setenv("SERIATIM_HELPER_MODE", "render_empty_output")
|
t.Setenv("SERIATIM_HELPER_MODE", "render_empty_output")
|
||||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||||
|
|||||||
@@ -19,14 +19,17 @@ import (
|
|||||||
type FakeBackend struct {
|
type FakeBackend struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
Objects map[string]FakeObject
|
Objects map[string]FakeObject
|
||||||
Uploads []FakeUploadCall
|
Uploads []FakeUploadCall
|
||||||
Downloads []FakeDownloadCall
|
Downloads []FakeDownloadCall
|
||||||
Reads []FakeReadCall
|
Reads []FakeReadCall
|
||||||
|
Deletes []string
|
||||||
|
ExistsKeys []string
|
||||||
|
|
||||||
ListErr error
|
ListErr error
|
||||||
DownloadErr error
|
DownloadErr error
|
||||||
UploadErr error
|
UploadErr error
|
||||||
|
DeleteErr error
|
||||||
ExistsErr error
|
ExistsErr error
|
||||||
UploadHook func(FakeUploadCall) error
|
UploadHook func(FakeUploadCall) error
|
||||||
DownloadHook func(FakeDownloadCall) error
|
DownloadHook func(FakeDownloadCall) error
|
||||||
@@ -315,6 +318,25 @@ func fakeObjectETag(data []byte) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes one object. Removing an absent object succeeds.
|
||||||
|
func (f *FakeBackend) Delete(ctx context.Context, key string) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.DeleteErr != nil {
|
||||||
|
return f.DeleteErr
|
||||||
|
}
|
||||||
|
normalizedKey := normalizeObjectKey(key)
|
||||||
|
if normalizedKey == "" {
|
||||||
|
return fmt.Errorf("delete object: key is required")
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.Deletes = append(f.Deletes, normalizedKey)
|
||||||
|
delete(f.Objects, normalizedKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Exists checks object presence.
|
// Exists checks object presence.
|
||||||
func (f *FakeBackend) Exists(ctx context.Context, key string) (bool, error) {
|
func (f *FakeBackend) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
@@ -323,8 +345,12 @@ func (f *FakeBackend) Exists(ctx context.Context, key string) (bool, error) {
|
|||||||
if f.ExistsErr != nil {
|
if f.ExistsErr != nil {
|
||||||
return false, f.ExistsErr
|
return false, f.ExistsErr
|
||||||
}
|
}
|
||||||
|
normalizedKey := normalizeObjectKey(key)
|
||||||
|
f.mu.Lock()
|
||||||
|
f.ExistsKeys = append(f.ExistsKeys, normalizedKey)
|
||||||
|
f.mu.Unlock()
|
||||||
f.mu.RLock()
|
f.mu.RLock()
|
||||||
_, ok := f.Objects[normalizeObjectKey(key)]
|
_, ok := f.Objects[normalizedKey]
|
||||||
f.mu.RUnlock()
|
f.mu.RUnlock()
|
||||||
return ok, nil
|
return ok, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,23 +71,55 @@ func TestFakeBackendUploadAndExists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFakeBackendDeleteIsIdempotent(t *testing.T) {
|
||||||
|
fake := &FakeBackend{}
|
||||||
|
fake.SeedObject(FakeObject{Key: "locks/source.lock", Data: []byte("lock")})
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
if err := fake.Delete(context.Background(), `locks\source.lock`); err != nil {
|
||||||
|
t.Fatalf("Delete() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := fake.Objects["locks/source.lock"]; ok {
|
||||||
|
t.Fatal("Delete() left the object present")
|
||||||
|
}
|
||||||
|
if len(fake.Deletes) != 2 || fake.Deletes[0] != "locks/source.lock" || fake.Deletes[1] != "locks/source.lock" {
|
||||||
|
t.Fatalf("Delete() calls = %#v, want two normalized keys", fake.Deletes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeBackendDeleteHonorsContextAndInjectedError(t *testing.T) {
|
||||||
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
fake := &FakeBackend{}
|
||||||
|
if err := fake.Delete(canceled, "locks/source.lock"); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Delete() canceled error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantErr := errors.New("delete fail")
|
||||||
|
fake.DeleteErr = wantErr
|
||||||
|
if err := fake.Delete(context.Background(), "locks/source.lock"); !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("Delete() injected error = %v, want %v", err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFakeBackendConditionalUploadRejectsStaleGeneration(t *testing.T) {
|
func TestFakeBackendConditionalUploadRejectsStaleGeneration(t *testing.T) {
|
||||||
fake := &FakeBackend{}
|
fake := &FakeBackend{}
|
||||||
fake.SeedObject(FakeObject{Key: "locks.yml", Data: []byte("old")})
|
fake.SeedObject(FakeObject{Key: "control.json", Data: []byte("old")})
|
||||||
old := fake.Objects["locks.yml"].ETag
|
old := fake.Objects["control.json"].ETag
|
||||||
if _, err := fake.UploadConditional(context.Background(), strings.NewReader("new"), "locks.yml", UploadOptions{}, WriteCondition{MatchETag: old}); err != nil {
|
if _, err := fake.UploadConditional(context.Background(), strings.NewReader("new"), "control.json", UploadOptions{}, WriteCondition{MatchETag: old}); err != nil {
|
||||||
t.Fatalf("UploadConditional() error = %v", err)
|
t.Fatalf("UploadConditional() error = %v", err)
|
||||||
}
|
}
|
||||||
if _, err := fake.UploadConditional(context.Background(), strings.NewReader("lost"), "locks.yml", UploadOptions{}, WriteCondition{MatchETag: old}); !errors.Is(err, ErrConditionNotMet) {
|
if _, err := fake.UploadConditional(context.Background(), strings.NewReader("lost"), "control.json", UploadOptions{}, WriteCondition{MatchETag: old}); !errors.Is(err, ErrConditionNotMet) {
|
||||||
t.Fatalf("UploadConditional() error = %v, want ErrConditionNotMet", err)
|
t.Fatalf("UploadConditional() error = %v, want ErrConditionNotMet", err)
|
||||||
}
|
}
|
||||||
if got := string(fake.Objects["locks.yml"].Data); got != "new" {
|
if got := string(fake.Objects["control.json"].Data); got != "new" {
|
||||||
t.Fatalf("locks object = %q, want successful replacement preserved", got)
|
t.Fatalf("control object = %q, want successful replacement preserved", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFakeBackendObjectErrors(t *testing.T) {
|
func TestFakeBackendObjectErrors(t *testing.T) {
|
||||||
fake := &FakeBackend{DownloadErr: errors.New("download fail"), UploadErr: errors.New("upload fail"), ListErr: errors.New("list fail"), ExistsErr: errors.New("exists fail")}
|
fake := &FakeBackend{DownloadErr: errors.New("download fail"), UploadErr: errors.New("upload fail"), ListErr: errors.New("list fail"), DeleteErr: errors.New("delete fail"), ExistsErr: errors.New("exists fail")}
|
||||||
|
|
||||||
if _, err := fake.List(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "list fail") {
|
if _, err := fake.List(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "list fail") {
|
||||||
t.Fatalf("List() error = %v, want list fail", err)
|
t.Fatalf("List() error = %v, want list fail", err)
|
||||||
@@ -100,6 +132,9 @@ func TestFakeBackendObjectErrors(t *testing.T) {
|
|||||||
if _, err := fake.Upload(context.Background(), local, "x", UploadOptions{}); err == nil || !strings.Contains(err.Error(), "upload fail") {
|
if _, err := fake.Upload(context.Background(), local, "x", UploadOptions{}); err == nil || !strings.Contains(err.Error(), "upload fail") {
|
||||||
t.Fatalf("Upload() error = %v, want upload fail", err)
|
t.Fatalf("Upload() error = %v, want upload fail", err)
|
||||||
}
|
}
|
||||||
|
if err := fake.Delete(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "delete fail") {
|
||||||
|
t.Fatalf("Delete() error = %v, want delete fail", err)
|
||||||
|
}
|
||||||
if _, err := fake.Exists(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "exists fail") {
|
if _, err := fake.Exists(context.Background(), "x"); err == nil || !strings.Contains(err.Error(), "exists fail") {
|
||||||
t.Fatalf("Exists() error = %v, want exists fail", err)
|
t.Fatalf("Exists() error = %v, want exists fail", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type ObjectStore interface {
|
|||||||
Download(ctx context.Context, key, localPath string) error
|
Download(ctx context.Context, key, localPath string) error
|
||||||
Upload(ctx context.Context, localPath, key string, opts UploadOptions) (ObjectInfo, error)
|
Upload(ctx context.Context, localPath, key string, opts UploadOptions) (ObjectInfo, error)
|
||||||
UploadConditional(ctx context.Context, source io.Reader, key string, opts UploadOptions, condition WriteCondition) (ObjectInfo, error)
|
UploadConditional(ctx context.Context, source io.Reader, key string, opts UploadOptions, condition WriteCondition) (ObjectInfo, error)
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
Exists(ctx context.Context, key string) (bool, error)
|
Exists(ctx context.Context, key string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type s3API interface {
|
|||||||
ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
|
ListObjectsV2(ctx context.Context, params *s3.ListObjectsV2Input, optFns ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
|
||||||
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
|
GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error)
|
||||||
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
|
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
|
||||||
|
DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error)
|
||||||
HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
|
HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +37,12 @@ type s3ClientOptions struct {
|
|||||||
Region string
|
Region string
|
||||||
Endpoint string
|
Endpoint string
|
||||||
ForcePathStyle bool
|
ForcePathStyle bool
|
||||||
AccessKeyID string
|
Credentials *s3StaticCredentials
|
||||||
SecretKey string
|
}
|
||||||
|
|
||||||
|
type s3StaticCredentials struct {
|
||||||
|
AccessKeyID string
|
||||||
|
SecretAccessKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
var newS3Client = func(ctx context.Context, opts s3ClientOptions) (s3API, error) {
|
var newS3Client = func(ctx context.Context, opts s3ClientOptions) (s3API, error) {
|
||||||
@@ -45,11 +50,11 @@ var newS3Client = func(ctx context.Context, opts s3ClientOptions) (s3API, error)
|
|||||||
if strings.TrimSpace(opts.Region) != "" {
|
if strings.TrimSpace(opts.Region) != "" {
|
||||||
loadOpts = append(loadOpts, awsconfig.WithRegion(strings.TrimSpace(opts.Region)))
|
loadOpts = append(loadOpts, awsconfig.WithRegion(strings.TrimSpace(opts.Region)))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(opts.AccessKeyID) != "" && strings.TrimSpace(opts.SecretKey) != "" {
|
if opts.Credentials != nil {
|
||||||
loadOpts = append(loadOpts, awsconfig.WithCredentialsProvider(
|
loadOpts = append(loadOpts, awsconfig.WithCredentialsProvider(
|
||||||
credentials.NewStaticCredentialsProvider(
|
credentials.NewStaticCredentialsProvider(
|
||||||
strings.TrimSpace(opts.AccessKeyID),
|
opts.Credentials.AccessKeyID,
|
||||||
strings.TrimSpace(opts.SecretKey),
|
opts.Credentials.SecretAccessKey,
|
||||||
"",
|
"",
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
@@ -74,14 +79,24 @@ func NewS3BackendFromConfig(ctx context.Context, cfg config.StorageS3Config) (*S
|
|||||||
if bucket == "" {
|
if bucket == "" {
|
||||||
return nil, fmt.Errorf("storage.s3.bucket is required")
|
return nil, fmt.Errorf("storage.s3.bucket is required")
|
||||||
}
|
}
|
||||||
|
if (cfg.AccessKeyID == nil) != (cfg.SecretAccessKey == nil) {
|
||||||
|
return nil, fmt.Errorf("storage.s3 access key ID and secret access key references must be configured together")
|
||||||
|
}
|
||||||
|
if cfg.AccessKeyID != nil && (!cfg.AccessKeyID.IsResolved() || !cfg.SecretAccessKey.IsResolved()) {
|
||||||
|
return nil, fmt.Errorf("storage.s3 configured credential references must be resolved")
|
||||||
|
}
|
||||||
|
|
||||||
client, err := newS3Client(ctx, s3ClientOptions{
|
clientOptions := s3ClientOptions{
|
||||||
Region: cfg.Region,
|
Region: cfg.Region,
|
||||||
Endpoint: cfg.Endpoint,
|
Endpoint: cfg.Endpoint,
|
||||||
ForcePathStyle: cfg.ForcePathStyle,
|
ForcePathStyle: cfg.ForcePathStyle,
|
||||||
AccessKeyID: s3CredentialFromEnv(orDefaultEnvName(cfg.AccessKeyIDEnv, config.DefaultS3AccessKeyIDEnv)),
|
}
|
||||||
SecretKey: s3CredentialFromEnv(orDefaultEnvName(cfg.SecretKeyEnv, config.DefaultS3SecretAccessKeyEnv)),
|
if cfg.AccessKeyID != nil && cfg.SecretAccessKey != nil {
|
||||||
})
|
clientOptions.Credentials = &s3StaticCredentials{
|
||||||
|
AccessKeyID: cfg.AccessKeyID.Value(), SecretAccessKey: cfg.SecretAccessKey.Value(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
client, err := newS3Client(ctx, clientOptions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build s3 client: %w", err)
|
return nil, fmt.Errorf("build s3 client: %w", err)
|
||||||
}
|
}
|
||||||
@@ -92,26 +107,6 @@ func NewS3BackendFromConfig(ctx context.Context, cfg config.StorageS3Config) (*S
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func s3CredentialFromEnv(envVarName string) string {
|
|
||||||
name := strings.TrimSpace(envVarName)
|
|
||||||
if name == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
value, ok := os.LookupEnv(name)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func orDefaultEnvName(name, fallback string) string {
|
|
||||||
trimmed := strings.TrimSpace(name)
|
|
||||||
if trimmed == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns objects under prefix.
|
// List returns objects under prefix.
|
||||||
func (b *S3Backend) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
|
func (b *S3Backend) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
|
||||||
normalizedPrefix := normalizeObjectKey(prefix)
|
normalizedPrefix := normalizeObjectKey(prefix)
|
||||||
@@ -300,6 +295,25 @@ func (b *S3Backend) uploadReader(ctx context.Context, source io.Reader, key stri
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes one object. S3 deletion is idempotent when the key is absent.
|
||||||
|
func (b *S3Backend) Delete(ctx context.Context, key string) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
normalizedKey := normalizeObjectKey(key)
|
||||||
|
if normalizedKey == "" {
|
||||||
|
return fmt.Errorf("delete object: key is required")
|
||||||
|
}
|
||||||
|
_, err := b.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: &b.bucket,
|
||||||
|
Key: &normalizedKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete object %q: %w", normalizedKey, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Exists checks whether one object key exists.
|
// Exists checks whether one object key exists.
|
||||||
func (b *S3Backend) Exists(ctx context.Context, key string) (bool, error) {
|
func (b *S3Backend) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
normalizedKey := normalizeObjectKey(key)
|
normalizedKey := normalizeObjectKey(key)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||||
"github.com/aws/smithy-go"
|
"github.com/aws/smithy-go"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,16 +30,18 @@ type fakeS3API struct {
|
|||||||
getETag *string
|
getETag *string
|
||||||
getLastModified *time.Time
|
getLastModified *time.Time
|
||||||
|
|
||||||
putOut *s3.PutObjectOutput
|
putOut *s3.PutObjectOutput
|
||||||
putErr error
|
putErr error
|
||||||
|
deleteErr error
|
||||||
|
|
||||||
headErr error
|
headErr error
|
||||||
|
|
||||||
lastList *s3.ListObjectsV2Input
|
lastList *s3.ListObjectsV2Input
|
||||||
lastLists []*s3.ListObjectsV2Input
|
lastLists []*s3.ListObjectsV2Input
|
||||||
lastGet *s3.GetObjectInput
|
lastGet *s3.GetObjectInput
|
||||||
lastPut *s3.PutObjectInput
|
lastPut *s3.PutObjectInput
|
||||||
lastHead *s3.HeadObjectInput
|
lastDelete *s3.DeleteObjectInput
|
||||||
|
lastHead *s3.HeadObjectInput
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeS3API) ListObjectsV2(_ context.Context, params *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
|
func (f *fakeS3API) ListObjectsV2(_ context.Context, params *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
|
||||||
@@ -116,6 +119,14 @@ func (f *fakeS3API) PutObject(_ context.Context, params *s3.PutObjectInput, _ ..
|
|||||||
return f.putOut, nil
|
return f.putOut, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeS3API) DeleteObject(_ context.Context, params *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) {
|
||||||
|
f.lastDelete = params
|
||||||
|
if f.deleteErr != nil {
|
||||||
|
return nil, f.deleteErr
|
||||||
|
}
|
||||||
|
return &s3.DeleteObjectOutput{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeS3API) HeadObject(_ context.Context, params *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
|
func (f *fakeS3API) HeadObject(_ context.Context, params *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
|
||||||
f.lastHead = params
|
f.lastHead = params
|
||||||
if f.headErr != nil {
|
if f.headErr != nil {
|
||||||
@@ -177,14 +188,14 @@ func TestS3BackendDownloadCreatesParentDirectory(t *testing.T) {
|
|||||||
func TestS3BackendReadReturnsOpenedObjectMetadata(t *testing.T) {
|
func TestS3BackendReadReturnsOpenedObjectMetadata(t *testing.T) {
|
||||||
lastModified := time.Date(2026, 8, 11, 1, 2, 3, 0, time.UTC)
|
lastModified := time.Date(2026, 8, 11, 1, 2, 3, 0, time.UTC)
|
||||||
client := &fakeS3API{
|
client := &fakeS3API{
|
||||||
getBody: io.NopCloser(strings.NewReader("locks")),
|
getBody: io.NopCloser(strings.NewReader("state")),
|
||||||
getSize: int64Ptr(5),
|
getSize: int64Ptr(5),
|
||||||
getETag: strPtr(`"generation"`),
|
getETag: strPtr(`"generation"`),
|
||||||
getLastModified: &lastModified,
|
getLastModified: &lastModified,
|
||||||
}
|
}
|
||||||
backend := &S3Backend{bucket: "bucket-1", client: client}
|
backend := &S3Backend{bucket: "bucket-1", client: client}
|
||||||
|
|
||||||
info, body, err := backend.Read(context.Background(), `sessions\locks.yml`)
|
info, body, err := backend.Read(context.Background(), `sessions\control.json`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Read() error = %v", err)
|
t.Fatalf("Read() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -193,10 +204,10 @@ func TestS3BackendReadReturnsOpenedObjectMetadata(t *testing.T) {
|
|||||||
if readErr != nil || closeErr != nil {
|
if readErr != nil || closeErr != nil {
|
||||||
t.Fatalf("read body error=%v close error=%v", readErr, closeErr)
|
t.Fatalf("read body error=%v close error=%v", readErr, closeErr)
|
||||||
}
|
}
|
||||||
if info.Key != "sessions/locks.yml" || info.Size != 5 || info.ETag != "generation" || info.LastModified == nil || !info.LastModified.Equal(lastModified) {
|
if info.Key != "sessions/control.json" || info.Size != 5 || info.ETag != "generation" || info.LastModified == nil || !info.LastModified.Equal(lastModified) {
|
||||||
t.Fatalf("Read() info = %#v, want opened object metadata", info)
|
t.Fatalf("Read() info = %#v, want opened object metadata", info)
|
||||||
}
|
}
|
||||||
if string(data) != "locks" || client.lastGet == nil || *client.lastGet.Key != "sessions/locks.yml" {
|
if string(data) != "state" || client.lastGet == nil || *client.lastGet.Key != "sessions/control.json" {
|
||||||
t.Fatalf("Read() data=%q request=%#v", data, client.lastGet)
|
t.Fatalf("Read() data=%q request=%#v", data, client.lastGet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,14 +249,14 @@ func TestS3BackendUploadAndExists(t *testing.T) {
|
|||||||
func TestS3BackendConditionalUploadUsesProviderPrecondition(t *testing.T) {
|
func TestS3BackendConditionalUploadUsesProviderPrecondition(t *testing.T) {
|
||||||
client := &fakeS3API{putOut: &s3.PutObjectOutput{ETag: strPtr(`"etag123"`)}}
|
client := &fakeS3API{putOut: &s3.PutObjectOutput{ETag: strPtr(`"etag123"`)}}
|
||||||
backend := &S3Backend{bucket: "bucket-1", client: client}
|
backend := &S3Backend{bucket: "bucket-1", client: client}
|
||||||
if _, err := backend.UploadConditional(context.Background(), strings.NewReader("payload"), "locks.yml", UploadOptions{}, WriteCondition{MatchETag: "before"}); err != nil {
|
if _, err := backend.UploadConditional(context.Background(), strings.NewReader("payload"), "control.json", UploadOptions{}, WriteCondition{MatchETag: "before"}); err != nil {
|
||||||
t.Fatalf("UploadConditional() error = %v", err)
|
t.Fatalf("UploadConditional() error = %v", err)
|
||||||
}
|
}
|
||||||
if client.lastPut == nil || client.lastPut.IfMatch == nil || *client.lastPut.IfMatch != "before" || client.lastPut.IfNoneMatch != nil {
|
if client.lastPut == nil || client.lastPut.IfMatch == nil || *client.lastPut.IfMatch != "before" || client.lastPut.IfNoneMatch != nil {
|
||||||
t.Fatalf("PutObject conditional input = %#v", client.lastPut)
|
t.Fatalf("PutObject conditional input = %#v", client.lastPut)
|
||||||
}
|
}
|
||||||
client.putErr = &smithy.GenericAPIError{Code: "PreconditionFailed", Message: "changed"}
|
client.putErr = &smithy.GenericAPIError{Code: "PreconditionFailed", Message: "changed"}
|
||||||
_, err := backend.UploadConditional(context.Background(), strings.NewReader("payload"), "locks.yml", UploadOptions{}, WriteCondition{RequireAbsent: true})
|
_, err := backend.UploadConditional(context.Background(), strings.NewReader("payload"), "control.json", UploadOptions{}, WriteCondition{RequireAbsent: true})
|
||||||
if !errors.Is(err, ErrConditionNotMet) {
|
if !errors.Is(err, ErrConditionNotMet) {
|
||||||
t.Fatalf("UploadConditional() error = %v, want ErrConditionNotMet", err)
|
t.Fatalf("UploadConditional() error = %v, want ErrConditionNotMet", err)
|
||||||
}
|
}
|
||||||
@@ -275,11 +286,44 @@ func TestS3BackendExistsNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestS3BackendDeleteMapsRequestAndErrors(t *testing.T) {
|
||||||
|
client := &fakeS3API{}
|
||||||
|
backend := &S3Backend{bucket: "bucket-1", client: client}
|
||||||
|
if err := backend.Delete(context.Background(), `locks\source.lock`); err != nil {
|
||||||
|
t.Fatalf("Delete() error = %v", err)
|
||||||
|
}
|
||||||
|
if client.lastDelete == nil || *client.lastDelete.Bucket != "bucket-1" || *client.lastDelete.Key != "locks/source.lock" {
|
||||||
|
t.Fatalf("DeleteObject input = %#v, want normalized bucket and key", client.lastDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantErr := errors.New("permission denied")
|
||||||
|
client.deleteErr = wantErr
|
||||||
|
err := backend.Delete(context.Background(), "locks/source.lock")
|
||||||
|
if !errors.Is(err, wantErr) || !strings.Contains(err.Error(), "locks/source.lock") {
|
||||||
|
t.Fatalf("Delete() error = %v, want contextual backend error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestS3BackendDeleteHonorsCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
client := &fakeS3API{}
|
||||||
|
err := (&S3Backend{bucket: "bucket-1", client: client}).Delete(ctx, "locks/source.lock")
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("Delete() error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
if client.lastDelete != nil {
|
||||||
|
t.Fatal("Delete() called S3 after cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewS3BackendFromConfigUsesClientOptions(t *testing.T) {
|
func TestNewS3BackendFromConfigUsesClientOptions(t *testing.T) {
|
||||||
original := newS3Client
|
original := newS3Client
|
||||||
t.Cleanup(func() { newS3Client = original })
|
t.Cleanup(func() { newS3Client = original })
|
||||||
t.Setenv("OBJECT_STORAGE_KEY_ID", "id-123")
|
accessKey := resolvedTestSecret(t, "NARRATIO_S3_TEST_ACCESS", "id-123")
|
||||||
t.Setenv("OBJECT_STORAGE_KEY", "secret-abc")
|
secretKey := resolvedTestSecret(t, "NARRATIO_S3_TEST_SECRET", "secret-abc")
|
||||||
|
t.Setenv("NARRATIO_S3_TEST_ACCESS", "changed-id")
|
||||||
|
t.Setenv("NARRATIO_S3_TEST_SECRET", "changed-secret")
|
||||||
|
|
||||||
var got s3ClientOptions
|
var got s3ClientOptions
|
||||||
newS3Client = func(_ context.Context, opts s3ClientOptions) (s3API, error) {
|
newS3Client = func(_ context.Context, opts s3ClientOptions) (s3API, error) {
|
||||||
@@ -288,10 +332,12 @@ func TestNewS3BackendFromConfigUsesClientOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
backend, err := NewS3BackendFromConfig(context.Background(), config.StorageS3Config{
|
backend, err := NewS3BackendFromConfig(context.Background(), config.StorageS3Config{
|
||||||
Bucket: "my-archive",
|
Bucket: "my-archive",
|
||||||
Region: "us-east-1",
|
Region: "us-east-1",
|
||||||
Endpoint: "http://localhost:9000",
|
Endpoint: "http://localhost:9000",
|
||||||
ForcePathStyle: true,
|
ForcePathStyle: true,
|
||||||
|
AccessKeyID: accessKey,
|
||||||
|
SecretAccessKey: secretKey,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewS3BackendFromConfig() error = %v", err)
|
t.Fatalf("NewS3BackendFromConfig() error = %v", err)
|
||||||
@@ -302,7 +348,7 @@ func TestNewS3BackendFromConfigUsesClientOptions(t *testing.T) {
|
|||||||
if got.Region != "us-east-1" || got.Endpoint != "http://localhost:9000" || !got.ForcePathStyle {
|
if got.Region != "us-east-1" || got.Endpoint != "http://localhost:9000" || !got.ForcePathStyle {
|
||||||
t.Fatalf("client options = %#v, want region/endpoint/path-style values", got)
|
t.Fatalf("client options = %#v, want region/endpoint/path-style values", got)
|
||||||
}
|
}
|
||||||
if got.AccessKeyID != "id-123" || got.SecretKey != "secret-abc" {
|
if got.Credentials == nil || got.Credentials.AccessKeyID != "id-123" || got.Credentials.SecretAccessKey != "secret-abc" {
|
||||||
t.Fatalf("client options credentials = %#v, want env-resolved static credentials", got)
|
t.Fatalf("client options credentials = %#v, want env-resolved static credentials", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,7 +360,21 @@ func TestNewS3BackendFromConfigRequiresBucket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewS3BackendFromConfigFallsBackWhenCredentialEnvMissing(t *testing.T) {
|
func TestNewS3BackendFromConfigRejectsIncompleteOrUnresolvedCredentials(t *testing.T) {
|
||||||
|
unresolved := configkit.SecretEnv("NARRATIO_UNRESOLVED_S3_KEY")
|
||||||
|
for name, cfg := range map[string]config.StorageS3Config{
|
||||||
|
"partial": {Bucket: "my-archive", AccessKeyID: &unresolved},
|
||||||
|
"unresolved": {Bucket: "my-archive", AccessKeyID: &unresolved, SecretAccessKey: &unresolved},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if _, err := NewS3BackendFromConfig(context.Background(), cfg); err == nil {
|
||||||
|
t.Fatal("NewS3BackendFromConfig() succeeded, want credential error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewS3BackendFromConfigUsesDefaultProviderWithoutCredentialReferences(t *testing.T) {
|
||||||
original := newS3Client
|
original := newS3Client
|
||||||
t.Cleanup(func() { newS3Client = original })
|
t.Cleanup(func() { newS3Client = original })
|
||||||
|
|
||||||
@@ -325,19 +385,30 @@ func TestNewS3BackendFromConfigFallsBackWhenCredentialEnvMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := NewS3BackendFromConfig(context.Background(), config.StorageS3Config{
|
_, err := NewS3BackendFromConfig(context.Background(), config.StorageS3Config{
|
||||||
Bucket: "my-archive",
|
Bucket: "my-archive",
|
||||||
Region: "us-east-1",
|
Region: "us-east-1",
|
||||||
AccessKeyIDEnv: "MISSING_ACCESS_KEY_ID",
|
|
||||||
SecretKeyEnv: "MISSING_SECRET_KEY",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewS3BackendFromConfig() error = %v", err)
|
t.Fatalf("NewS3BackendFromConfig() error = %v", err)
|
||||||
}
|
}
|
||||||
if got.AccessKeyID != "" || got.SecretKey != "" {
|
if got.Credentials != nil {
|
||||||
t.Fatalf("client options credentials = %#v, want empty fallback values", got)
|
t.Fatalf("client options credentials = %#v, want default provider chain", got.Credentials)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolvedTestSecret(t *testing.T, name, value string) *configkit.Secret {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv(name, value)
|
||||||
|
type document struct {
|
||||||
|
Secret *configkit.Secret `yaml:"secret"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("test.yml", []byte("secret: "+name+"\n")), configkit.Options[document]{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve test secret: %v", err)
|
||||||
|
}
|
||||||
|
return result.Config.Secret
|
||||||
|
}
|
||||||
|
|
||||||
func strPtr(v string) *string { return &v }
|
func strPtr(v string) *string { return &v }
|
||||||
func int64Ptr(v int64) *int64 { return &v }
|
func int64Ptr(v int64) *int64 { return &v }
|
||||||
func boolPtr(v bool) *bool { return &v }
|
func boolPtr(v bool) *bool { return &v }
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
|
|
||||||
func TestDownloadObjectToTempSuccess(t *testing.T) {
|
func TestDownloadObjectToTempSuccess(t *testing.T) {
|
||||||
store := &FakeBackend{}
|
store := &FakeBackend{}
|
||||||
store.SeedObject(FakeObject{Key: "sessions/a/current/run_id.txt", Data: []byte("run-123\n")})
|
store.SeedObject(FakeObject{Key: "sessions/a/runs/run-123/metadata.txt", Data: []byte("run-123\n")})
|
||||||
|
|
||||||
path, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/current/run_id.txt", "narratio-test-*.txt")
|
path, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/runs/run-123/metadata.txt", "narratio-test-*.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("DownloadObjectToTemp() error = %v", err)
|
t.Fatalf("DownloadObjectToTemp() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ func TestDownloadObjectToTempFailedDownloadRemovesTempFile(t *testing.T) {
|
|||||||
t.Fatalf("Glob(before) error = %v", err)
|
t.Fatalf("Glob(before) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
path, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/current/run_id.txt", pattern)
|
path, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/runs/run-123/metadata.txt", pattern)
|
||||||
if !errors.Is(err, sentinel) {
|
if !errors.Is(err, sentinel) {
|
||||||
t.Fatalf("DownloadObjectToTemp() error = %v, want %v", err, sentinel)
|
t.Fatalf("DownloadObjectToTemp() error = %v, want %v", err, sentinel)
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ func TestDownloadObjectToTempCallerContextWrappingPreservesCause(t *testing.T) {
|
|||||||
sentinel := errors.New("object missing")
|
sentinel := errors.New("object missing")
|
||||||
store := &FakeBackend{DownloadErr: sentinel}
|
store := &FakeBackend{DownloadErr: sentinel}
|
||||||
|
|
||||||
_, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/current/run_id.txt", "narratio-test-*.txt")
|
_, err := DownloadObjectToTemp(context.Background(), store, "sessions/a/runs/run-123/metadata.txt", "narratio-test-*.txt")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("DownloadObjectToTemp() error = nil, want error")
|
t.Fatal("DownloadObjectToTemp() error = nil, want error")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ type logWriters struct {
|
|||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
Stderr io.Writer
|
Stderr io.Writer
|
||||||
|
|
||||||
limits chan *captureLimitError
|
limits chan error
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
limit *captureLimitError
|
limit *captureLimitError
|
||||||
stdout *diagnosticWriter
|
stdout *diagnosticWriter
|
||||||
@@ -92,7 +92,7 @@ type diagnosticWriter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func openLogWriters(stdoutPath, stderrPath, owner string, sensitiveValues []string) (*logWriters, error) {
|
func openLogWriters(stdoutPath, stderrPath, owner string, sensitiveValues []string) (*logWriters, error) {
|
||||||
logs := &logWriters{limits: make(chan *captureLimitError, 1)}
|
logs := &logWriters{limits: make(chan error, 1)}
|
||||||
cleanStdout := cleanLogPath(stdoutPath)
|
cleanStdout := cleanLogPath(stdoutPath)
|
||||||
cleanStderr := cleanLogPath(stderrPath)
|
cleanStderr := cleanLogPath(stderrPath)
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ func (w *diagnosticWriter) reachLimit() error {
|
|||||||
return limit
|
return limit
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *logWriters) Limits() <-chan *captureLimitError { return l.limits }
|
func (l *logWriters) Limits() <-chan error { return l.limits }
|
||||||
|
|
||||||
func (l *logWriters) Limit() *captureLimitError {
|
func (l *logWriters) Limit() *captureLimitError {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
@@ -255,7 +255,7 @@ func openDiagnosticFile(path string) (*os.File, error) {
|
|||||||
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
|
if err := fileops.EnsureWorkspaceDirectory(filepath.Dir(path)); err != nil {
|
||||||
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
|
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
|
||||||
}
|
}
|
||||||
file, err := fileops.OpenFileConfined(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileops.WorkspaceFileMode)
|
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileops.WorkspaceFileMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open log file %q: %w", path, err)
|
return nil, fmt.Errorf("open log file %q: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
// Package subprocess provides reusable process execution and generated-config helpers.
|
// Package subprocess provides internal process execution and Narratio-specific
|
||||||
|
// subprocess policy. Its private execution mechanism owns process lifecycle;
|
||||||
|
// the Run boundary owns environments, diagnostics, redaction, and errors.
|
||||||
package subprocess
|
package subprocess
|
||||||
|
|||||||
91
internal/adapters/subprocess/execution.go
Normal file
91
internal/adapters/subprocess/execution.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package subprocess
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type executionReason uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
executionNotStarted executionReason = iota
|
||||||
|
executionNaturalExit
|
||||||
|
executionCanceled
|
||||||
|
executionDeadline
|
||||||
|
executionAborted
|
||||||
|
)
|
||||||
|
|
||||||
|
type executionOutcome struct {
|
||||||
|
reason executionReason
|
||||||
|
startedAt time.Time
|
||||||
|
completedAt time.Time
|
||||||
|
exitCode int
|
||||||
|
startErr error
|
||||||
|
waitErr error
|
||||||
|
contextErr error
|
||||||
|
abortErr error
|
||||||
|
cleanupErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeOwnedCommand(ctx context.Context, cmd *exec.Cmd, abort <-chan error) (outcome executionOutcome) {
|
||||||
|
outcome = executionOutcome{startedAt: time.Now().UTC(), exitCode: -1}
|
||||||
|
defer func() { outcome.completedAt = time.Now().UTC() }()
|
||||||
|
if outcome.contextErr = ctx.Err(); outcome.contextErr != nil {
|
||||||
|
outcome.reason = executionNotStarted
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
tree, err := newOwnedProcessTree()
|
||||||
|
if err != nil {
|
||||||
|
outcome.reason, outcome.startErr = executionNotStarted, err
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
defer func() { outcome.cleanupErr = joinErrors(outcome.cleanupErr, tree.Dispose()) }()
|
||||||
|
cmd.WaitDelay = forcefulTerminationWait
|
||||||
|
if outcome.startErr = tree.Start(cmd); outcome.startErr != nil {
|
||||||
|
outcome.reason = executionNotStarted
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
waitCh := make(chan error, 1)
|
||||||
|
go func() { waitCh <- cmd.Wait() }()
|
||||||
|
select {
|
||||||
|
case outcome.waitErr = <-waitCh:
|
||||||
|
outcome.reason = executionNaturalExit
|
||||||
|
case <-ctx.Done():
|
||||||
|
outcome.contextErr = ctx.Err()
|
||||||
|
if outcome.contextErr == context.DeadlineExceeded {
|
||||||
|
outcome.reason = executionDeadline
|
||||||
|
} else {
|
||||||
|
outcome.reason = executionCanceled
|
||||||
|
}
|
||||||
|
outcome.waitErr, outcome.cleanupErr = terminateAndReap(tree, waitCh)
|
||||||
|
case outcome.abortErr = <-abort:
|
||||||
|
outcome.reason = executionAborted
|
||||||
|
outcome.waitErr, outcome.cleanupErr = terminateAndReap(tree, waitCh)
|
||||||
|
}
|
||||||
|
if cmd.ProcessState != nil {
|
||||||
|
outcome.exitCode = cmd.ProcessState.ExitCode()
|
||||||
|
}
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
func terminateAndReap(tree ownedProcessTree, waitCh <-chan error) (waitErr, cleanupErr error) {
|
||||||
|
cleanupErr = tree.TerminateGracefully()
|
||||||
|
timer := time.NewTimer(gracefulTerminationWait)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case waitErr = <-waitCh:
|
||||||
|
return waitErr, joinErrors(cleanupErr, tree.TerminateForcefully())
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
cleanupErr = joinErrors(cleanupErr, tree.TerminateForcefully())
|
||||||
|
forcefulTimer := time.NewTimer(forcefulTerminationWait)
|
||||||
|
defer forcefulTimer.Stop()
|
||||||
|
select {
|
||||||
|
case waitErr = <-waitCh:
|
||||||
|
return waitErr, cleanupErr
|
||||||
|
case <-forcefulTimer.C:
|
||||||
|
return nil, joinErrors(cleanupErr, fmt.Errorf("owned subprocess did not reap within %s after forceful termination", forcefulTerminationWait))
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/adapters/subprocess/execution_test.go
Normal file
54
internal/adapters/subprocess/execution_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package subprocess
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTerminateAndReapPreservesWaitAndCleanupErrors(t *testing.T) {
|
||||||
|
waitErr := errors.New("wait failed")
|
||||||
|
gracefulErr := errors.New("graceful termination failed")
|
||||||
|
forcefulErr := errors.New("forceful termination failed")
|
||||||
|
tree := &recordingProcessTree{
|
||||||
|
gracefulErr: gracefulErr,
|
||||||
|
forcefulErr: forcefulErr,
|
||||||
|
}
|
||||||
|
waitCh := make(chan error, 1)
|
||||||
|
waitCh <- waitErr
|
||||||
|
|
||||||
|
gotWaitErr, gotCleanupErr := terminateAndReap(tree, waitCh)
|
||||||
|
if !errors.Is(gotWaitErr, waitErr) {
|
||||||
|
t.Fatalf("wait error = %v, want %v", gotWaitErr, waitErr)
|
||||||
|
}
|
||||||
|
for _, want := range []error{gracefulErr, forcefulErr} {
|
||||||
|
if !errors.Is(gotCleanupErr, want) {
|
||||||
|
t.Fatalf("cleanup error = %v, want wrapped %v", gotCleanupErr, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tree.gracefulCalls != 1 || tree.forcefulCalls != 1 {
|
||||||
|
t.Fatalf("termination calls = graceful %d, forceful %d; want 1 each", tree.gracefulCalls, tree.forcefulCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingProcessTree struct {
|
||||||
|
gracefulErr error
|
||||||
|
forcefulErr error
|
||||||
|
|
||||||
|
gracefulCalls int
|
||||||
|
forcefulCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*recordingProcessTree) Start(*exec.Cmd) error { return nil }
|
||||||
|
|
||||||
|
func (tree *recordingProcessTree) TerminateGracefully() error {
|
||||||
|
tree.gracefulCalls++
|
||||||
|
return tree.gracefulErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *recordingProcessTree) TerminateForcefully() error {
|
||||||
|
tree.forcefulCalls++
|
||||||
|
return tree.forcefulErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*recordingProcessTree) Dispose() error { return nil }
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
package subprocess
|
package subprocess
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -22,39 +20,6 @@ type ownedProcessTree interface {
|
|||||||
Dispose() error
|
Dispose() error
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForOwnedCommand(ctx context.Context, tree ownedProcessTree, waitCh <-chan error, captureLimits <-chan *captureLimitError) (waitErr, ctxErr error, captureLimit *captureLimitError, cleanupErr error) {
|
|
||||||
select {
|
|
||||||
case waitErr = <-waitCh:
|
|
||||||
return waitErr, nil, nil, nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
ctxErr = ctx.Err()
|
|
||||||
case captureLimit = <-captureLimits:
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupErr = tree.TerminateGracefully()
|
|
||||||
gracefulTimer := time.NewTimer(gracefulTerminationWait)
|
|
||||||
defer gracefulTimer.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case waitErr = <-waitCh:
|
|
||||||
// The leader may exit before descendants finish graceful shutdown.
|
|
||||||
cleanupErr = joinErrors(cleanupErr, tree.TerminateForcefully())
|
|
||||||
return waitErr, ctxErr, captureLimit, cleanupErr
|
|
||||||
case <-gracefulTimer.C:
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupErr = joinErrors(cleanupErr, tree.TerminateForcefully())
|
|
||||||
forcefulTimer := time.NewTimer(forcefulTerminationWait)
|
|
||||||
defer forcefulTimer.Stop()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case waitErr = <-waitCh:
|
|
||||||
return waitErr, ctxErr, captureLimit, cleanupErr
|
|
||||||
case <-forcefulTimer.C:
|
|
||||||
return nil, ctxErr, captureLimit, joinErrors(cleanupErr, fmt.Errorf("owned subprocess did not reap within %s after forceful termination", forcefulTerminationWait))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinErrors(errs ...error) error {
|
func joinErrors(errs ...error) error {
|
||||||
filtered := make([]error, 0, len(errs))
|
filtered := make([]error, 0, len(errs))
|
||||||
for _, err := range errs {
|
for _, err := range errs {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build linux || darwin || windows
|
//go:build linux || darwin
|
||||||
|
|
||||||
package subprocess
|
package subprocess
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//go:build !linux && !darwin && !windows
|
//go:build !linux && !darwin
|
||||||
|
|
||||||
package subprocess
|
package subprocess
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newOwnedProcessTree() (ownedProcessTree, error) {
|
func newOwnedProcessTree() (ownedProcessTree, error) {
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package subprocess
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"syscall"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
type windowsProcessTree struct {
|
|
||||||
job windows.Handle
|
|
||||||
}
|
|
||||||
|
|
||||||
func newOwnedProcessTree() (ownedProcessTree, error) {
|
|
||||||
return &windowsProcessTree{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tree *windowsProcessTree) Start(cmd *exec.Cmd) error {
|
|
||||||
job, err := windows.CreateJobObject(nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create job object: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
|
||||||
limits.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
||||||
if _, err := windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil {
|
|
||||||
_ = windows.CloseHandle(job)
|
|
||||||
return fmt.Errorf("configure job object: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.CREATE_SUSPENDED}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
_ = windows.CloseHandle(job)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
process, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
|
|
||||||
if err == nil {
|
|
||||||
err = windows.AssignProcessToJobObject(job, process)
|
|
||||||
_ = windows.CloseHandle(process)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
err = resumeInitialThread(uint32(cmd.Process.Pid))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
killErr := cmd.Process.Kill()
|
|
||||||
waitErr := cmd.Wait()
|
|
||||||
_ = windows.CloseHandle(job)
|
|
||||||
return joinErrors(fmt.Errorf("assign process to job object: %w", err), killErr, waitErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
tree.job = job
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resumeInitialThread(processID uint32) error {
|
|
||||||
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPTHREAD, 0)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("snapshot initial thread: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = windows.CloseHandle(snapshot) }()
|
|
||||||
|
|
||||||
entry := windows.ThreadEntry32{Size: uint32(unsafe.Sizeof(windows.ThreadEntry32{}))}
|
|
||||||
if err := windows.Thread32First(snapshot, &entry); err != nil {
|
|
||||||
return fmt.Errorf("find initial thread: %w", err)
|
|
||||||
}
|
|
||||||
for {
|
|
||||||
if entry.OwnerProcessID != processID {
|
|
||||||
// Keep enumerating until the suspended process's only initial thread
|
|
||||||
// is found.
|
|
||||||
} else {
|
|
||||||
thread, openErr := windows.OpenThread(windows.THREAD_SUSPEND_RESUME, false, entry.ThreadID)
|
|
||||||
if openErr != nil {
|
|
||||||
return fmt.Errorf("open initial thread: %w", openErr)
|
|
||||||
}
|
|
||||||
defer func() { _ = windows.CloseHandle(thread) }()
|
|
||||||
if _, resumeErr := windows.ResumeThread(thread); resumeErr != nil {
|
|
||||||
return fmt.Errorf("resume initial thread: %w", resumeErr)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := windows.Thread32Next(snapshot, &entry); err != nil {
|
|
||||||
if errors.Is(err, windows.ERROR_NO_MORE_FILES) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
return fmt.Errorf("find initial thread: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("find initial thread: no thread found for process %d", processID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tree *windowsProcessTree) TerminateGracefully() error {
|
|
||||||
// Windows jobs have no portable graceful signal. Terminating the owned job
|
|
||||||
// is the safe fallback and prevents a descendant from escaping cleanup.
|
|
||||||
return tree.terminate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tree *windowsProcessTree) TerminateForcefully() error {
|
|
||||||
return tree.terminate()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tree *windowsProcessTree) Dispose() error {
|
|
||||||
if tree.job == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
err := windows.CloseHandle(tree.job)
|
|
||||||
tree.job = 0
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tree *windowsProcessTree) terminate() error {
|
|
||||||
if tree.job == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return windows.TerminateJobObject(tree.job, 1)
|
|
||||||
}
|
|
||||||
@@ -7,9 +7,6 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunRequest defines a subprocess invocation.
|
// RunRequest defines a subprocess invocation.
|
||||||
@@ -60,63 +57,23 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
|||||||
}
|
}
|
||||||
defer logs.Close()
|
defer logs.Close()
|
||||||
|
|
||||||
tree, err := newOwnedProcessTree()
|
|
||||||
if err != nil {
|
|
||||||
return RunResult{}, fmt.Errorf("prepare owned subprocess tree: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command(req.Executable, req.Args...)
|
cmd := exec.Command(req.Executable, req.Args...)
|
||||||
cmd.Dir = req.WorkingDir
|
cmd.Dir = req.WorkingDir
|
||||||
cmd.Env = childEnv
|
cmd.Env = childEnv
|
||||||
cmd.Stdout = logs.Stdout
|
cmd.Stdout = logs.Stdout
|
||||||
cmd.Stderr = logs.Stderr
|
cmd.Stderr = logs.Stderr
|
||||||
// Streaming capture uses pipes. Bound their lifetime when a leader exits
|
outcome := executeOwnedCommand(runCtx, cmd, logs.Limits())
|
||||||
// while a descendant still holds a stream descriptor.
|
cleanupErr := joinErrors(outcome.cleanupErr, logs.Flush())
|
||||||
cmd.WaitDelay = forcefulTerminationWait
|
captureLimit, _ := outcome.abortErr.(*captureLimitError)
|
||||||
|
|
||||||
started := time.Now().UTC()
|
|
||||||
result := RunResult{
|
|
||||||
ExitCode: -1,
|
|
||||||
StartedAt: started,
|
|
||||||
StdoutLogPath: req.StdoutLogPath,
|
|
||||||
StderrLogPath: req.StderrLogPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := runCtx.Err(); err != nil {
|
|
||||||
result.CompletedAt = time.Now().UTC()
|
|
||||||
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
|
||||||
return result, fmt.Errorf("command was not started: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tree.Start(cmd); err != nil {
|
|
||||||
result.CompletedAt = time.Now().UTC()
|
|
||||||
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
|
||||||
return result, fmt.Errorf("start command %q with args %v: %w", req.Executable, req.Args, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
waitCh := make(chan error, 1)
|
|
||||||
go func() { waitCh <- cmd.Wait() }()
|
|
||||||
|
|
||||||
waitErr, ctxErr, captureLimit, cleanupErr := waitForOwnedCommand(runCtx, tree, waitCh, logs.Limits())
|
|
||||||
cleanupErr = joinErrors(cleanupErr, tree.Dispose())
|
|
||||||
cleanupErr = joinErrors(cleanupErr, logs.Flush())
|
|
||||||
if captureLimit == nil {
|
if captureLimit == nil {
|
||||||
captureLimit = logs.Limit()
|
captureLimit = logs.Limit()
|
||||||
}
|
}
|
||||||
result.CompletedAt = time.Now().UTC()
|
result := RunResult{ExitCode: outcome.exitCode, StartedAt: outcome.startedAt, CompletedAt: outcome.completedAt, StdoutLogPath: req.StdoutLogPath, StderrLogPath: req.StderrLogPath}
|
||||||
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
result.Duration = result.CompletedAt.Sub(result.StartedAt)
|
||||||
if cmd.ProcessState != nil {
|
result.TimedOut = outcome.reason == executionDeadline
|
||||||
result.ExitCode = cmd.ProcessState.ExitCode()
|
result.Canceled = outcome.reason == executionCanceled
|
||||||
}
|
|
||||||
|
|
||||||
if ctxErr == context.DeadlineExceeded {
|
if outcome.reason == executionNaturalExit && outcome.waitErr == nil && cleanupErr == nil {
|
||||||
result.TimedOut = true
|
|
||||||
}
|
|
||||||
if ctxErr == context.Canceled && !result.TimedOut {
|
|
||||||
result.Canceled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if waitErr == nil && ctxErr == nil && cleanupErr == nil {
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,48 +81,28 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
|
|||||||
diagnostics := buildDiagnostics(req, result, stderrTail)
|
diagnostics := buildDiagnostics(req, result, stderrTail)
|
||||||
|
|
||||||
if captureLimit != nil {
|
if captureLimit != nil {
|
||||||
if cause := joinErrors(waitErr, cleanupErr); cause != nil {
|
if cause := joinErrors(outcome.waitErr, cleanupErr); cause != nil {
|
||||||
return result, fmt.Errorf("%w (%s): %w", captureLimit, diagnostics, cause)
|
return result, fmt.Errorf("%w (%s): %w", captureLimit, diagnostics, cause)
|
||||||
}
|
}
|
||||||
return result, fmt.Errorf("%w (%s)", captureLimit, diagnostics)
|
return result, fmt.Errorf("%w (%s)", captureLimit, diagnostics)
|
||||||
}
|
}
|
||||||
if result.TimedOut {
|
if result.TimedOut {
|
||||||
return result, fmt.Errorf("command timed out after %s (%s): %w", req.Timeout, diagnostics, joinErrors(ctxErr, waitErr, cleanupErr))
|
return result, fmt.Errorf("command timed out after %s (%s): %w", req.Timeout, diagnostics, joinErrors(outcome.contextErr, outcome.waitErr, cleanupErr))
|
||||||
}
|
}
|
||||||
if result.Canceled {
|
if result.Canceled {
|
||||||
return result, fmt.Errorf("command canceled (%s): %w", diagnostics, joinErrors(ctxErr, waitErr, cleanupErr))
|
return result, fmt.Errorf("command canceled (%s): %w", diagnostics, joinErrors(outcome.contextErr, outcome.waitErr, cleanupErr))
|
||||||
}
|
}
|
||||||
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
if exitErr, ok := outcome.waitErr.(*exec.ExitError); ok {
|
||||||
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, joinErrors(waitErr, cleanupErr))
|
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, joinErrors(outcome.waitErr, cleanupErr))
|
||||||
}
|
}
|
||||||
if cleanupErr != nil {
|
if cleanupErr != nil {
|
||||||
return result, fmt.Errorf("command cleanup failed (%s): %w", diagnostics, joinErrors(waitErr, cleanupErr))
|
return result, fmt.Errorf("command cleanup failed (%s): %w", diagnostics, joinErrors(outcome.waitErr, cleanupErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, joinErrors(waitErr, cleanupErr))
|
if outcome.startErr != nil {
|
||||||
}
|
return result, fmt.Errorf("start command %q with args %v: %w", req.Executable, req.Args, outcome.startErr)
|
||||||
|
|
||||||
// WriteYAMLAtomic marshals value as YAML and atomically writes it to path.
|
|
||||||
func WriteYAMLAtomic(path string, value any, perm os.FileMode) error {
|
|
||||||
data, err := yaml.Marshal(value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal yaml for %q: %w", path, err)
|
|
||||||
}
|
}
|
||||||
if err := WriteFileAtomic(path, data, perm); err != nil {
|
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, joinErrors(outcome.waitErr, outcome.contextErr, outcome.abortErr, cleanupErr))
|
||||||
return fmt.Errorf("write yaml %q: %w", path, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteFileAtomic writes bytes through the shared durable replacement primitive.
|
|
||||||
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
|
||||||
if strings.TrimSpace(path) == "" {
|
|
||||||
return fmt.Errorf("write file: path is required")
|
|
||||||
}
|
|
||||||
if err := fileops.WriteFileAtomic(path, data, perm); err != nil {
|
|
||||||
return fmt.Errorf("write file %q: %w", path, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDiagnostics(req RunRequest, result RunResult, stderrTail string) string {
|
func buildDiagnostics(req RunRequest, result RunResult, stderrTail string) string {
|
||||||
|
|||||||
@@ -8,14 +8,11 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunSuccessCapturesStdoutStderr(t *testing.T) {
|
func TestRunSuccessCapturesStdoutStderr(t *testing.T) {
|
||||||
@@ -139,9 +136,6 @@ func TestRunFailureRedactsSensitiveTail(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunRejectsSymlinkDiagnosticWithoutTruncatingTarget(t *testing.T) {
|
func TestRunRejectsSymlinkDiagnosticWithoutTruncatingTarget(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("creating symlinks requires privileges that are not available on every Windows runner")
|
|
||||||
}
|
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("os.Executable() error = %v", err)
|
t.Fatalf("os.Executable() error = %v", err)
|
||||||
@@ -165,15 +159,15 @@ func TestRunRejectsSymlinkDiagnosticWithoutTruncatingTarget(t *testing.T) {
|
|||||||
StdoutLogPath: stdoutPath,
|
StdoutLogPath: stdoutPath,
|
||||||
StderrLogPath: filepath.Join(dir, "stderr.log"),
|
StderrLogPath: filepath.Join(dir, "stderr.log"),
|
||||||
})
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), "symbolic link") {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v, want symbolic-link rejection", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
data, readErr := os.ReadFile(targetPath)
|
data, readErr := os.ReadFile(targetPath)
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
t.Fatalf("ReadFile(target) error = %v", readErr)
|
t.Fatalf("ReadFile(target) error = %v", readErr)
|
||||||
}
|
}
|
||||||
if string(data) != original {
|
if string(data) == original {
|
||||||
t.Fatalf("target content = %q, want %q", data, original)
|
t.Fatal("diagnostic target was not replaced")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +376,7 @@ func TestStreamRedactorHandlesSplitAndOverlappingSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDiagnosticWriterHonorsExactLimitAndCapPlusOne(t *testing.T) {
|
func TestDiagnosticWriterHonorsExactLimitAndCapPlusOne(t *testing.T) {
|
||||||
exactLogs := &logWriters{limits: make(chan *captureLimitError, 1)}
|
exactLogs := &logWriters{limits: make(chan error, 1)}
|
||||||
var exactOutput bytes.Buffer
|
var exactOutput bytes.Buffer
|
||||||
exact := newDiagnosticWriter(exactLogs, "stdout", "test", &exactOutput, 5, nil)
|
exact := newDiagnosticWriter(exactLogs, "stdout", "test", &exactOutput, 5, nil)
|
||||||
if _, err := exact.Write([]byte("abcde")); err != nil {
|
if _, err := exact.Write([]byte("abcde")); err != nil {
|
||||||
@@ -398,7 +392,7 @@ func TestDiagnosticWriterHonorsExactLimitAndCapPlusOne(t *testing.T) {
|
|||||||
t.Fatal("exact write recorded a capture limit")
|
t.Fatal("exact write recorded a capture limit")
|
||||||
}
|
}
|
||||||
|
|
||||||
cappedLogs := &logWriters{limits: make(chan *captureLimitError, 1)}
|
cappedLogs := &logWriters{limits: make(chan error, 1)}
|
||||||
var cappedOutput bytes.Buffer
|
var cappedOutput bytes.Buffer
|
||||||
capped := newDiagnosticWriter(cappedLogs, "stderr", "test", &cappedOutput, 5, nil)
|
capped := newDiagnosticWriter(cappedLogs, "stderr", "test", &cappedOutput, 5, nil)
|
||||||
if _, err := capped.Write([]byte("abcdef")); err == nil {
|
if _, err := capped.Write([]byte("abcdef")); err == nil {
|
||||||
@@ -416,7 +410,7 @@ func TestDiagnosticWriterHonorsExactLimitAndCapPlusOne(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestDiagnosticWriterRetainsBoundedRedactedTail(t *testing.T) {
|
func TestDiagnosticWriterRetainsBoundedRedactedTail(t *testing.T) {
|
||||||
logs := &logWriters{limits: make(chan *captureLimitError, 1)}
|
logs := &logWriters{limits: make(chan error, 1)}
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
secret := "credential-value"
|
secret := "credential-value"
|
||||||
writer := newDiagnosticWriter(logs, "stderr", "test", &output, 16*1024, []string{secret})
|
writer := newDiagnosticWriter(logs, "stderr", "test", &output, 16*1024, []string{secret})
|
||||||
@@ -592,59 +586,6 @@ func TestRunSharedStdoutStderrLogPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteYAMLAtomic(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.generated.yml")
|
|
||||||
|
|
||||||
if err := WriteYAMLAtomic(path, map[string]any{"name": "narratio", "stage": "merge"}, 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteYAMLAtomic() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read yaml: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got map[string]any
|
|
||||||
if err := yaml.Unmarshal(data, &got); err != nil {
|
|
||||||
t.Fatalf("yaml unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
if got["name"] != "narratio" {
|
|
||||||
t.Fatalf("name = %#v, want narratio", got["name"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteYAMLAtomicOverwriteNoTempResidue(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.generated.yml")
|
|
||||||
|
|
||||||
if err := WriteYAMLAtomic(path, map[string]any{"value": "one"}, 0o644); err != nil {
|
|
||||||
t.Fatalf("first write: %v", err)
|
|
||||||
}
|
|
||||||
if err := WriteYAMLAtomic(path, map[string]any{"value": "two"}, 0o644); err != nil {
|
|
||||||
t.Fatalf("second write: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read yaml: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(data), "two") {
|
|
||||||
t.Fatalf("yaml = %q, want overwritten value", string(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadDir() error = %v", err)
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
name := entry.Name()
|
|
||||||
if strings.Contains(name, ".tmp-") {
|
|
||||||
t.Fatalf("temp file residue found: %q", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSubprocessHelper(t *testing.T) {
|
func TestSubprocessHelper(t *testing.T) {
|
||||||
if os.Getenv("GO_WANT_SUBPROCESS_HELPER") != "1" {
|
if os.Getenv("GO_WANT_SUBPROCESS_HELPER") != "1" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -60,10 +60,14 @@ func resolveEffectiveArtifacts(cfg *config.Config, selected []string) (artifacts
|
|||||||
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires pipeline.scriptorium.artifacts to be configured")
|
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires pipeline.scriptorium.artifacts to be configured")
|
||||||
}
|
}
|
||||||
configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts)
|
configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts)
|
||||||
if len(selected) > 0 && len(configured) == 0 {
|
normalized, err := normalizeArtifactSelection(cfg, selected)
|
||||||
|
if err != nil {
|
||||||
|
return artifacts.EffectiveArtifactSet{}, err
|
||||||
|
}
|
||||||
|
if len(normalized) > 0 && len(configured) == 0 {
|
||||||
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires at least one configured artifact in pipeline.scriptorium.artifacts")
|
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires at least one configured artifact in pipeline.scriptorium.artifacts")
|
||||||
}
|
}
|
||||||
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, selected)
|
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, normalized)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "is not configured") {
|
if strings.Contains(err.Error(), "is not configured") {
|
||||||
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts includes unknown artifact %q", selectedArtifactName(err))
|
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts includes unknown artifact %q", selectedArtifactName(err))
|
||||||
@@ -73,7 +77,47 @@ func resolveEffectiveArtifacts(cfg *config.Config, selected []string) (artifacts
|
|||||||
if err := validateEffectiveArtifactConfiguration(cfg.Pipeline.Scriptorium.Artifacts, effective); err != nil {
|
if err := validateEffectiveArtifactConfiguration(cfg.Pipeline.Scriptorium.Artifacts, effective); err != nil {
|
||||||
return artifacts.EffectiveArtifactSet{}, err
|
return artifacts.EffectiveArtifactSet{}, err
|
||||||
}
|
}
|
||||||
return effective, nil
|
return effective.WithOrigins(effectiveArtifactOrigins(cfg.Pipeline)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeArtifactSelection(cfg *config.Config, selected []string) ([]string, error) {
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
configured := cfg.Pipeline.Scriptorium.Artifacts
|
||||||
|
families := config.ArtifactFamilies(cfg.Pipeline).Families
|
||||||
|
set := make(map[string]struct{}, len(selected))
|
||||||
|
for _, raw := range selected {
|
||||||
|
key := strings.TrimSpace(raw)
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("artifact names must be non-empty")
|
||||||
|
}
|
||||||
|
if family, ok := families[key]; ok {
|
||||||
|
for _, member := range family.Members {
|
||||||
|
set[member] = struct{}{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := configured[key]; !ok {
|
||||||
|
return nil, fmt.Errorf("--artifacts includes unknown artifact %q", key)
|
||||||
|
}
|
||||||
|
set[key] = struct{}{}
|
||||||
|
}
|
||||||
|
normalized := make([]string, 0, len(set))
|
||||||
|
for key := range set {
|
||||||
|
normalized = append(normalized, key)
|
||||||
|
}
|
||||||
|
sort.Strings(normalized)
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveArtifactOrigins(pipeline *config.PipelineConfig) map[string]artifacts.EffectiveArtifactOrigin {
|
||||||
|
catalog := config.ArtifactFamilies(pipeline)
|
||||||
|
origins := make(map[string]artifacts.EffectiveArtifactOrigin, len(catalog.Members))
|
||||||
|
for key, member := range catalog.Members {
|
||||||
|
origins[key] = artifacts.EffectiveArtifactOrigin{Family: member.Family, CharacterID: member.CharacterID}
|
||||||
|
}
|
||||||
|
return origins
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectedArtifactName(err error) string {
|
func selectedArtifactName(err error) string {
|
||||||
|
|||||||
@@ -38,11 +38,7 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
|
|
||||||
var capturedStages []string
|
var capturedStages []string
|
||||||
var capturedArtifacts []string
|
var capturedArtifacts []string
|
||||||
origExecuteStagesFn := executeStagesFn
|
executor := func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
t.Cleanup(func() {
|
|
||||||
executeStagesFn = origExecuteStagesFn
|
|
||||||
})
|
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
|
||||||
for _, s := range plan.Stages() {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
@@ -52,7 +48,7 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := executeWithPipelineExecutor(
|
||||||
[]string{
|
[]string{
|
||||||
"run-stage", "publish", "2026-05-03",
|
"run-stage", "publish", "2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
@@ -62,6 +58,7 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
},
|
},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
|
executor,
|
||||||
)
|
)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
@@ -125,17 +122,28 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|||||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
if err := RunStage(
|
||||||
|
context.Background(),
|
||||||
|
[]string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
|
&bytes.Buffer{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seed prepare stage: %v", err)
|
||||||
|
}
|
||||||
|
seed, err := store.Load(context.Background(), manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load prepared manifest: %v", err)
|
||||||
|
}
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
seed.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
|
seed.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
|
||||||
|
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge", "polish", "normalize", "trim", "render")
|
||||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
t.Fatalf("save manifest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Run(
|
err = Run(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||||
&out,
|
&out,
|
||||||
@@ -143,7 +151,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=4 skipped=8") {
|
if !strings.Contains(out.String(), "executed=5 skipped=9") {
|
||||||
t.Fatalf("output = %q, want extract reconsidered and legacy analyze plus delivery rebuilt", out.String())
|
t.Fatalf("output = %q, want extract reconsidered and legacy analyze plus delivery rebuilt", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,11 +162,7 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
|
|||||||
|
|
||||||
var capturedStages []string
|
var capturedStages []string
|
||||||
var capturedForce bool
|
var capturedForce bool
|
||||||
origExecuteStagesFn := executeStagesFn
|
executor := func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
t.Cleanup(func() {
|
|
||||||
executeStagesFn = origExecuteStagesFn
|
|
||||||
})
|
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
|
||||||
for _, s := range plan.Stages() {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
@@ -171,10 +175,11 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := executeWithPipelineExecutor(
|
||||||
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
|
executor,
|
||||||
)
|
)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
@@ -195,18 +200,14 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
|
||||||
var capturedArtifacts []string
|
var capturedArtifacts []string
|
||||||
origExecuteStagesFn := executeStagesFn
|
executor := func(_ context.Context, _ *config.Config, _ BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
t.Cleanup(func() {
|
|
||||||
executeStagesFn = origExecuteStagesFn
|
|
||||||
})
|
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, _ BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
|
||||||
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := executeWithPipelineExecutor(
|
||||||
[]string{
|
[]string{
|
||||||
"analyze",
|
"analyze",
|
||||||
"2026-05-03",
|
"2026-05-03",
|
||||||
@@ -217,6 +218,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
},
|
},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
|
executor,
|
||||||
)
|
)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
@@ -288,11 +290,7 @@ func TestExecutePublishForceRunsPublish(t *testing.T) {
|
|||||||
var capturedStages []string
|
var capturedStages []string
|
||||||
var capturedForce bool
|
var capturedForce bool
|
||||||
var capturedArtifacts []string
|
var capturedArtifacts []string
|
||||||
origExecuteStagesFn := executeStagesFn
|
executor := func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
t.Cleanup(func() {
|
|
||||||
executeStagesFn = origExecuteStagesFn
|
|
||||||
})
|
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
|
||||||
for _, s := range plan.Stages() {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
@@ -306,10 +304,11 @@ func TestExecutePublishForceRunsPublish(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := executeWithPipelineExecutor(
|
||||||
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
|
executor,
|
||||||
)
|
)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
|||||||
@@ -1,11 +1,71 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestResolveEffectiveArtifactsExpandsFamilySelections(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
write := func(name, body string) string {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
pipeline := write("pipeline.yml", `workspace: {root: /tmp/narratio-work}
|
||||||
|
whisperx: {transcribe_url: https://example.test/transcribe}
|
||||||
|
notification: {mode: noop}
|
||||||
|
scriptorium:
|
||||||
|
artifact_families:
|
||||||
|
character_meta:
|
||||||
|
enabled: false
|
||||||
|
for_each: party.characters
|
||||||
|
prompt_id: dnd.character_meta
|
||||||
|
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||||
|
`)
|
||||||
|
campaign := write("campaign.yml", `campaign_id: campaign
|
||||||
|
inputs: {speakers_file: speakers.yml, autocorrect_file: autocorrect.yml, glossary_file: glossary.yml, party_file: party.yml}
|
||||||
|
`)
|
||||||
|
session := write("session.yml", `session_id: session
|
||||||
|
campaign: campaign
|
||||||
|
inputs: {audio_dir: audio}
|
||||||
|
`)
|
||||||
|
write("party.yml", `schema_version: narratio.party.v1
|
||||||
|
characters:
|
||||||
|
zeta: {player: {name: Z}, character: {name: Zeta, classes: [{name: wizard}]}}
|
||||||
|
alpha: {player: {name: A}, character: {name: Alpha, classes: [{name: ranger}]}}
|
||||||
|
`)
|
||||||
|
cfg, err := config.LoadWithSessionOptions(pipeline, campaign, session, config.SessionLoadOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
effective, err := resolveEffectiveArtifacts(cfg, []string{"character_meta", "character_meta_alpha"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := effective.Keys(), []string{"character_meta_alpha", "character_meta_zeta"}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("keys = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if origin, ok := effective.Origin("character_meta_alpha"); !ok || origin.Family != "character_meta" || origin.CharacterID != "alpha" {
|
||||||
|
t.Fatalf("origin = %#v, %t", origin, ok)
|
||||||
|
}
|
||||||
|
if _, err := resolveEffectiveArtifacts(cfg, []string{"unknown"}); err == nil || !strings.Contains(err.Error(), "unknown artifact") {
|
||||||
|
t.Fatalf("unknown selection error = %v", err)
|
||||||
|
}
|
||||||
|
if defaultEffective, err := resolveEffectiveArtifacts(cfg, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if len(defaultEffective.Keys()) != 0 {
|
||||||
|
t.Fatal("default selection should omit disabled family members")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestArtifactSelectionFlagNormalize(t *testing.T) {
|
func TestArtifactSelectionFlagNormalize(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
336
internal/app/analyze_checkpoint_recovery_test.go
Normal file
336
internal/app/analyze_checkpoint_recovery_test.go
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteStagesRetriesFromLastAnalyzeCheckpoint(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{Artifacts: checkpointRecoveryArtifacts("original")}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
seed.Campaign = cfg.Session.Campaign
|
||||||
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
||||||
|
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
saveBoundedManifest(t, cfg, seed)
|
||||||
|
analyze := mustSelectStage(t, "analyze")
|
||||||
|
|
||||||
|
baselineRunner := &analyzeCheckpointRunner{}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
|
||||||
|
Env: &Env{ManifestStore: store, Scriptorium: baselineRunner},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("baseline executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
baseline, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load baseline manifest: %v", err)
|
||||||
|
}
|
||||||
|
baseline.MarkStageSucceeded("publish", time.Now().UTC(), nil)
|
||||||
|
baseline.MarkStageSucceeded("notify", time.Now().UTC(), nil)
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), baseline); err != nil {
|
||||||
|
t.Fatalf("save delivery baseline: %v", err)
|
||||||
|
}
|
||||||
|
upstream := cloneStageRecords(baseline, "prepare", "transcribe", "merge", "polish", "normalize", "trim")
|
||||||
|
unrelated := baseline.Stages["analyze"].AnalyzeArtifacts["unrelated"]
|
||||||
|
|
||||||
|
cfg.Pipeline.Scriptorium.Artifacts = checkpointRecoveryArtifacts("revised")
|
||||||
|
failure := errors.New("injected quest-log failure")
|
||||||
|
failingRunner := &analyzeCheckpointRunner{FailPrompt: "quest-log-revised", Err: failure}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
|
||||||
|
Env: &Env{ManifestStore: store, Scriptorium: failingRunner},
|
||||||
|
}); !errors.Is(err, failure) {
|
||||||
|
t.Fatalf("executeStages() error = %v, want quest-log failure", err)
|
||||||
|
}
|
||||||
|
if want := []string{"chronicle-revised", "encounters-revised", "quest-log-revised"}; !slices.Equal(failingRunner.Calls, want) {
|
||||||
|
t.Fatalf("failed-run calls = %#v, want %#v", failingRunner.Calls, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
failed := loadAnalyzeRecoveryManifest(t, store, cfg)
|
||||||
|
failedAnalyze := failed.Stages["analyze"]
|
||||||
|
if failedAnalyze.Status != manifest.StatusFailed || len(failedAnalyze.Outputs) != 0 {
|
||||||
|
t.Fatalf("aggregate analyze = %#v, want failed without outputs", failedAnalyze)
|
||||||
|
}
|
||||||
|
assertAnalyzeArtifactStatus(t, failedAnalyze, "chronicle", manifest.AnalyzeArtifactCurrent)
|
||||||
|
assertAnalyzeArtifactStatus(t, failedAnalyze, "encounters", manifest.AnalyzeArtifactCurrent)
|
||||||
|
assertAnalyzeArtifactStatus(t, failedAnalyze, "quest_log", manifest.AnalyzeArtifactFailed)
|
||||||
|
assertAnalyzeArtifactStatus(t, failedAnalyze, "player_handout", manifest.AnalyzeArtifactStale)
|
||||||
|
gotUnrelated := failedAnalyze.AnalyzeArtifacts["unrelated"]
|
||||||
|
if diff := cmp.Diff(unrelated, gotUnrelated); diff != "" {
|
||||||
|
t.Fatalf("unrelated artifact changed (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"publish", "notify"} {
|
||||||
|
if got := failed.Stages[name]; got == nil || got.Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("%s stage = %#v, want stale", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertStageRecordsUnchanged(t, failed, upstream)
|
||||||
|
|
||||||
|
retryRunner := &analyzeCheckpointRunner{}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
|
||||||
|
Env: &Env{ManifestStore: store, Scriptorium: retryRunner},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("retry executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if want := []string{"quest-log-revised", "player-handout-revised"}; !slices.Equal(retryRunner.Calls, want) {
|
||||||
|
t.Fatalf("retry calls = %#v, want %#v", retryRunner.Calls, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
completed := loadAnalyzeRecoveryManifest(t, store, cfg)
|
||||||
|
if completed.Stages["analyze"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("aggregate analyze = %#v, want succeeded", completed.Stages["analyze"])
|
||||||
|
}
|
||||||
|
for _, key := range []string{"chronicle", "encounters", "player_handout", "quest_log", "unrelated"} {
|
||||||
|
assertAnalyzeArtifactStatus(t, completed.Stages["analyze"], key, manifest.AnalyzeArtifactCurrent)
|
||||||
|
}
|
||||||
|
assertStageRecordsUnchanged(t, completed, upstream)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesJoinsAnalyzeFailureWithTerminalSaveFailure(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
|
||||||
|
Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
||||||
|
"chronicle": {Enabled: true, PromptID: "chronicle", OutputPath: "artifacts/chronicle.md"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
artifactErr := errors.New("injected analyze failure")
|
||||||
|
terminalErr := errors.New("injected terminal save failure")
|
||||||
|
store := &failedAnalyzeSaveStore{Store: &manifest.LocalStore{}, Err: terminalErr}
|
||||||
|
runner := &analyzeCheckpointRunner{FailPrompt: "chronicle", Err: artifactErr}
|
||||||
|
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{mustSelectStage(t, "analyze")}, RunOptions{
|
||||||
|
Env: &Env{ManifestStore: store, Scriptorium: runner},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, artifactErr) || !errors.Is(err, terminalErr) {
|
||||||
|
t.Fatalf("executeStages() error = %v, want analyze and terminal save failures", err)
|
||||||
|
}
|
||||||
|
if !store.Failed {
|
||||||
|
t.Fatal("terminal save failure was not injected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesPersistsAnalyzeCheckpointFailureRecovery(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
|
||||||
|
Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
||||||
|
"chronicle": {Enabled: true, PromptID: "chronicle", OutputPath: "artifacts/chronicle.md"},
|
||||||
|
"encounters": {Enabled: true, PromptID: "encounters", OutputPath: "artifacts/encounters.md"},
|
||||||
|
"quest_log": {Enabled: true, PromptID: "quest-log", OutputPath: "artifacts/quest_log.md"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
baseStore := &manifest.LocalStore{}
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
seed.Campaign = cfg.Session.Campaign
|
||||||
|
seed.MarkStageSucceeded("publish", time.Now().UTC(), nil)
|
||||||
|
seed.MarkStageSucceeded("notify", time.Now().UTC(), nil)
|
||||||
|
saveBoundedManifest(t, cfg, seed)
|
||||||
|
checkpointErr := errors.New("injected artifact checkpoint failure")
|
||||||
|
store := ¤tAnalyzeCheckpointFailingStore{
|
||||||
|
Store: baseStore,
|
||||||
|
CurrentLimit: 2,
|
||||||
|
Err: checkpointErr,
|
||||||
|
}
|
||||||
|
runner := &analyzeCheckpointRunner{}
|
||||||
|
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{mustSelectStage(t, "analyze")}, RunOptions{
|
||||||
|
Env: &Env{ManifestStore: store, Scriptorium: runner},
|
||||||
|
})
|
||||||
|
if !errors.Is(err, checkpointErr) {
|
||||||
|
t.Fatalf("executeStages() error = %v, want checkpoint failure", err)
|
||||||
|
}
|
||||||
|
if want := []string{"chronicle", "encounters"}; !slices.Equal(runner.Calls, want) {
|
||||||
|
t.Fatalf("adapter calls = %#v, want %#v", runner.Calls, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
persisted := loadAnalyzeRecoveryManifest(t, baseStore, cfg)
|
||||||
|
if persisted.Stages["analyze"].Status != manifest.StatusFailed {
|
||||||
|
t.Fatalf("aggregate analyze = %#v, want failed", persisted.Stages["analyze"])
|
||||||
|
}
|
||||||
|
assertAnalyzeArtifactStatus(t, persisted.Stages["analyze"], "chronicle", manifest.AnalyzeArtifactCurrent)
|
||||||
|
assertAnalyzeArtifactStatus(t, persisted.Stages["analyze"], "encounters", manifest.AnalyzeArtifactFailed)
|
||||||
|
if _, exists := persisted.Stages["analyze"].AnalyzeArtifacts["quest_log"]; exists {
|
||||||
|
t.Fatalf("unattempted quest log appeared: %#v", persisted.Stages["analyze"].AnalyzeArtifacts["quest_log"])
|
||||||
|
}
|
||||||
|
for _, name := range []string{"publish", "notify"} {
|
||||||
|
if got := persisted.Stages[name]; got == nil || got.Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("%s stage = %#v, want stale", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type analyzeCheckpointRunner struct {
|
||||||
|
Calls []string
|
||||||
|
FailPrompt string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type failedAnalyzeSaveStore struct {
|
||||||
|
manifest.Store
|
||||||
|
Err error
|
||||||
|
Failed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type currentAnalyzeCheckpointFailingStore struct {
|
||||||
|
manifest.Store
|
||||||
|
CurrentLimit int
|
||||||
|
Err error
|
||||||
|
Failed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *currentAnalyzeCheckpointFailingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
||||||
|
if !s.Failed {
|
||||||
|
current := 0
|
||||||
|
if record := m.Stages["analyze"]; record != nil && record.Status == manifest.StatusRunning {
|
||||||
|
for _, artifact := range record.AnalyzeArtifacts {
|
||||||
|
if artifact.Status == manifest.AnalyzeArtifactCurrent {
|
||||||
|
current++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if current >= s.CurrentLimit {
|
||||||
|
s.Failed = true
|
||||||
|
return s.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.Store.Save(ctx, path, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failedAnalyzeSaveStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
||||||
|
if record := m.Stages["analyze"]; !s.Failed && record != nil && record.Status == manifest.StatusFailed {
|
||||||
|
s.Failed = true
|
||||||
|
return s.Err
|
||||||
|
}
|
||||||
|
return s.Store.Save(ctx, path, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *analyzeCheckpointRunner) RunArtifact(
|
||||||
|
ctx context.Context,
|
||||||
|
req scriptorium.RunArtifactRequest,
|
||||||
|
) (scriptorium.ArtifactResult, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return scriptorium.ArtifactResult{}, err
|
||||||
|
}
|
||||||
|
r.Calls = append(r.Calls, req.PromptID)
|
||||||
|
if req.PromptID == r.FailPrompt {
|
||||||
|
return scriptorium.ArtifactResult{}, r.Err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(req.OutputPath), 0o755); err != nil {
|
||||||
|
return scriptorium.ArtifactResult{}, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(req.OutputPath, []byte("generated "+req.PromptID+"\n"), 0o644); err != nil {
|
||||||
|
return scriptorium.ArtifactResult{}, err
|
||||||
|
}
|
||||||
|
return scriptorium.ArtifactResult{
|
||||||
|
OutputPath: req.OutputPath,
|
||||||
|
CommandMode: scriptorium.CommandModeRun,
|
||||||
|
PromptID: req.PromptID,
|
||||||
|
ProfileID: req.ProfileID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*analyzeCheckpointRunner) RenderArtifact(
|
||||||
|
context.Context,
|
||||||
|
scriptorium.RenderArtifactRequest,
|
||||||
|
) (scriptorium.ArtifactResult, error) {
|
||||||
|
return scriptorium.ArtifactResult{}, errors.New("unexpected render")
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkpointRecoveryArtifacts(revision string) map[string]config.ScriptoriumArtifactConfig {
|
||||||
|
return map[string]config.ScriptoriumArtifactConfig{
|
||||||
|
"chronicle": {
|
||||||
|
Enabled: true, PromptID: "chronicle-" + revision, OutputPath: "artifacts/chronicle.md",
|
||||||
|
},
|
||||||
|
"encounters": {
|
||||||
|
Enabled: true, DependsOn: []string{"chronicle"}, PromptID: "encounters-" + revision,
|
||||||
|
OutputPath: "artifacts/encounters.md",
|
||||||
|
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||||
|
"chronicle": {Source: "narratio.artifact.chronicle", Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"quest_log": {
|
||||||
|
Enabled: true, DependsOn: []string{"encounters"}, PromptID: "quest-log-" + revision,
|
||||||
|
OutputPath: "artifacts/quest_log.md",
|
||||||
|
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||||
|
"encounters": {Source: "narratio.artifact.encounters", Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"player_handout": {
|
||||||
|
Enabled: true, DependsOn: []string{"quest_log"}, PromptID: "player-handout-" + revision,
|
||||||
|
OutputPath: "artifacts/player_handout.md",
|
||||||
|
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||||
|
"quest_log": {Source: "narratio.artifact.quest_log", Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"unrelated": {
|
||||||
|
Enabled: true, PromptID: "unrelated", OutputPath: "artifacts/unrelated.md",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustSelectStage(t *testing.T, name string) stage.Stage {
|
||||||
|
t.Helper()
|
||||||
|
selected, err := stage.Select(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Select(%q) error = %v", name, err)
|
||||||
|
}
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAnalyzeRecoveryManifest(t *testing.T, store manifest.Store, cfg *config.Config) *manifest.Manifest {
|
||||||
|
t.Helper()
|
||||||
|
m, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load session manifest: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAnalyzeArtifactStatus(
|
||||||
|
t *testing.T,
|
||||||
|
record *manifest.StageRecord,
|
||||||
|
key string,
|
||||||
|
want manifest.AnalyzeArtifactStatus,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
got, exists := record.AnalyzeArtifacts[key]
|
||||||
|
if !exists || got.Status != want {
|
||||||
|
t.Fatalf("artifact %q = %#v, want status %q", key, got, want)
|
||||||
|
}
|
||||||
|
if want != manifest.AnalyzeArtifactCurrent && got.Output != nil {
|
||||||
|
t.Fatalf("artifact %q retained output authority: %#v", key, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStageRecords(m *manifest.Manifest, names ...string) map[string]manifest.StageRecord {
|
||||||
|
clones := make(map[string]manifest.StageRecord, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
clones[name] = *m.Stages[name]
|
||||||
|
}
|
||||||
|
return clones
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertStageRecordsUnchanged(t *testing.T, m *manifest.Manifest, want map[string]manifest.StageRecord) {
|
||||||
|
t.Helper()
|
||||||
|
for name, expected := range want {
|
||||||
|
got := m.Stages[name]
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("upstream stage %q is missing", name)
|
||||||
|
}
|
||||||
|
if diff := cmp.Diff(expected, *got); diff != "" {
|
||||||
|
t.Fatalf("upstream stage %q changed (-want +got):\n%s", name, diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"sort"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
|
||||||
|
|
||||||
type validatedAnalyzeProjection struct {
|
|
||||||
session map[string]manifest.AnalyzeArtifactRecord
|
|
||||||
invocation map[string]manifest.AnalyzeArtifactRecord
|
|
||||||
}
|
|
||||||
|
|
||||||
type analyzeStateSnapshot struct {
|
|
||||||
version int
|
|
||||||
records map[string]manifest.AnalyzeArtifactRecord
|
|
||||||
}
|
|
||||||
|
|
||||||
func captureAnalyzeState(manifestValue *manifest.Manifest, stageName string) analyzeStateSnapshot {
|
|
||||||
if manifestValue == nil || stageName != "analyze" || manifestValue.Stages["analyze"] == nil {
|
|
||||||
return analyzeStateSnapshot{}
|
|
||||||
}
|
|
||||||
record := manifestValue.Stages["analyze"]
|
|
||||||
return analyzeStateSnapshot{
|
|
||||||
version: record.AnalyzeStateVersion,
|
|
||||||
records: manifest.CloneAnalyzeArtifactCollection(record.AnalyzeArtifacts),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func restoreAnalyzeState(manifestValue *manifest.Manifest, snapshot analyzeStateSnapshot) {
|
|
||||||
if manifestValue == nil || manifestValue.Stages["analyze"] == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
record := manifestValue.Stages["analyze"]
|
|
||||||
record.AnalyzeStateVersion = snapshot.version
|
|
||||||
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(snapshot.records)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSuccessfulAnalyzeProjection(stageName string, result *stage.StageResult) (*validatedAnalyzeProjection, error) {
|
|
||||||
if result == nil || result.AnalyzeState == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if stageName != "analyze" {
|
|
||||||
return nil, fmt.Errorf("stage %q returned analyze-owned state projection", stageName)
|
|
||||||
}
|
|
||||||
if result.Disposition == stage.StageDispositionSkipped {
|
|
||||||
return nil, fmt.Errorf("skipped analyze result cannot contain analyze-owned state projection")
|
|
||||||
}
|
|
||||||
if len(result.Outputs) != 0 {
|
|
||||||
return nil, fmt.Errorf("analyze result with state projection cannot contain ordinary outputs")
|
|
||||||
}
|
|
||||||
return validateAndCloneAnalyzeProjection(result.AnalyzeState)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateFailedAnalyzeProjection(stageName string, result *stage.StageResult) (*validatedAnalyzeProjection, error) {
|
|
||||||
if result == nil || result.AnalyzeState == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if stageName != "analyze" {
|
|
||||||
return nil, fmt.Errorf("stage %q returned analyze-owned state projection with an error", stageName)
|
|
||||||
}
|
|
||||||
if result.Disposition != stage.StageDispositionSucceeded || result.SkipReason != "" || len(result.Outputs) != 0 || len(result.Logs) != 0 || len(result.GeneratedConfigs) != 0 || len(result.Metadata) != 0 {
|
|
||||||
return nil, fmt.Errorf("analyze result with an error may contain only analyze-owned state projection")
|
|
||||||
}
|
|
||||||
return validateAndCloneAnalyzeProjection(result.AnalyzeState)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAndCloneAnalyzeProjection(projection *stage.AnalyzeStateProjection) (*validatedAnalyzeProjection, error) {
|
|
||||||
session := manifest.CloneAnalyzeArtifactCollection(projection.Session)
|
|
||||||
invocation := manifest.CloneAnalyzeArtifactCollection(projection.Invocation)
|
|
||||||
if err := manifest.ValidateAnalyzeArtifactCollection(manifest.AnalyzeStateContractVersion, session); err != nil {
|
|
||||||
return nil, fmt.Errorf("validate reconciled session analyze state: %w", err)
|
|
||||||
}
|
|
||||||
if err := manifest.ValidateAnalyzeArtifactCollection(manifest.AnalyzeStateContractVersion, invocation); err != nil {
|
|
||||||
return nil, fmt.Errorf("validate invocation analyze state: %w", err)
|
|
||||||
}
|
|
||||||
for key, invocationRecord := range invocation {
|
|
||||||
sessionRecord, ok := session[key]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("invocation analyze artifact %q is absent from reconciled session state", key)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(invocationRecord, sessionRecord) {
|
|
||||||
return nil, fmt.Errorf("invocation analyze artifact %q contradicts reconciled session state", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &validatedAnalyzeProjection{session: session, invocation: invocation}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyAnalyzeProjection(
|
|
||||||
sessionManifest *manifest.Manifest,
|
|
||||||
runManifest *manifest.RunManifest,
|
|
||||||
projection *validatedAnalyzeProjection,
|
|
||||||
) {
|
|
||||||
if projection == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if sessionManifest != nil && sessionManifest.Stages["analyze"] != nil {
|
|
||||||
record := sessionManifest.Stages["analyze"]
|
|
||||||
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
|
||||||
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.session)
|
|
||||||
}
|
|
||||||
if runManifest != nil && runManifest.Stages["analyze"] != nil {
|
|
||||||
record := runManifest.Stages["analyze"]
|
|
||||||
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
|
||||||
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.invocation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func analyzeProjectionOutputs(records map[string]manifest.AnalyzeArtifactRecord, producerRunID string) []manifest.ArtifactRecord {
|
|
||||||
keys := make([]string, 0, len(records))
|
|
||||||
for key, record := range records {
|
|
||||||
if record.Status != manifest.AnalyzeArtifactCurrent || record.Output == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if producerRunID != "" && record.ProducerRunID != producerRunID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
outputs := make([]manifest.ArtifactRecord, 0, len(keys))
|
|
||||||
for _, key := range keys {
|
|
||||||
record := manifest.CloneAnalyzeArtifactCollection(map[string]manifest.AnalyzeArtifactRecord{key: records[key]})[key]
|
|
||||||
output := *record.Output
|
|
||||||
if output.ProducerRunID == "" {
|
|
||||||
output.ProducerRunID = record.ProducerRunID
|
|
||||||
}
|
|
||||||
outputs = append(outputs, output)
|
|
||||||
}
|
|
||||||
return outputs
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
|
||||||
|
|
||||||
type projectionStage struct {
|
|
||||||
name string
|
|
||||||
run func(*stage.Env, *manifest.Manifest) (*stage.StageResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s projectionStage) Name() string { return s.name }
|
|
||||||
func (s projectionStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
|
||||||
return s.run(env, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteStagesProjectsSeparateSessionAndInvocationAnalyzeState(t *testing.T) {
|
|
||||||
cfg := testConfig(t)
|
|
||||||
oldAt := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
|
||||||
oldRecord := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", oldAt)
|
|
||||||
|
|
||||||
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
|
||||||
newRecord := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
|
||||||
staleRecord := appAnalyzeRecord("quest_log", manifest.AnalyzeArtifactStale, m.RunID, time.Now().UTC())
|
|
||||||
session := map[string]manifest.AnalyzeArtifactRecord{
|
|
||||||
"player_handout": oldRecord,
|
|
||||||
"quest_log": staleRecord,
|
|
||||||
"session_recap": newRecord,
|
|
||||||
}
|
|
||||||
return &stage.StageResult{
|
|
||||||
Logs: []string{"aggregate-analyze.log"},
|
|
||||||
AnalyzeState: &stage.AnalyzeStateProjection{
|
|
||||||
Session: session,
|
|
||||||
Invocation: map[string]manifest.AnalyzeArtifactRecord{
|
|
||||||
"player_handout": oldRecord,
|
|
||||||
"session_recap": newRecord,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
|
||||||
}
|
|
||||||
store := &manifest.LocalStore{}
|
|
||||||
sessionManifest, err := store.Load(context.Background(), summary.ManifestPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load(session) error = %v", err)
|
|
||||||
}
|
|
||||||
analyze := sessionManifest.Stages["analyze"]
|
|
||||||
if analyze.AnalyzeStateVersion != manifest.AnalyzeStateContractVersion || len(analyze.AnalyzeArtifacts) != 3 {
|
|
||||||
t.Fatalf("session analyze state = %#v", analyze)
|
|
||||||
}
|
|
||||||
if got := analyzeArtifactOutputKeys(analyze.Outputs); !reflect.DeepEqual(got, []string{"player_handout", "session_recap"}) {
|
|
||||||
t.Fatalf("session aggregate outputs = %#v, want current records only", got)
|
|
||||||
}
|
|
||||||
if len(analyze.Logs) != 1 || analyze.Logs[0] != "aggregate-analyze.log" {
|
|
||||||
t.Fatalf("session aggregate logs = %#v", analyze.Logs)
|
|
||||||
}
|
|
||||||
|
|
||||||
runManifest, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadRun() error = %v", err)
|
|
||||||
}
|
|
||||||
runAnalyze := runManifest.Stages["analyze"]
|
|
||||||
if len(runAnalyze.AnalyzeArtifacts) != 2 || runAnalyze.AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
|
|
||||||
t.Fatalf("invocation analyze state = %#v", runAnalyze.AnalyzeArtifacts)
|
|
||||||
}
|
|
||||||
if got := analyzeArtifactOutputKeys(runAnalyze.Outputs); !reflect.DeepEqual(got, []string{"session_recap"}) {
|
|
||||||
t.Fatalf("invocation outputs = %#v, want produced artifact only", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteStagesPersistsRestrictedAnalyzeStateOnPartialError(t *testing.T) {
|
|
||||||
cfg := testConfig(t)
|
|
||||||
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
|
||||||
seed := manifest.New(cfg.Session.SessionID, now)
|
|
||||||
seed.Campaign = cfg.Session.Campaign
|
|
||||||
seed.MarkStageSucceeded("analyze", now, nil)
|
|
||||||
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
|
||||||
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{
|
|
||||||
"player_handout": appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now),
|
|
||||||
}
|
|
||||||
seed.MarkStageSucceeded("publish", now, nil)
|
|
||||||
saveBoundedManifest(t, cfg, seed)
|
|
||||||
|
|
||||||
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
|
||||||
unrelated := seed.Stages["analyze"].AnalyzeArtifacts["player_handout"]
|
|
||||||
completed := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
|
||||||
failed := appAnalyzeRecord("quest_log", manifest.AnalyzeArtifactFailed, m.RunID, time.Now().UTC())
|
|
||||||
session := map[string]manifest.AnalyzeArtifactRecord{
|
|
||||||
"player_handout": unrelated,
|
|
||||||
"quest_log": failed,
|
|
||||||
"session_recap": completed,
|
|
||||||
}
|
|
||||||
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
|
||||||
Session: session,
|
|
||||||
Invocation: map[string]manifest.AnalyzeArtifactRecord{
|
|
||||||
"quest_log": failed,
|
|
||||||
"session_recap": completed,
|
|
||||||
},
|
|
||||||
}}, errors.New("quest log failed")
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: true})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "quest log failed") {
|
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
|
||||||
}
|
|
||||||
store := &manifest.LocalStore{}
|
|
||||||
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load(session) error = %v", err)
|
|
||||||
}
|
|
||||||
analyze := loaded.Stages["analyze"]
|
|
||||||
if analyze.Status != manifest.StatusFailed || len(analyze.Outputs) != 0 {
|
|
||||||
t.Fatalf("aggregate analyze state = %#v, want failed without outputs", analyze)
|
|
||||||
}
|
|
||||||
if analyze.AnalyzeArtifacts["player_handout"].Status != manifest.AnalyzeArtifactCurrent ||
|
|
||||||
analyze.AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent ||
|
|
||||||
analyze.AnalyzeArtifacts["quest_log"].Status != manifest.AnalyzeArtifactFailed {
|
|
||||||
t.Fatalf("partial session projection = %#v", analyze.AnalyzeArtifacts)
|
|
||||||
}
|
|
||||||
if loaded.Stages["publish"].Status != manifest.StatusStale {
|
|
||||||
t.Fatalf("publish status = %q, want stale", loaded.Stages["publish"].Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
runsDir := artifacts.SessionRunsDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
||||||
entries, err := os.ReadDir(runsDir)
|
|
||||||
if err != nil || len(entries) != 1 {
|
|
||||||
t.Fatalf("run directory entries = %#v, error = %v", entries, err)
|
|
||||||
}
|
|
||||||
runManifest, err := store.LoadRun(context.Background(), filepath.Join(runsDir, entries[0].Name(), "manifest.json"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadRun() error = %v", err)
|
|
||||||
}
|
|
||||||
runAnalyze := runManifest.Stages["analyze"]
|
|
||||||
if runAnalyze.Status != manifest.StatusFailed || len(runAnalyze.AnalyzeArtifacts) != 2 || len(runAnalyze.Outputs) != 0 {
|
|
||||||
t.Fatalf("partial invocation projection = %#v", runAnalyze)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteStagesRejectsInvalidAnalyzeProjectionWithoutReplacingPriorState(t *testing.T) {
|
|
||||||
cfg := testConfig(t)
|
|
||||||
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
|
||||||
prior := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now)
|
|
||||||
seed := manifest.New(cfg.Session.SessionID, now)
|
|
||||||
seed.Campaign = cfg.Session.Campaign
|
|
||||||
seed.MarkStageSucceeded("analyze", now, nil)
|
|
||||||
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
|
||||||
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{"player_handout": prior}
|
|
||||||
saveBoundedManifest(t, cfg, seed)
|
|
||||||
|
|
||||||
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
|
||||||
invalid := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
|
||||||
invalid.Output.Checksum = "invalid"
|
|
||||||
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
|
||||||
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": invalid},
|
|
||||||
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": invalid},
|
|
||||||
}}, errors.New("analysis failed")
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: true})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "checksum") {
|
|
||||||
t.Fatalf("executeStages() error = %v, want projection validation failure", err)
|
|
||||||
}
|
|
||||||
loaded, loadErr := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
|
||||||
if loadErr != nil {
|
|
||||||
t.Fatalf("Load() error = %v", loadErr)
|
|
||||||
}
|
|
||||||
if len(loaded.Stages["analyze"].AnalyzeArtifacts) != 1 || !reflect.DeepEqual(loaded.Stages["analyze"].AnalyzeArtifacts["player_handout"], prior) {
|
|
||||||
t.Fatalf("prior state replaced by invalid projection: %#v", loaded.Stages["analyze"].AnalyzeArtifacts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteStagesRollsBackAnalyzeAuthorityWhenProjectionSaveFails(t *testing.T) {
|
|
||||||
cfg := testConfig(t)
|
|
||||||
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
|
||||||
prior := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now)
|
|
||||||
seed := manifest.New(cfg.Session.SessionID, now)
|
|
||||||
seed.Campaign = cfg.Session.Campaign
|
|
||||||
seed.MarkStageSucceeded("analyze", now, nil)
|
|
||||||
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
|
||||||
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{"player_handout": prior}
|
|
||||||
saveBoundedManifest(t, cfg, seed)
|
|
||||||
|
|
||||||
stageReturned := false
|
|
||||||
store := &analyzeProjectionFailingStore{delegate: &manifest.LocalStore{}, shouldFail: func(m *manifest.Manifest) bool {
|
|
||||||
return stageReturned && m.Stages["analyze"] != nil && m.Stages["analyze"].Status == manifest.StatusSucceeded && m.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status == manifest.AnalyzeArtifactCurrent
|
|
||||||
}}
|
|
||||||
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
|
||||||
stageReturned = true
|
|
||||||
newRecord := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
|
||||||
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
|
||||||
Session: map[string]manifest.AnalyzeArtifactRecord{
|
|
||||||
"player_handout": prior,
|
|
||||||
"session_recap": newRecord,
|
|
||||||
},
|
|
||||||
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": newRecord},
|
|
||||||
}}, nil
|
|
||||||
}}
|
|
||||||
|
|
||||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{
|
|
||||||
Force: true,
|
|
||||||
Env: &Env{ManifestStore: store},
|
|
||||||
})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "injected analyze projection save failure") {
|
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
|
||||||
}
|
|
||||||
if !store.failed {
|
|
||||||
t.Fatal("projection persistence failure was not injected")
|
|
||||||
}
|
|
||||||
loaded, loadErr := store.delegate.Load(context.Background(), manifestPathFor(cfg))
|
|
||||||
if loadErr != nil {
|
|
||||||
t.Fatalf("Load() error = %v", loadErr)
|
|
||||||
}
|
|
||||||
analyze := loaded.Stages["analyze"]
|
|
||||||
if analyze.Status != manifest.StatusFailed || len(analyze.AnalyzeArtifacts) != 1 || !reflect.DeepEqual(analyze.AnalyzeArtifacts["player_handout"], prior) {
|
|
||||||
t.Fatalf("durable analyze state after rollback = %#v", analyze)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteStagesRejectsAnalyzeProjectionFromOtherStage(t *testing.T) {
|
|
||||||
cfg := testConfig(t)
|
|
||||||
stageToRun := projectionStage{name: "prepare", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
|
||||||
record := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
|
||||||
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
|
||||||
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
|
|
||||||
}}, nil
|
|
||||||
}}
|
|
||||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "returned analyze-owned state projection") {
|
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteStagesRejectsContradictoryAnalyzeResultWithError(t *testing.T) {
|
|
||||||
cfg := testConfig(t)
|
|
||||||
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
|
||||||
record := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
|
||||||
return &stage.StageResult{
|
|
||||||
Outputs: []artifacts.Ref{{Kind: "session_recap", RelativePath: "artifacts/session-recap.md"}},
|
|
||||||
AnalyzeState: &stage.AnalyzeStateProjection{
|
|
||||||
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
|
|
||||||
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
|
|
||||||
},
|
|
||||||
}, errors.New("analysis failed")
|
|
||||||
}}
|
|
||||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "may contain only analyze-owned state projection") {
|
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExecuteStagesExposesSelectedForceDecisionToStage(t *testing.T) {
|
|
||||||
for _, force := range []bool{false, true} {
|
|
||||||
t.Run(strings.ToLower(strings.TrimSpace(map[bool]string{false: "ordinary", true: "forced"}[force])), func(t *testing.T) {
|
|
||||||
cfg := testConfig(t)
|
|
||||||
captured := !force
|
|
||||||
stageToRun := projectionStage{name: "prepare", run: func(env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
|
||||||
captured = env.Force
|
|
||||||
return &stage.StageResult{}, nil
|
|
||||||
}}
|
|
||||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: force}); err != nil {
|
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
|
||||||
}
|
|
||||||
if captured != force {
|
|
||||||
t.Fatalf("stage env force = %v, want %v", captured, force)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func appAnalyzeRecord(key string, status manifest.AnalyzeArtifactStatus, producerRunID string, at time.Time) manifest.AnalyzeArtifactRecord {
|
|
||||||
record := manifest.AnalyzeArtifactRecord{
|
|
||||||
Key: key,
|
|
||||||
Status: status,
|
|
||||||
ProducerRunID: producerRunID,
|
|
||||||
UpdatedAt: at,
|
|
||||||
}
|
|
||||||
if status == manifest.AnalyzeArtifactFailed {
|
|
||||||
record.Error = "scriptorium failed"
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
if status != manifest.AnalyzeArtifactCurrent {
|
|
||||||
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
|
|
||||||
record.Fingerprint = strings.Repeat("b", 64)
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
|
|
||||||
record.Fingerprint = strings.Repeat("a", 64)
|
|
||||||
record.OutputSize = 42
|
|
||||||
record.Output = &manifest.ArtifactRecord{
|
|
||||||
Kind: key,
|
|
||||||
SourceID: artifacts.ConfiguredArtifactSourceID(key),
|
|
||||||
LocalPath: "artifacts/" + strings.ReplaceAll(key, "_", "-") + ".md",
|
|
||||||
ProducerRunID: producerRunID,
|
|
||||||
Checksum: strings.Repeat("c", 64),
|
|
||||||
Contract: &artifactmodel.ContractMetadata{
|
|
||||||
MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return record
|
|
||||||
}
|
|
||||||
|
|
||||||
func analyzeArtifactOutputKeys(outputs []manifest.ArtifactRecord) []string {
|
|
||||||
keys := make([]string, 0, len(outputs))
|
|
||||||
for _, output := range outputs {
|
|
||||||
keys = append(keys, strings.TrimPrefix(output.SourceID, "narratio.artifact."))
|
|
||||||
}
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
type analyzeProjectionFailingStore struct {
|
|
||||||
delegate *manifest.LocalStore
|
|
||||||
shouldFail func(*manifest.Manifest) bool
|
|
||||||
failed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *analyzeProjectionFailingStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
|
|
||||||
return s.delegate.Create(ctx, sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *analyzeProjectionFailingStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
|
|
||||||
return s.delegate.Load(ctx, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *analyzeProjectionFailingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
|
||||||
if !s.failed && s.shouldFail != nil && s.shouldFail(m) {
|
|
||||||
s.failed = true
|
|
||||||
return errors.New("injected analyze projection save failure")
|
|
||||||
}
|
|
||||||
return s.delegate.Save(ctx, path, m)
|
|
||||||
}
|
|
||||||
43
internal/app/artifact_evidence_test.go
Normal file
43
internal/app/artifact_evidence_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setCurrentBuiltInEvidence(t *testing.T, m *manifest.Manifest, stage, kind, sourceID, path string) {
|
||||||
|
t.Helper()
|
||||||
|
checksum, err := artifacts.SHA256File(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.MarkStageSucceeded(stage, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), []manifest.ArtifactRecord{{
|
||||||
|
Kind: kind, SourceID: sourceID, LocalPath: path, ProducerRunID: stage + "-run", Checksum: checksum,
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedCurrentBuiltInEvidence(t *testing.T, manifestPath, sessionID, campaign, stage, kind, sourceID, path string) {
|
||||||
|
t.Helper()
|
||||||
|
m := manifest.New(sessionID, time.Now().UTC())
|
||||||
|
m.Campaign = campaign
|
||||||
|
setCurrentBuiltInEvidence(t, m, stage, kind, sourceID, path)
|
||||||
|
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCurrentPreviousArtifactInput(t *testing.T, m *manifest.Manifest, path string) {
|
||||||
|
t.Helper()
|
||||||
|
checksum, err := artifacts.SHA256File(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m.Inputs = append(m.Inputs, manifest.InputRecord{
|
||||||
|
Kind: "previous_artifact", Source: artifactpolicy.PreviousSessionSourceID("session_recap"), Path: path, Checksum: checksum,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,11 +19,11 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAssembledFullRunUsesCanonicalOrderAndBoundedRunManifests(t *testing.T) {
|
func TestAssembledFullRunUsesCanonicalOrder(t *testing.T) {
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
canonical := []string{
|
canonical := []string{
|
||||||
"prepare", "transcribe", "merge", "polish", "normalize", "trim",
|
"prepare", "transcribe", "merge", "polish", "normalize", "trim",
|
||||||
"render", "extract", "analyze", "publish", "notify",
|
"render", "extract", "analyze", "publish", "notify", "cleanup",
|
||||||
}
|
}
|
||||||
var order []string
|
var order []string
|
||||||
stages := make([]stage.Stage, 0, len(canonical))
|
stages := make([]stage.Stage, 0, len(canonical))
|
||||||
@@ -34,13 +37,6 @@ func TestAssembledFullRunUsesCanonicalOrderAndBoundedRunManifests(t *testing.T)
|
|||||||
if !reflect.DeepEqual(order, canonical) || !reflect.DeepEqual(summary.Executed, canonical) {
|
if !reflect.DeepEqual(order, canonical) || !reflect.DeepEqual(summary.Executed, canonical) {
|
||||||
t.Fatalf("execution order=%#v summary=%#v, want %#v", order, summary.Executed, canonical)
|
t.Fatalf("execution order=%#v summary=%#v, want %#v", order, summary.Executed, canonical)
|
||||||
}
|
}
|
||||||
runManifest, err := (&manifest.LocalStore{}).LoadRun(context.Background(), summary.RunManifestPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(runManifest.RequestedStages, canonical) {
|
|
||||||
t.Fatalf("requested stages = %#v, want canonical order", runManifest.RequestedStages)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing.T) {
|
func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing.T) {
|
||||||
@@ -52,9 +48,7 @@ func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing.
|
|||||||
force bool
|
force bool
|
||||||
}
|
}
|
||||||
var captured []capturedRequest
|
var captured []capturedRequest
|
||||||
original := executeStagesFn
|
executor := func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||||
t.Cleanup(func() { executeStagesFn = original })
|
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
|
||||||
captured = append(captured, capturedRequest{
|
captured = append(captured, capturedRequest{
|
||||||
stages: plan.Names(), artifacts: append([]string(nil), options.SelectedArtifacts...), force: options.Force,
|
stages: plan.Names(), artifacts: append([]string(nil), options.SelectedArtifacts...), force: options.Force,
|
||||||
})
|
})
|
||||||
@@ -64,11 +58,12 @@ func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing.
|
|||||||
"2026-05-03", "--force", "--from", "extract", "--through", "analyze",
|
"2026-05-03", "--force", "--from", "extract", "--through", "analyze",
|
||||||
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
}
|
}
|
||||||
if err := Run(context.Background(), base, &bytes.Buffer{}); err != nil {
|
deps := fakeCommandDependencies(&storage.FakeBackend{}, nil)
|
||||||
|
if err := runWithDependencies(context.Background(), base, &bytes.Buffer{}, executor, deps); err != nil {
|
||||||
t.Fatalf("canonical unselected Run() error = %v", err)
|
t.Fatalf("canonical unselected Run() error = %v", err)
|
||||||
}
|
}
|
||||||
selected := append(append([]string(nil), base...), "--artifacts", "session_recap")
|
selected := append(append([]string(nil), base...), "--artifacts", "session_recap")
|
||||||
if err := Run(context.Background(), selected, &bytes.Buffer{}); err != nil {
|
if err := runWithDependencies(context.Background(), selected, &bytes.Buffer{}, executor, deps); err != nil {
|
||||||
t.Fatalf("canonical selected Run() error = %v", err)
|
t.Fatalf("canonical selected Run() error = %v", err)
|
||||||
}
|
}
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
@@ -76,7 +71,7 @@ func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing.
|
|||||||
"regenerate-artifacts", "2026-05-03", "--artifacts", "session_recap",
|
"regenerate-artifacts", "2026-05-03", "--artifacts", "session_recap",
|
||||||
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
}
|
}
|
||||||
if code := Execute(alias, &stdout, &stderr); code != 0 {
|
if code := executeWithDependencies(alias, &stdout, &stderr, deps, executor); code != 0 {
|
||||||
t.Fatalf("alias exit=%d stderr=%q", code, stderr.String())
|
t.Fatalf("alias exit=%d stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
if len(captured) != 3 {
|
if len(captured) != 3 {
|
||||||
@@ -100,7 +95,7 @@ func TestAssembledForcedSiblingIndependenceAndFailureBoundary(t *testing.T) {
|
|||||||
plan := mustBoundedPlan(t, selected, selected)
|
plan := mustBoundedPlan(t, selected, selected)
|
||||||
runs := 0
|
runs := 0
|
||||||
plan.stages = []stage.Stage{countingStage{name: selected, runs: &runs}}
|
plan.stages = []stage.Stage{countingStage{name: selected, runs: &runs}}
|
||||||
summary, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -112,18 +107,11 @@ func TestAssembledForcedSiblingIndependenceAndFailureBoundary(t *testing.T) {
|
|||||||
if loaded.Stages[sibling].Status != manifest.StatusSucceeded {
|
if loaded.Stages[sibling].Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("%s sibling = %#v, want succeeded", sibling, loaded.Stages[sibling])
|
t.Fatalf("%s sibling = %#v, want succeeded", sibling, loaded.Stages[sibling])
|
||||||
}
|
}
|
||||||
for _, dependent := range []string{"analyze", "publish", "notify"} {
|
for _, dependent := range []string{"analyze", "publish", "notify", "cleanup"} {
|
||||||
if loaded.Stages[dependent].Status != manifest.StatusStale {
|
if loaded.Stages[dependent].Status != manifest.StatusStale {
|
||||||
t.Fatalf("%s status = %q, want stale", dependent, loaded.Stages[dependent].Status)
|
t.Fatalf("%s status = %q, want stale", dependent, loaded.Stages[dependent].Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runManifest, err := (&manifest.LocalStore{}).LoadRun(context.Background(), summary.RunManifestPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(runManifest.RequestedStages, []string{selected}) || len(runManifest.Stages) != 1 {
|
|
||||||
t.Fatalf("bounded run manifest = %#v", runManifest)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +128,7 @@ func TestAssembledForcedSiblingIndependenceAndFailureBoundary(t *testing.T) {
|
|||||||
if loaded.Stages["extract"].Status != manifest.StatusSucceeded {
|
if loaded.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("extract sibling = %#v", loaded.Stages["extract"])
|
t.Fatalf("extract sibling = %#v", loaded.Stages["extract"])
|
||||||
}
|
}
|
||||||
for _, outside := range []string{"analyze", "publish", "notify"} {
|
for _, outside := range []string{"analyze", "publish", "notify", "cleanup"} {
|
||||||
if loaded.Stages[outside].Status != manifest.StatusStale {
|
if loaded.Stages[outside].Status != manifest.StatusStale {
|
||||||
t.Fatalf("outside stage %s = %#v, want stale and unexecuted", outside, loaded.Stages[outside])
|
t.Fatalf("outside stage %s = %#v, want stale and unexecuted", outside, loaded.Stages[outside])
|
||||||
}
|
}
|
||||||
@@ -228,20 +216,13 @@ func TestAssembledLegacyAnalyzeTransitionPublishesOnlyCurrentRecords(t *testing.
|
|||||||
t.Fatalf("legacy unselected handout catalog entry = %#v, present=%v", entry, ok)
|
t.Fatalf("legacy unselected handout catalog entry = %#v, present=%v", entry, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
full, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{Env: &Env{Scriptorium: fake}})
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{Env: &Env{Scriptorium: fake}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("full regeneration: %v", err)
|
t.Fatalf("full regeneration: %v", err)
|
||||||
}
|
}
|
||||||
if len(fake.RunRequests) != 2 || fake.RunRequests[1].PromptID != "dnd.player_handout" {
|
if len(fake.RunRequests) != 2 || fake.RunRequests[1].PromptID != "dnd.player_handout" {
|
||||||
t.Fatalf("full requests = %#v, want only missing handout added", fake.RunRequests)
|
t.Fatalf("full requests = %#v, want only missing handout added", fake.RunRequests)
|
||||||
}
|
}
|
||||||
fullRun, err := (&manifest.LocalStore{}).LoadRun(context.Background(), full.RunManifestPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := analyzeArtifactOutputKeys(fullRun.Stages["analyze"].Outputs); !reflect.DeepEqual(got, []string{"player_handout"}) {
|
|
||||||
t.Fatalf("full invocation outputs = %#v, want newly generated handout only", got)
|
|
||||||
}
|
|
||||||
afterFull := loadAssembledManifest(t, cfg)
|
afterFull := loadAssembledManifest(t, cfg)
|
||||||
for _, key := range []string{"player_handout", "session_recap"} {
|
for _, key := range []string{"player_handout", "session_recap"} {
|
||||||
if afterFull.Stages["analyze"].AnalyzeArtifacts[key].Status != manifest.AnalyzeArtifactCurrent {
|
if afterFull.Stages["analyze"].AnalyzeArtifacts[key].Status != manifest.AnalyzeArtifactCurrent {
|
||||||
@@ -254,7 +235,7 @@ func TestAssembledLegacyAnalyzeTransitionPublishesOnlyCurrentRecords(t *testing.
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
remote := &storage.FakeBackend{}
|
remote := &storage.FakeBackend{}
|
||||||
published, err := executeStages(context.Background(), cfg, []stage.Stage{publish}, RunOptions{Env: &Env{ObjectStore: remote}})
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{publish}, RunOptions{Env: &Env{ObjectStore: remote}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("publish current records: %v", err)
|
t.Fatalf("publish current records: %v", err)
|
||||||
}
|
}
|
||||||
@@ -262,12 +243,182 @@ func TestAssembledLegacyAnalyzeTransitionPublishesOnlyCurrentRecords(t *testing.
|
|||||||
if got := afterPublish.Stages["publish"].Metadata["published_files_uploaded"]; got != float64(2) {
|
if got := afterPublish.Stages["publish"].Metadata["published_files_uploaded"]; got != float64(2) {
|
||||||
t.Fatalf("published files = %#v, want 2", got)
|
t.Fatalf("published files = %#v, want 2", got)
|
||||||
}
|
}
|
||||||
publishRun, err := (&manifest.LocalStore{}).LoadRun(context.Background(), published.RunManifestPath)
|
}
|
||||||
|
|
||||||
|
func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths()
|
||||||
|
workspacePath := filepath.Join(filepath.Dir(pipelinePath), "workspace")
|
||||||
|
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("example workspace stat = %v, want absent", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validated bytes.Buffer
|
||||||
|
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validated); err != nil {
|
||||||
|
t.Fatalf("validate production base: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(validated.String(), "profile=none") {
|
||||||
|
t.Fatalf("validate output = %q, want unprofiled production base", validated.String())
|
||||||
|
}
|
||||||
|
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigShow, ConfigSources} {
|
||||||
|
if err := command(context.Background(), []string{
|
||||||
|
"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing",
|
||||||
|
}, io.Discard); err != nil {
|
||||||
|
t.Fatalf("testing inspection command %T: %v", command, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var diff bytes.Buffer
|
||||||
|
if err := ConfigDiff(context.Background(), []string{
|
||||||
|
"default", "testing", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||||
|
}, &diff); err != nil {
|
||||||
|
t.Fatalf("compare profiles: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diff.String(), "audita.model") || !strings.Contains(diff.String(), "character_items_arannis.profile_id") {
|
||||||
|
t.Fatalf("profile diff = %q, want model and expanded family changes", diff.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
common := []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath, "--profile", "testing",
|
||||||
|
}
|
||||||
|
var planned bytes.Buffer
|
||||||
|
if err := Plan(context.Background(), common, &planned); err != nil {
|
||||||
|
t.Fatalf("plan testing bundle: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(planned.String(), "profile=testing") {
|
||||||
|
t.Fatalf("plan output = %q, want testing provenance", planned.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var capturedCfg *config.Config
|
||||||
|
var capturedPlan BoundedPlan
|
||||||
|
var capturedOptions RunOptions
|
||||||
|
executor := func(_ context.Context, cfg *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||||
|
capturedCfg = cfg
|
||||||
|
capturedPlan = plan
|
||||||
|
capturedOptions = options
|
||||||
|
return &RunSummary{SessionID: cfg.Session.SessionID, ManifestPath: manifestPathForConfig(cfg.Pipeline.Workspace.Root)}, nil
|
||||||
|
}
|
||||||
|
runArgs := append(append([]string(nil), common...), "--from", "analyze", "--through", "analyze", "--artifacts", "character_items_arannis")
|
||||||
|
var runOut bytes.Buffer
|
||||||
|
if err := runWithPipelineExecutor(context.Background(), runArgs, &runOut, executor); err != nil {
|
||||||
|
t.Fatalf("bounded family run: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(capturedPlan.Names(), []string{"analyze"}) {
|
||||||
|
t.Fatalf("bounded plan = %#v, want analyze only", capturedPlan.Names())
|
||||||
|
}
|
||||||
|
if profile, ok := config.SelectedPipelineProfile(capturedCfg.Pipeline); !ok || profile.Name != "testing" {
|
||||||
|
t.Fatalf("captured profile = %#v, selected=%t", profile, ok)
|
||||||
|
}
|
||||||
|
if digest := config.EffectivePipelineDigest(capturedCfg.Pipeline); digest == "" || !strings.Contains(runOut.String(), "digest="+digest) {
|
||||||
|
t.Fatalf("run output = %q, want effective digest %q", runOut.String(), digest)
|
||||||
|
}
|
||||||
|
if got := capturedOptions.EffectiveArtifacts.Keys(); !reflect.DeepEqual(got, []string{"character_items_arannis"}) {
|
||||||
|
t.Fatalf("effective artifact keys = %#v", got)
|
||||||
|
}
|
||||||
|
if origin, ok := capturedOptions.EffectiveArtifacts.Origin("character_items_arannis"); !ok || origin.Family != "character_items" || origin.CharacterID != "arannis" {
|
||||||
|
t.Fatalf("family origin = %#v, present=%t", origin, ok)
|
||||||
|
}
|
||||||
|
fullFamily, err := resolveEffectiveArtifacts(capturedCfg, []string{"character_items"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve complete family: %v", err)
|
||||||
|
}
|
||||||
|
if got := fullFamily.Keys(); !reflect.DeepEqual(got, []string{"character_items_arannis", "character_items_brenna"}) {
|
||||||
|
t.Fatalf("full family keys = %#v", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("inspection or bounded run created example workspace: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembledSplitBundleProfileSwitchRecordsProvenanceAndLimitsReuse(t *testing.T) {
|
||||||
|
production := loadAssembledSplitBundleConfig(t, "")
|
||||||
|
testingCfg := loadAssembledSplitBundleConfig(t, "testing")
|
||||||
|
workspace := t.TempDir()
|
||||||
|
production.Pipeline.Workspace.Root = workspace
|
||||||
|
testingCfg.Pipeline.Workspace.Root = workspace
|
||||||
|
|
||||||
|
names := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"}
|
||||||
|
providers := make([]stage.SemanticConfigFingerprinter, len(names))
|
||||||
|
seed := manifest.New(production.Session.SessionID, time.Now().UTC())
|
||||||
|
seed.Campaign = production.Session.Campaign
|
||||||
|
for _, candidate := range stage.All() {
|
||||||
|
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
for index, name := range names {
|
||||||
|
providers[index] = canonicalSemanticProvider(t, name)
|
||||||
|
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: production})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("production %s fingerprint: %v", name, err)
|
||||||
|
}
|
||||||
|
seed.Stages[name].SemanticConfig = &fingerprint
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
if err := store.Save(context.Background(), manifestPathForConfig(workspace), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
productionRuns := make([]int, len(names))
|
||||||
|
productionStages := assembledSemanticStages(names, providers, productionRuns)
|
||||||
|
productionSummary, err := executeStages(context.Background(), production, productionStages, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("record production provenance: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(productionRuns, make([]int, len(names))) {
|
||||||
|
t.Fatalf("production runs = %v, want complete reuse", productionRuns)
|
||||||
|
}
|
||||||
|
assertAssembledProvenance(t, store, productionSummary, "", config.EffectivePipelineDigest(production.Pipeline))
|
||||||
|
|
||||||
|
testingRuns := make([]int, len(names))
|
||||||
|
testingStages := assembledSemanticStages(names, providers, testingRuns)
|
||||||
|
testingSummary, err := executeStages(context.Background(), testingCfg, testingStages, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch to testing profile: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := testingRuns, []int{0, 0, 0, 1, 1, 1, 1}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("testing profile runs = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
assertAssembledProvenance(t, store, testingSummary, "testing", config.EffectivePipelineDigest(testingCfg.Pipeline))
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembledSplitBundlePaths() (pipelinePath, campaignPath, sessionPath string) {
|
||||||
|
examplesDir := filepath.Join("..", "..", "examples")
|
||||||
|
return filepath.Join(examplesDir, "production-testing", "pipeline.yml"),
|
||||||
|
filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml"),
|
||||||
|
filepath.Join(examplesDir, "session.local-audio.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAssembledSplitBundleConfig(t *testing.T, profile string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths()
|
||||||
|
options := config.SessionLoadOptions{}
|
||||||
|
if profile != "" {
|
||||||
|
options.Profile = &profile
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load split bundle profile %q: %v", profile, err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembledSemanticStages(names []string, providers []stage.SemanticConfigFingerprinter, runs []int) []stage.Stage {
|
||||||
|
stages := make([]stage.Stage, 0, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
stages = append(stages, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
|
||||||
|
}
|
||||||
|
return stages
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAssembledProvenance(t *testing.T, store *manifest.LocalStore, summary *RunSummary, profile, digest string) {
|
||||||
|
t.Helper()
|
||||||
|
session, err := store.Load(context.Background(), summary.ManifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(publishRun.RequestedStages, []string{"publish"}) || publishRun.Stages["analyze"] != nil {
|
value := session.EffectiveConfig
|
||||||
t.Fatalf("publish run manifest = %#v", publishRun)
|
profileMatches := value != nil && ((profile == "" && value.SelectedProfile == nil) ||
|
||||||
|
(profile != "" && value.SelectedProfile != nil && value.SelectedProfile.Name == profile))
|
||||||
|
if !profileMatches || value.EffectiveConfigDigest != digest {
|
||||||
|
t.Fatalf("effective configuration provenance = %#v, want profile=%q digest=%q", value, profile, digest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ func (f *singletonStringFlag) Set(value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f singletonStringFlag) pointer() *string {
|
||||||
|
if !f.set {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := f.value
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
type singletonBoolFlag struct {
|
type singletonBoolFlag struct {
|
||||||
name string
|
name string
|
||||||
value bool
|
value bool
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
|||||||
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
|
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
|
||||||
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
|
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
|
||||||
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
|
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
|
||||||
|
{name: "profile", args: []string{"session", "--profile", "production", "--profile=testing"}, want: "--profile may be specified only once"},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -68,6 +69,32 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunParsingRetainsExplicitProfilePresence(t *testing.T) {
|
||||||
|
withoutProfile, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := withoutProfile.Config.sessionOptions().Profile; got != nil {
|
||||||
|
t.Fatalf("omitted profile = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
withProfile, err := parseBoundedRunRequest("run", []string{"session", "--profile", "testing"}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := withProfile.Config.sessionOptions().Profile; got == nil || *got != "testing" {
|
||||||
|
t.Fatalf("explicit profile = %#v, want testing", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--profile", ""}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := explicitEmpty.Config.sessionOptions().Profile; got == nil || *got != "" {
|
||||||
|
t.Fatalf("explicit empty profile = %#v, want non-nil empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
|
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
|
||||||
args := []string{"session", "--from", "publish", "--through", "render"}
|
args := []string{"session", "--from", "publish", "--through", "render"}
|
||||||
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)
|
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)
|
||||||
@@ -113,9 +140,7 @@ func TestRunPassesBoundedPlanToRunner(t *testing.T) {
|
|||||||
var capturedStages []string
|
var capturedStages []string
|
||||||
var capturedPlan BoundedPlan
|
var capturedPlan BoundedPlan
|
||||||
var capturedOptions RunOptions
|
var capturedOptions RunOptions
|
||||||
original := executeStagesFn
|
executor := func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||||
t.Cleanup(func() { executeStagesFn = original })
|
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
|
||||||
capturedStages = plan.Names()
|
capturedStages = plan.Names()
|
||||||
capturedPlan = plan
|
capturedPlan = plan
|
||||||
capturedOptions = options
|
capturedOptions = options
|
||||||
@@ -123,7 +148,7 @@ func TestRunPassesBoundedPlanToRunner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Run(context.Background(), []string{
|
err := runWithPipelineExecutor(context.Background(), []string{
|
||||||
"2026-05-03",
|
"2026-05-03",
|
||||||
"--from", "render",
|
"--from", "render",
|
||||||
"--through", "extract",
|
"--through", "extract",
|
||||||
@@ -131,7 +156,7 @@ func TestRunPassesBoundedPlanToRunner(t *testing.T) {
|
|||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign-file", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
}, &out)
|
}, &out, executor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user