Compare commits
216 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d3ecd907ee | |||
| 442469b9f5 | |||
| c45e042462 | |||
| 207da1b6ef | |||
| 9a1609435e | |||
| 13b83d7ee2 | |||
| a0312ed034 | |||
| 8ff74e1251 | |||
| 1b480d4ed5 | |||
| e13d87a077 | |||
| 5202f16972 | |||
| 5086596f94 | |||
| f7fc64db10 | |||
| 6b0778b420 | |||
| 5b92b3e790 | |||
| cc43906ae9 | |||
| dcaa2ad9c6 | |||
| 9d68cfcf2f | |||
| 2d346733d6 | |||
| cd75bf6d5a | |||
| e08de98ae6 | |||
| df213bc2ba | |||
| 869e68e834 | |||
| 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 | |||
| 8b4b328c4e | |||
| ee2b8e63e6 | |||
| 51edd384c0 | |||
| b804d0f2c8 | |||
| fd5ccc668b | |||
| 0dc8ff9b52 | |||
| 8657a28bdb | |||
| a9c5e4ad4e | |||
| 2176b4371d | |||
| effc10d75b | |||
| 5887839aa1 | |||
| 3128bef20a | |||
| 4e4e2b7d96 | |||
| 99f4f9a0db | |||
| 6abdd67bb5 | |||
| c32e0c401f | |||
| ab5751459a | |||
| 62de6abdbf | |||
| 903dc70682 | |||
| 23c714da66 | |||
| 6639775d7d | |||
| 966b95b176 | |||
| 3bcf2c08dd | |||
| 700ab655ca | |||
| 85c5647385 | |||
| 2ef7c76d99 | |||
| 9bc1b0feda | |||
| 5cec84a4a7 | |||
| abfbe42d61 | |||
| e7e3bef1e4 | |||
| a68e8e31a4 | |||
| 3a9e60cda9 | |||
| 905ff03ccc | |||
| 495f7bcde4 | |||
| 51e0e8c5d0 | |||
| a2409a1fd1 | |||
| b3363f87d6 | |||
| 5831c0c9e6 | |||
| 42ed81cbe1 |
@@ -2,64 +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"
|
|
||||||
|
|
||||||
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:
|
||||||
@@ -67,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
|
||||||
|
|
||||||
|
|||||||
218
docs/cli.md
218
docs/cli.md
@@ -12,12 +12,15 @@ This runs the canonical full pipeline for session `2026-04-04`.
|
|||||||
|
|
||||||
Top-level commands:
|
Top-level commands:
|
||||||
|
|
||||||
- `run <session_id>`: run full stage order.
|
- `version`: print the Narratio build version.
|
||||||
|
- `run <session_id>`: run all or one contiguous range of the canonical stage order.
|
||||||
|
- `regenerate-artifacts <session_id>`: force-run extraction through analysis.
|
||||||
- `run-stage <stage> <session_id>`: run one stage.
|
- `run-stage <stage> <session_id>`: run one stage.
|
||||||
- `analyze <session_id>`: force-run analyze.
|
- `analyze <session_id>`: force-run analyze.
|
||||||
- `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:
|
||||||
|
|
||||||
@@ -41,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:
|
||||||
|
|
||||||
@@ -49,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).
|
||||||
@@ -70,21 +82,144 @@ 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.
|
||||||
|
Typed credential references, including `notarius.llm_api_key`, must therefore
|
||||||
|
resolve even though these inspection commands never construct an adapter.
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio version
|
||||||
|
```
|
||||||
|
|
||||||
|
Official release binaries report their exact Git tag. Binaries built directly
|
||||||
|
from source without release linker metadata report `dev`.
|
||||||
|
|
||||||
### `run`
|
### `run`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run <session_id> [--force] [--artifacts <name[,name...]>] [...common config flags]
|
narratio run <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- evaluates full stage order;
|
- evaluates one inclusive contiguous range of the canonical stage order;
|
||||||
- runs `extract` between `trim` and `render`; an omitted or disabled Notarius
|
- defaults an omitted `--from` to `prepare` and an omitted `--through` to
|
||||||
|
`cleanup`, so omitting both retains full-pipeline behavior;
|
||||||
|
- rejects unknown endpoints and a `--from` endpoint after `--through`;
|
||||||
|
- 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;
|
||||||
|
- resolves every selected typed credential during the initial configuration
|
||||||
|
load, including a Notarius credential when extract is disabled, outside the
|
||||||
|
selected range, or later skipped as resumable;
|
||||||
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
||||||
resume check finds its durable result obsolete;
|
resume check finds its durable result obsolete;
|
||||||
|
- applies `--force` only to stages in the selected range;
|
||||||
|
- rejects repeated `--from`, `--through`, or `--force` options, including
|
||||||
|
`--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
|
||||||
|
`publish`. Either consumer is sufficient, including a one-stage range.
|
||||||
|
|
||||||
|
### `regenerate-artifacts`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [...common config flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly equivalent to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio run <session_id> --force --from extract --through analyze [caller options]
|
||||||
|
```
|
||||||
|
|
||||||
|
The command always reruns extraction. Analysis rebuilds the selected configured
|
||||||
|
artifacts and any prerequisites required by those targets; without
|
||||||
|
`--artifacts`, it uses the normal default analysis selection. Publish and notify
|
||||||
|
never run. Common session/configuration options and repeatable artifact values
|
||||||
|
pass through unchanged.
|
||||||
|
|
||||||
|
The forced extraction rebuilds the complete configured Notarius bundle,
|
||||||
|
disables Notarius checkpoint loading, and requests chunk-plan refresh.
|
||||||
|
`--artifacts` narrows only the analysis work; it never selects Notarius lanes.
|
||||||
|
|
||||||
|
Because the expansion owns `--force`, `--from`, and `--through`, callers cannot
|
||||||
|
supply those options. The shared `run` parser reports them as duplicate
|
||||||
|
singleton flags. The alias has no private execution options or behavior, and
|
||||||
|
runtime diagnostics may identify the operation as `run`.
|
||||||
|
|
||||||
### `run-stage`
|
### `run-stage`
|
||||||
|
|
||||||
@@ -100,11 +235,12 @@ Valid stage names:
|
|||||||
- `polish`
|
- `polish`
|
||||||
- `normalize`
|
- `normalize`
|
||||||
- `trim`
|
- `trim`
|
||||||
- `extract`
|
|
||||||
- `render`
|
- `render`
|
||||||
|
- `extract`
|
||||||
- `analyze`
|
- `analyze`
|
||||||
- `publish`
|
- `publish`
|
||||||
- `notify`
|
- `notify`
|
||||||
|
- `cleanup`
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@@ -122,6 +258,10 @@ Equivalent to:
|
|||||||
narratio run-stage analyze <session_id> --force [...common config flags]
|
narratio run-stage analyze <session_id> --force [...common config flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This selects only `analyze`: it does not invoke Notarius, replace extraction,
|
||||||
|
or remove existing extract outputs. `--artifacts` narrows the forced analysis
|
||||||
|
targets and their required analysis prerequisites.
|
||||||
|
|
||||||
### `publish`
|
### `publish`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -134,6 +274,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
|
||||||
@@ -147,16 +291,25 @@ 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`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio session plan <session_id> [--force] [...common config flags]
|
narratio session plan <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
Validates config, prepares local workdir layout, and prints run/skip decisions for each stage.
|
Uses the same inclusive bounds, endpoint validation, force scope, and artifact
|
||||||
|
selection contract as `run`. It validates config and prints run/skip decisions
|
||||||
|
for selected stages only without creating the local workdir or changing the
|
||||||
|
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,
|
||||||
|
execution order, and reusable current artifacts with concise reasons. These
|
||||||
|
artifact decisions come from the same reconciliation and work planner used by
|
||||||
|
execution; the preview does not predict output identities.
|
||||||
|
|
||||||
### `session validate`
|
### `session validate`
|
||||||
|
|
||||||
@@ -164,7 +317,8 @@ Validates config, prepares local workdir layout, and prints run/skip decisions f
|
|||||||
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`
|
||||||
|
|
||||||
@@ -173,7 +327,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`
|
||||||
|
|
||||||
@@ -192,6 +351,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>`
|
||||||
@@ -231,7 +391,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`
|
||||||
|
|
||||||
@@ -243,23 +404,34 @@ 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
|
||||||
|
|
||||||
- accepted on `run`, `run-stage`, `analyze`, and `publish`;
|
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`;
|
||||||
|
- repeatable and comma-separated values are combined, surrounding whitespace
|
||||||
|
is removed, and duplicate names are collapsed;
|
||||||
- names must exist in `pipeline.scriptorium.artifacts`;
|
- names must exist in `pipeline.scriptorium.artifacts`;
|
||||||
- empty entries are invalid;
|
- empty entries are invalid;
|
||||||
- repeated names are deduplicated.
|
- on `run-stage`, only `analyze` and `publish` accept the option.
|
||||||
|
|
||||||
Effects:
|
Effects:
|
||||||
|
|
||||||
- filters analyze execution to selected configured artifacts;
|
- selects explicit analyze targets; required configured prerequisites may be
|
||||||
|
reused or rebuilt before them;
|
||||||
- filters publish rules that source `narratio.artifact.<name>`;
|
- filters publish rules that source `narratio.artifact.<name>`;
|
||||||
- does not filter built-in transcript/bounds or explicitly configured
|
- does not filter built-in transcript/bounds or explicitly configured
|
||||||
`narratio.extraction.<name>` publish sources; and
|
`narratio.extraction.<name>` publish sources; and
|
||||||
@@ -291,6 +463,12 @@ Force publish only:
|
|||||||
narratio publish 2026-04-04
|
narratio publish 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Regenerate post-transcript artifacts without publishing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
|
||||||
|
```
|
||||||
|
|
||||||
## Output And Exit Behavior
|
## Output And Exit Behavior
|
||||||
|
|
||||||
- Successful commands write their result or summary to standard output and
|
- Successful commands write their result or summary to standard output and
|
||||||
|
|||||||
340
docs/config.md
340
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,18 +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.
|
||||||
- Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, `players_file`, `party_file`) resolve from session overrides when provided, otherwise from campaign defaults.
|
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
||||||
|
`party_file`) are campaign-owned; the first three may be overridden by the
|
||||||
|
session. The optional `spell_catalog_file` also inherits a 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`
|
||||||
@@ -85,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -100,15 +225,31 @@ 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.notarius.llm_api_key: NOTARIUS_LLM_API_KEY`). A configured reference
|
||||||
|
must resolve to a nonempty environment value during load.
|
||||||
|
- Every surviving reference is resolved during ordinary pipeline loading,
|
||||||
|
regardless of whether its integration is disabled, outside the selected
|
||||||
|
stage range, or eligible for resume. Omit the field or set it to `null` when
|
||||||
|
that deployment should not require the credential.
|
||||||
|
- 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 non-hidden symlink entries, and limits input to
|
||||||
|
1 MiB per file, 8 MiB total, and 1024 entries. Dot-prefixed entries (including
|
||||||
|
files, directories, and symlinks) are ignored and omitted from bootstrap
|
||||||
|
reports, but still count toward the directory entry limit.
|
||||||
- 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
|
||||||
|
|
||||||
@@ -131,9 +272,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:
|
||||||
@@ -144,7 +282,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
|
||||||
|
|
||||||
@@ -152,21 +291,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` |
|
||||||
@@ -175,9 +314,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` |
|
||||||
@@ -195,7 +331,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 |
|
||||||
@@ -223,10 +359,14 @@ Rules:
|
|||||||
| `pipeline.trim.seriatim.report` | bool | No | `false` |
|
| `pipeline.trim.seriatim.report` | bool | No | `false` |
|
||||||
| `pipeline.notarius.enabled` | bool | No | `false` |
|
| `pipeline.notarius.enabled` | bool | No | `false` |
|
||||||
| `pipeline.notarius.binary` | string | No | `notarius` |
|
| `pipeline.notarius.binary` | string | No | `notarius` |
|
||||||
|
| `pipeline.notarius.llm_api_key` | secret reference | No | unset; exact environment-variable name forwarded to Notarius |
|
||||||
| `pipeline.notarius.config_path` | string | Conditional | required when enabled; relative paths resolve from the pipeline file directory |
|
| `pipeline.notarius.config_path` | string | Conditional | required when enabled; relative paths resolve from the pipeline file directory |
|
||||||
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
|
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
|
||||||
| `pipeline.notarius.timeout` | duration | No | `3h`; must be positive |
|
| `pipeline.notarius.timeout` | duration | No | `3h`; must be positive |
|
||||||
| `pipeline.notarius.working_directory` | string | No | directory containing resolved `config_path`; relative paths resolve from the pipeline file directory |
|
| `pipeline.notarius.working_directory` | string | No | directory containing resolved `config_path`; relative paths resolve from the pipeline file directory |
|
||||||
|
| `pipeline.notarius.resume` | bool | No | `false`; request compatible Notarius checkpoint reuse during ordinary executed extraction |
|
||||||
|
| `pipeline.notarius.chunk_cache` | string | No | empty; when set, exactly `auto`, `bypass`, or `refresh` |
|
||||||
|
| `pipeline.notarius.references` | map[string]string | No | empty; maps normalized Notarius selectors to supported prepared Narratio source IDs; maximum 256 entries |
|
||||||
| `pipeline.notarius.outputs` | map | Conditional | at least one entry when enabled |
|
| `pipeline.notarius.outputs` | map | Conditional | at least one entry when enabled |
|
||||||
| `pipeline.render.enabled` | bool | No | `true` |
|
| `pipeline.render.enabled` | bool | No | `true` |
|
||||||
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
|
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
|
||||||
@@ -239,8 +379,89 @@ 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 Credential
|
||||||
|
|
||||||
|
`pipeline.notarius.llm_api_key` optionally names the one environment variable
|
||||||
|
to forward to the Notarius child process:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notarius:
|
||||||
|
llm_api_key: OPENROUTER_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
The field contains the variable name, not a credential value. That name must
|
||||||
|
match `api_key_env` in the Notarius/PromptKit profile selected by the deployed
|
||||||
|
Notarius pipeline; Narratio does not infer or remap provider names. Omission or
|
||||||
|
`null` forwards nothing. A non-null selected reference must resolve at pipeline
|
||||||
|
load time through the process environment or existing secrets-directory
|
||||||
|
bootstrap, even when Notarius is disabled, extract is outside the requested
|
||||||
|
range, or a completed extraction can be reused.
|
||||||
|
|
||||||
|
The Notarius child receives only Narratio's minimal execution environment plus
|
||||||
|
this exact selected name and loaded value. Other credentials are not forwarded.
|
||||||
|
Changing the value, name, presence, or absence does not invalidate extract's
|
||||||
|
semantic resume evidence. The public name remains part of safe effective
|
||||||
|
inspection, so renaming it may appear in `config diff` and change the effective
|
||||||
|
configuration digest; rotating only its value does neither.
|
||||||
|
|
||||||
|
See the [Notarius integration contract](./integrations/notarius.md) for the
|
||||||
|
delivery and redaction boundary.
|
||||||
|
|
||||||
|
### Notarius Cache Controls
|
||||||
|
|
||||||
|
`pipeline.notarius.resume` defaults to `false`. When true, an executed ordinary
|
||||||
|
extraction passes `--resume`; the deployed Notarius configuration must already
|
||||||
|
enable checkpoint recording and should select an explicit persistent checkpoint
|
||||||
|
root. Narratio does not inspect or generate that configuration.
|
||||||
|
|
||||||
|
`pipeline.notarius.chunk_cache` is empty by default, leaving Notarius's
|
||||||
|
configured mode in control. A nonempty value must be `auto`, `bypass`, or
|
||||||
|
`refresh` and is passed as `--chunk_cache`. Forced extraction takes precedence
|
||||||
|
over both fields: it omits `--resume` and passes `--chunk_cache refresh`.
|
||||||
|
Neither field changes Narratio's semantic extraction fingerprint, so changing
|
||||||
|
them alone does not rerun an otherwise complete durable extraction.
|
||||||
|
|
||||||
|
### Notarius Reference Bindings
|
||||||
|
|
||||||
|
`pipeline.notarius.references` maps a Notarius CLI selector to a prepared
|
||||||
|
Narratio source, not to a filesystem path:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notarius:
|
||||||
|
references:
|
||||||
|
glossary: narratio.input.glossary
|
||||||
|
party: narratio.input.party
|
||||||
|
players: narratio.input.players
|
||||||
|
spell_catalog: narratio.input.spell_catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported sources are `narratio.input.party`, `narratio.input.players`,
|
||||||
|
`narratio.input.glossary`, and `narratio.input.spell_catalog`. Each map entry is
|
||||||
|
required by its presence: omit a binding when the selected Notarius pipeline
|
||||||
|
does not need it. A spell-catalog binding additionally requires an effective
|
||||||
|
campaign or session `spell_catalog_file`.
|
||||||
|
|
||||||
|
Selectors accept Notarius's `slot`, `chunk.slot`, `lane.slot`,
|
||||||
|
`lane.extract.slot`, `lane.merge.slot`, and `lane.normalize.slot` forms.
|
||||||
|
Narratio trims whitespace around
|
||||||
|
selectors and their dot-separated components, rejects empty components and
|
||||||
|
`=`, rejects duplicate normalized selectors, and limits the map to 256 entries.
|
||||||
|
It validates only selector structure and the prepared source vocabulary;
|
||||||
|
Notarius owns target-slot declarations and media compatibility.
|
||||||
|
|
||||||
|
Before extraction, Narratio resolves every binding from the current prepared
|
||||||
|
session manifest and streams it into a verified, content-addressed session
|
||||||
|
snapshot whose absolute path is passed to Notarius. Missing, unsafe, empty,
|
||||||
|
changed-during-copy, or checksum-inconsistent prepared evidence fails with
|
||||||
|
guidance to force `prepare`. Bindings are sorted by normalized selector and are
|
||||||
|
part of extraction fingerprint and resume identity. See the
|
||||||
|
[Notarius integration contract](./integrations/notarius.md) for the subprocess
|
||||||
|
boundary and the [complete example](../examples/pipeline.full.annotated.yml)
|
||||||
|
for a copyable configuration.
|
||||||
|
|
||||||
### Notarius Output Entries
|
### Notarius Output Entries
|
||||||
|
|
||||||
For each `pipeline.notarius.outputs.<name>`:
|
For each `pipeline.notarius.outputs.<name>`:
|
||||||
@@ -268,7 +489,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; enabled 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 |
|
||||||
@@ -281,12 +502,13 @@ 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 execution set even if their `enabled` values are false; the list
|
one-invocation targets even if their `enabled` values are false. Analyze closes
|
||||||
does not automatically include dependencies. Named artifacts must therefore be
|
those targets over the effective dependency graph: a current prerequisite is reused, while a
|
||||||
configured with valid executable fields, and their configured dependencies must
|
stale, missing, or failed prerequisite is rebuilt before its dependent.
|
||||||
already be available to analyze. This override affects analyze planning only;
|
Unrelated artifacts are not executed. Named targets and any prerequisite that
|
||||||
publish uses the list only to filter configured
|
may require rebuilding must therefore have valid executable fields. This
|
||||||
`narratio.artifact.<name>` output rules.
|
override affects analyze planning only; publish uses the list only to filter
|
||||||
|
configured `narratio.artifact.<name>` output rules.
|
||||||
|
|
||||||
For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`:
|
For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`:
|
||||||
|
|
||||||
@@ -299,6 +521,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
|
||||||
@@ -316,8 +585,8 @@ 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` |
|
||||||
|
|
||||||
### Session
|
### Session
|
||||||
|
|
||||||
@@ -331,8 +600,7 @@ 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.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
||||||
| `inputs.party_file` | string | No | overrides campaign stable input |
|
|
||||||
| `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 |
|
||||||
| `inputs.audio_s3.prefix` | string | Conditional | S3 audio mode |
|
| `inputs.audio_s3.prefix` | string | Conditional | S3 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.
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ lanes from the final trimmed Seriatim transcript. Narratio owns invocation,
|
|||||||
safe bundle discovery, lane selection, and its own artifact metadata. Notarius
|
safe bundle discovery, lane selection, and its own artifact metadata. Notarius
|
||||||
owns pipeline definitions, lane schemas, the receipt, and bundle formats.
|
owns pipeline definitions, lane schemas, the receipt, and bundle formats.
|
||||||
|
|
||||||
Canonical Notarius references:
|
Canonical Notarius v0.7.0 references:
|
||||||
|
|
||||||
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/consumers/subprocess.md)
|
- [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/cli.md)
|
||||||
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/consumers/dnd-pipeline.md)
|
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/consumers/subprocess.md)
|
||||||
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/run-result.md)
|
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/consumers/dnd-pipeline.md)
|
||||||
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/json-output.md)
|
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/run-result.md)
|
||||||
|
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/json-output.md)
|
||||||
|
- [D&D spell-catalog overlay](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/dnd-spell-catalog-overlays.md)
|
||||||
|
|
||||||
The [complete Narratio example](../../examples/pipeline.full.annotated.yml)
|
The [complete Narratio example](../../examples/pipeline.full.annotated.yml)
|
||||||
records the exact current constraints for all ten D&D lanes. Treat the linked
|
records the exact current constraints for all ten D&D lanes. Treat the linked
|
||||||
@@ -23,12 +25,56 @@ duplicate the complete schemas.
|
|||||||
|
|
||||||
When `pipeline.notarius.enabled` is true, Narratio resolves the executable,
|
When `pipeline.notarius.enabled` is true, Narratio resolves the executable,
|
||||||
configuration path, input path, output directory, and working directory to
|
configuration path, input path, output directory, and working directory to
|
||||||
absolute paths and invokes:
|
absolute paths. Configured references require support for the `--reference`
|
||||||
|
contract introduced in Notarius v0.6.0. Cache controls require support for the
|
||||||
|
selected `--resume` and `--chunk_cache` flags. Notarius v0.7.0 is the tested
|
||||||
|
cache integration baseline, not an unconditional minimum version; Narratio
|
||||||
|
does not impose a runtime version gate. Each reference binding is a separate
|
||||||
|
argument before `--json`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> --json
|
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--resume] [--chunk_cache <mode>] [--reference <selector>=<verified_snapshot_path>]... --json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Reference paths are absolute stable snapshots streamed from manifest-verified
|
||||||
|
canonical files prepared inside the current Narratio session workspace. They
|
||||||
|
live at `cache/notarius/references/<sha256>/<prepared-basename>` and retain the
|
||||||
|
full lowercase content digest. Narratio verifies snapshot checksum and size
|
||||||
|
before and after the subprocess, reconstructs missing or corrupt regular files
|
||||||
|
from a still-valid prepared source, and passes only configured bindings,
|
||||||
|
ordered lexically by normalized selector, as direct argument-vector entries
|
||||||
|
without shell interpretation. Symlinks, directories, other non-regular
|
||||||
|
conflicts, and prepared-source mismatches fail rather than being deleted. A CLI
|
||||||
|
binding takes precedence over a matching external path in Notarius
|
||||||
|
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:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notarius run dnd-session \
|
||||||
|
--config <absolute config path> \
|
||||||
|
--input <absolute trimmed transcript path> \
|
||||||
|
--output-dir <absolute staging directory> \
|
||||||
|
--reference glossary=<absolute verified glossary snapshot> \
|
||||||
|
--reference party=<absolute verified party snapshot> \
|
||||||
|
--reference players=<absolute verified players snapshot> \
|
||||||
|
--reference spell_catalog=<absolute verified spell catalog snapshot> \
|
||||||
|
--json
|
||||||
|
```
|
||||||
|
|
||||||
|
The spell-catalog binding is omitted when the campaign does not maintain that
|
||||||
|
optional overlay. Registry, scene-description, combat-turn, and occurrence
|
||||||
|
handoffs generated during the same Notarius run remain in Notarius pipeline
|
||||||
|
composition and must not be emitted as CLI references. The linked CLI and D&D
|
||||||
|
consumer documents own selector targeting, declared slots, media compatibility,
|
||||||
|
and generated-handoff collision rules.
|
||||||
|
|
||||||
Standard output is reserved for the JSON receipt. Standard error is captured
|
Standard output is reserved for the JSON receipt. Standard error is captured
|
||||||
separately as diagnostic output. Narratio applies the configured timeout and
|
separately as diagnostic output. Narratio applies the configured timeout and
|
||||||
does not interpret stdout as a receipt unless the subprocess exits successfully.
|
does not interpret stdout as a receipt unless the subprocess exits successfully.
|
||||||
@@ -36,26 +82,67 @@ It does not pass a Narratio session ID or run `notarius config validate`
|
|||||||
automatically; the configured working directory and Narratio's minimal child
|
automatically; the configured working directory and Narratio's minimal child
|
||||||
environment apply to the subprocess.
|
environment apply to the subprocess.
|
||||||
|
|
||||||
|
Ordinary extraction maps `pipeline.notarius.resume: true` to `--resume` and a
|
||||||
|
nonempty `pipeline.notarius.chunk_cache` to `--chunk_cache <mode>`. Forced
|
||||||
|
extraction always omits `--resume` and supplies `--chunk_cache refresh`.
|
||||||
|
Notarius owns checkpoint enablement, recording, identity, and both cache roots;
|
||||||
|
Narratio neither reads nor synthesizes its configuration. Use explicit
|
||||||
|
persistent roots in Notarius when reuse must span subprocess invocations.
|
||||||
|
Changing a reference path/digest or other upstream identity input correctly
|
||||||
|
causes a cold miss. Chunk refresh bypasses lookup and publishes the accepted
|
||||||
|
replacement for the active source; it does not delete unrelated plans or
|
||||||
|
checkpoint state.
|
||||||
|
|
||||||
|
When `pipeline.notarius.llm_api_key` is absent or null, Narratio adds no
|
||||||
|
credential to that environment. When configured, the field names an environment
|
||||||
|
variable whose already-loaded nonempty value is forwarded under that exact
|
||||||
|
name. Operators must use the same name as the `api_key_env` in the selected
|
||||||
|
Notarius/PromptKit profile. Narratio does not inspect the Notarius configuration
|
||||||
|
to infer a provider or destination name, and it does not forward unrelated
|
||||||
|
credentials from its parent environment or secrets directory.
|
||||||
|
|
||||||
|
The selected name is registered with Narratio's shared streaming redactor.
|
||||||
|
Credential bytes written by the child to captured stdout, captured stderr, or
|
||||||
|
stderr-derived failure diagnostics are replaced with `<redacted>`, including
|
||||||
|
custom names not known to the built-in sensitive-name list. Narratio does not
|
||||||
|
serialize the credential into arguments, stage metadata, manifests, or
|
||||||
|
generated configuration.
|
||||||
|
|
||||||
|
This captured-stream protection is not a filesystem sandbox. Notarius remains
|
||||||
|
responsible for keeping credentials out of files it writes directly, including
|
||||||
|
bundle artifacts and its own diagnostics. Narratio does not scan or rewrite
|
||||||
|
arbitrary downstream files and cannot promise redaction of transformed or
|
||||||
|
encoded credentials.
|
||||||
|
|
||||||
## Accepted Result
|
## Accepted Result
|
||||||
|
|
||||||
Narratio currently accepts receipt schema `notarius.run-result.v1`. The receipt
|
Narratio's tested invocation baseline is Notarius v0.7.0. The accepted
|
||||||
|
receipt remains `notarius.run-result.v2`; reference flags do not change the
|
||||||
|
receipt or ten-lane output contract. The receipt
|
||||||
must identify the configured pipeline, and its `index_file` must be exactly
|
must identify the configured pipeline, and its `index_file` must be exactly
|
||||||
`index.json` beneath the reported bundle root. The production index must name
|
`index.json` beneath the reported bundle root. The production index must name
|
||||||
the management files exactly as `manifest.json`, `rejected.json`, and
|
the management files exactly as `manifest.json`, `rejected.json`,
|
||||||
`warnings.json`. All receipt, index, and lane paths must stay inside that
|
`warnings.json`, and `diagnostics.json`. All receipt, index, and lane paths must
|
||||||
bundle; symlinks and non-regular lane payloads are rejected.
|
stay inside that bundle; symlinks and non-regular lane payloads are rejected.
|
||||||
|
|
||||||
Supported receipt and index shapes tolerate unknown fields for forward
|
Supported receipt and index shapes tolerate unknown fields for forward
|
||||||
compatibility, while required identity, validation, count, manifest,
|
compatibility, while required identity, validation, count, manifest,
|
||||||
rejection, warning, and lane-list fields remain mandatory. Narratio applies
|
rejection, warning, diagnostic, and lane-list fields remain mandatory.
|
||||||
bounded reads to the receipt, index, rejection, and warning documents. Optional
|
Narratio applies bounded reads to the receipt, index, rejection, warning, and
|
||||||
chunk-map and evidence-context descriptors must carry their complete generic
|
diagnostic documents. Warning and diagnostic envelopes, group counts,
|
||||||
contract metadata when present.
|
occurrence counts, truncation state, framework-owned origins, and
|
||||||
|
receipt-to-bundle counts must be internally consistent. Optional chunk-map and
|
||||||
|
evidence-context descriptors must carry their complete generic contract
|
||||||
|
metadata when present.
|
||||||
|
|
||||||
For every entry in `pipeline.notarius.outputs`, Narratio requires exactly one
|
For every entry in `pipeline.notarius.outputs`, Narratio requires exactly one
|
||||||
index descriptor with the configured lane ID, media type, schema ID, schema
|
index descriptor with the configured lane ID, media type, schema ID, schema
|
||||||
version, and, when configured, module key. Missing, duplicate, rejected, or
|
version, and, when configured, module key. Missing, duplicate, rejected, or
|
||||||
incompatible required lanes fail extraction even if Notarius exited zero.
|
incompatible required lanes fail extraction even if Notarius exited zero. A
|
||||||
|
configured lane whose v2 validation summary is `rejected` or `incomplete` also
|
||||||
|
fails extraction.
|
||||||
|
An accepted reused producer may report `producer_attempt_count: 0`; Narratio
|
||||||
|
preserves that valid summary rather than treating it as a missing field.
|
||||||
Unconfigured lanes may remain in the preserved bundle but do not become
|
Unconfigured lanes may remain in the preserved bundle but do not become
|
||||||
selectable Narratio sources.
|
selectable Narratio sources.
|
||||||
|
|
||||||
@@ -72,12 +159,30 @@ only explicitly named lane sources; `--artifacts` never selects Notarius lanes.
|
|||||||
staged bundle is promoted to durable storage.
|
staged bundle is promoted to durable storage.
|
||||||
- Contract and external provenance metadata are preserved on lane artifact
|
- Contract and external provenance metadata are preserved on lane artifact
|
||||||
records and through explicit publication.
|
records and through explicit publication.
|
||||||
|
- Undeclared selectors, incompatible reference files, and external/generated
|
||||||
|
reference collisions are Notarius errors and fail extraction normally.
|
||||||
|
|
||||||
Rejection and warning summaries retain structured stage, scope, lane, and
|
Rejection, validation, warning, and diagnostic summaries retain bounded stable
|
||||||
reason-code fields for diagnostics without exposing free-form external messages
|
identity, category, origin, reason-code, status, and occurrence fields without
|
||||||
or reading lane payload bodies.
|
copying free-form external messages into Narratio manifest metadata or reading
|
||||||
|
lane payload bodies.
|
||||||
|
|
||||||
Configuration fields and defaults are in [Configuration](../config.md).
|
Configuration fields and defaults are in [Configuration](../config.md).
|
||||||
Operator paths, rerun procedures, and bundle retention are in
|
Operator paths, rerun procedures, and bundle retention are in
|
||||||
[Operations](../operations.md). See [Troubleshooting](../troubleshooting.md)
|
[Operations](../operations.md). See [Troubleshooting](../troubleshooting.md)
|
||||||
for failure recovery.
|
for failure recovery.
|
||||||
|
|
||||||
|
## Opt-in Integration Test
|
||||||
|
|
||||||
|
The production-boundary cache test requires an absolute path to a locally
|
||||||
|
built Notarius v0.7.0-compatible executable. It uses only a local
|
||||||
|
OpenAI-compatible `httptest` provider and temporary cache/config/output roots:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NARRATIO_TEST_NOTARIUS_BINARY=/absolute/path/to/notarius \
|
||||||
|
GOWORK=off go test ./internal/stage \
|
||||||
|
-run '^TestNotariusCacheReuseIntegration$' -count=1 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
When the variable is unset, the normal test suite skips this test and never
|
||||||
|
searches `PATH`, a sibling checkout, the network, or a real provider.
|
||||||
|
|||||||
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,43 +35,74 @@ Adapters do not own:
|
|||||||
|
|
||||||
## Default Wiring
|
## Default Wiring
|
||||||
|
|
||||||
`internal/app/runner.go` initializes default adapters when not injected:
|
The application composes default adapters per invocation when they were not
|
||||||
|
injected through the stage environment, and only when the selected execution
|
||||||
|
plan needs them:
|
||||||
|
|
||||||
- WhisperX HTTP client from pipeline config.
|
- WhisperX HTTP client for `transcribe`.
|
||||||
- Seriatim subprocess runner.
|
- Seriatim subprocess runner for `merge`, `normalize`, `trim`, or `render`.
|
||||||
- Audita subprocess runner.
|
- Audita subprocess runner for `polish`.
|
||||||
- Scriptorium subprocess runner.
|
- Scriptorium subprocess runner for `trim` or `analyze`.
|
||||||
- Notarius subprocess runner when extraction is enabled.
|
- Notarius subprocess runner for `extract` when extraction is enabled.
|
||||||
- Noop notifier (`notify.NoopSender`).
|
- Noop notifier (`notify.NoopSender`) for `notify`.
|
||||||
- Object store only when required by selected stages/config.
|
- Object store only when required by selected stages/config.
|
||||||
|
|
||||||
Notarius is composed only when extraction is enabled; the extract stage owns
|
Remote publish locks are loaded only for a selected, enabled publish that
|
||||||
receipt, bundle, and configured-lane policy rather than the adapter.
|
uploads a run. Shared session lifecycle setup still applies to every selected
|
||||||
|
range, but an unselected integration is neither initialized nor validated by
|
||||||
|
application-owned composition. Constructor, clock, randomness, remote-lock,
|
||||||
|
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.
|
||||||
|
|
||||||
Object-store construction goes through `newCommandObjectStore`, which loads
|
`session plan` is outside production adapter composition. It performs
|
||||||
configured filesystem secrets before adapter initialization.
|
resume validation and models selected transitions against cloned manifest
|
||||||
|
state without constructing or invoking stage-execution adapters. The shared
|
||||||
|
command configuration loader may still use object storage to retrieve a missing
|
||||||
|
remote session file before planning begins.
|
||||||
|
|
||||||
|
Notarius is composed only when extraction is enabled; the extract stage owns
|
||||||
|
prepared reference resolution, receipt, bundle, and configured-lane policy.
|
||||||
|
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
||||||
|
owner of serializing them as repeated `--reference` arguments before `--json`.
|
||||||
|
It also serializes an effective resume request as `--resume` and a nonempty
|
||||||
|
validated chunk mode as `--chunk_cache <mode>`. The extract stage, not the
|
||||||
|
adapter, resolves force precedence before constructing the request.
|
||||||
|
Its request may also carry one already-resolved opaque ConfigKit secret
|
||||||
|
reference. Only the Notarius adapter reads that value, rejects an unresolved or
|
||||||
|
empty programmatic reference, and maps the exact configured name/value into the
|
||||||
|
shared subprocess override and sensitive-name fields. It never rereads the
|
||||||
|
parent environment. A nil reference preserves the previous invocation.
|
||||||
|
|
||||||
|
Application-owned object-store construction loads configured filesystem
|
||||||
|
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
|
||||||
@@ -36,28 +36,57 @@ unrecognized token into a valid source. Extraction sources are registered only
|
|||||||
from `pipeline.notarius.outputs`; the Notarius index has no selectable source
|
from `pipeline.notarius.outputs`; the Notarius index has no selectable source
|
||||||
ID.
|
ID.
|
||||||
|
|
||||||
|
Prepared stable source IDs are `narratio.input.players`,
|
||||||
|
`narratio.input.party`, `narratio.input.glossary`, and
|
||||||
|
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest
|
||||||
|
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
|
||||||
|
|
||||||
`ArtifactCatalog` tracks:
|
`ArtifactCatalog` tracks:
|
||||||
|
|
||||||
- `planned`: source registered for run context;
|
- `planned`: source registered for run context;
|
||||||
- `executable`: included in the effective analyze artifact set;
|
- `executable`: included in the effective analyze artifact set;
|
||||||
- `available`: local file exists and validates;
|
- `available`: the source's canonical evidence owner validates its current
|
||||||
|
manifest record and durable bytes;
|
||||||
- `provenance`: availability source.
|
- `provenance`: availability source.
|
||||||
|
|
||||||
Configured definitions are always registered. Without an explicit selection,
|
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; dependencies are not added implicitly.
|
regardless of their `enabled` value. The effective-set resolver itself does not
|
||||||
Availability is separate from executability: a non-executable configured output
|
expand dependencies. Configuration has already materialized the sorted union of
|
||||||
may be reused from a canonical non-empty file, while an executable definition
|
explicit ordering edges and configured-artifact input edges, and the analyze
|
||||||
is generated by analyze. Extraction entries are registered from configuration
|
work planner closes targets over that one concrete prerequisite graph.
|
||||||
and become available only after compatible extraction evidence is hydrated.
|
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
|
||||||
|
only when the versioned analyze state identifies a current result whose source,
|
||||||
|
contract, canonical configured path, size, and checksum match a confined
|
||||||
|
no-follow regular file. An incidental canonical file is unavailable, and a
|
||||||
|
manifest with an unsupported aggregate-only representation is rejected before
|
||||||
|
catalog construction.
|
||||||
|
Extraction entries are registered from configuration and become available only
|
||||||
|
after compatible extraction evidence is hydrated.
|
||||||
|
|
||||||
|
During an analyze invocation, a newly validated and atomically materialized
|
||||||
|
configured output is marked available with its producer run ID, contract,
|
||||||
|
checksum, and size. Later scheduled dependents therefore observe the same
|
||||||
|
semantic identity whether their prerequisite was reused from current manifest
|
||||||
|
evidence or produced earlier in the invocation.
|
||||||
|
|
||||||
Current provenance values:
|
Current provenance values:
|
||||||
|
|
||||||
- `generated.current_analyze_run`
|
- `generated.current_analyze_run`
|
||||||
- `filesystem.disabled_artifact_output`
|
- `manifest.current_analyze_artifact`
|
||||||
- `manifest.inputs.previous_cache`
|
- `manifest.inputs.previous_cache`
|
||||||
- `current_session.previous_cache`
|
- `current_session.previous_cache`
|
||||||
|
|
||||||
@@ -65,12 +94,40 @@ 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.*`):
|
||||||
|
|
||||||
- resolve only through runtime catalog availability.
|
- resolve only through runtime catalog availability;
|
||||||
|
- use the shared typed analyze-evidence inspection in
|
||||||
|
`analyze_evidence.go` for prior current-session results;
|
||||||
|
- require the supported analyze-state and fingerprint versions, a `current`
|
||||||
|
record for the exact configured key and source ID, a complete contract, the
|
||||||
|
configured canonical relative path, positive stored size, and stored
|
||||||
|
checksum matching bytes read from a confined no-follow regular file; and
|
||||||
|
- treat non-current statuses, malformed records, removed keys,
|
||||||
|
unsafe or missing files, and size/checksum mismatches as unavailable without
|
||||||
|
rewriting manifest state. Catalog construction iterates current
|
||||||
|
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.*`):
|
||||||
|
|
||||||
|
- resolve only from the current manifest's exact prepared-input record;
|
||||||
|
- require the policy-owned canonical path below the session root, a confined
|
||||||
|
non-symlink regular file, a non-empty payload, and a matching SHA-256
|
||||||
|
checksum; and
|
||||||
|
- return an immutable source/path/checksum/size identity shared by extract and
|
||||||
|
analyze rather than falling back to campaign/session source paths.
|
||||||
|
|
||||||
Extraction sources (`narratio.extraction.*`):
|
Extraction sources (`narratio.extraction.*`):
|
||||||
|
|
||||||
@@ -86,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
|
||||||
@@ -116,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:
|
||||||
|
|
||||||
@@ -142,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:
|
||||||
|
|
||||||
@@ -159,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:
|
||||||
@@ -171,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
|
||||||
@@ -199,10 +287,10 @@ physical layout.
|
|||||||
`internal/artifacts/catalog.go`, `internal/artifacts/transcripts.go`,
|
`internal/artifacts/catalog.go`, `internal/artifacts/transcripts.go`,
|
||||||
`internal/artifacts/extraction_catalog.go`,
|
`internal/artifacts/extraction_catalog.go`,
|
||||||
`internal/artifacts/extraction_evidence.go`,
|
`internal/artifacts/extraction_evidence.go`,
|
||||||
`internal/artifacts/extraction_input.go`
|
`internal/artifacts/extraction_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`
|
||||||
|
|||||||
215
docs/internal/configuration.md
Normal file
215
docs/internal/configuration.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# 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.
|
||||||
|
The optional Notarius credential follows the same path even for disabled or
|
||||||
|
resumable extraction; normalization copies the opaque reference without reading
|
||||||
|
or resolving it again. Direct YAML serialization has a configuration-owned safe
|
||||||
|
projection so only the reference name can leave this boundary.
|
||||||
|
`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`
|
||||||
@@ -35,15 +50,79 @@ The model admits these stage states:
|
|||||||
- `stale`
|
- `stale`
|
||||||
- `interrupted`
|
- `interrupted`
|
||||||
|
|
||||||
## Run Manifest
|
### Analyze-owned artifact state
|
||||||
|
|
||||||
`manifest.RunManifest` is created for each invocation and records:
|
Every `analyze` stage record carries `analyze_state_version: 1` and an
|
||||||
|
`analyze_artifacts` map keyed by normalized configured artifact key. The
|
||||||
|
version is the authority marker: version 1 with no entries is a valid evaluated
|
||||||
|
empty set. An absent or unsupported version is unsupported local state and is
|
||||||
|
rejected rather than being treated as aggregate-only authority.
|
||||||
|
|
||||||
- invocation identity and `force` flag
|
Each analyze artifact record has one disposition:
|
||||||
- requested stages
|
|
||||||
- per-stage action (`run` or `skip`)
|
- `current`: the configured artifact is available and carries a versioned
|
||||||
- per-stage status
|
fingerprint plus a complete output record and separate output size;
|
||||||
- overall run status (`running`, `succeeded`, `failed`)
|
- `stale`: the recorded semantic identity is no longer current;
|
||||||
|
- `missing`: no validated current result exists;
|
||||||
|
- `failed`: the attempted work failed and carries a bounded diagnostic; or
|
||||||
|
- `unselected`: the artifact was intentionally outside the evaluated set.
|
||||||
|
|
||||||
|
Records bind their normalized key and dependencies, fingerprint contract when
|
||||||
|
evaluated, canonical session-relative output identity when current, producing
|
||||||
|
Narratio run, update time, and bounded non-secret Scriptorium provenance and
|
||||||
|
diagnostic paths. A current output includes its configured source ID, contract,
|
||||||
|
checksum, and positive byte size. Non-current records cannot carry an output,
|
||||||
|
so an older file is not advertised through stale, missing, failed, or
|
||||||
|
unselected state.
|
||||||
|
|
||||||
|
Family-produced records additionally retain optional `family` and
|
||||||
|
`character_id` provenance supplied by configuration resolution. These fields
|
||||||
|
do not replace the concrete configured key or infer family membership from a
|
||||||
|
name, so older records without them remain valid.
|
||||||
|
|
||||||
|
The session-stage collection is the reconciled authority across invocations.
|
||||||
|
These records remain analyze-owned data inside the fixed stage; they are not
|
||||||
|
dynamic stages or generic subtasks.
|
||||||
|
|
||||||
|
Analyze checkpoints one complete validated candidate collection after each
|
||||||
|
successful artifact and before exposing that artifact to later work. It saves a
|
||||||
|
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
|
||||||
|
preserves valid unrelated current records. Rebuilt outputs are compared by
|
||||||
|
bytes and contract: an unchanged identity permits an unselected dependent with
|
||||||
|
the same recomputed fingerprint to remain current, while a changed identity
|
||||||
|
removes output authority from every unselected transitive dependent by marking
|
||||||
|
it stale. A partial analyze invocation can therefore succeed while unrelated
|
||||||
|
configured records remain stale. Existing canonical files never create current
|
||||||
|
records without validated execution and a successful checkpoint.
|
||||||
|
|
||||||
|
Aggregate analyze status is deliberately coarser than this collection. Resume
|
||||||
|
validation may skip a succeeded aggregate record when the selected artifact
|
||||||
|
closure is current even if unrelated records are stale. Conversely, a stale
|
||||||
|
aggregate record may cross the ordinary runner boundary and perform zero
|
||||||
|
Scriptorium calls when reconciliation proves every selected artifact current;
|
||||||
|
ordinary successful stage completion then restores the aggregate status.
|
||||||
|
|
||||||
|
On an artifact error, analyze installs a complete retry collection on the live
|
||||||
|
manifest before returning. The active record is failed without output, current
|
||||||
|
transitive dependents are stale, unrelated current records survive, and only
|
||||||
|
earlier checkpointed completions remain current. The runner's ordinary failure
|
||||||
|
path marks aggregate analyze failed, clears aggregate outputs, invalidates
|
||||||
|
delivery dependents, and saves that collection. A terminal session-save failure
|
||||||
|
is joined with the original adapter, validation, filesystem, cancellation, or
|
||||||
|
checkpoint cause.
|
||||||
|
|
||||||
|
If an artifact checkpoint fails, analyze rolls its working collection back to
|
||||||
|
the last committed collection before constructing the active failed record.
|
||||||
|
Newly materialized canonical bytes therefore remain incidental and cannot
|
||||||
|
become current authority. A later ordinary run reconciles the durable records,
|
||||||
|
reuses earlier current artifacts, and resumes with the failed or stale work.
|
||||||
|
The same recovery applies after process interruption: the aggregate record may
|
||||||
|
be non-successful, but every completed artifact checkpoint remains reusable.
|
||||||
|
|
||||||
## Remote Commit Manifest
|
## Remote Commit Manifest
|
||||||
|
|
||||||
@@ -60,66 +139,145 @@ 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 downstream 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;
|
||||||
|
dependents are returned in canonical order. Render and extract therefore never
|
||||||
|
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
|
||||||
|
per-artifact collection. This lets later reconciliation replace only evaluated
|
||||||
|
entries without erasing unrelated current results. Other stages retain their
|
||||||
|
existing aggregate-only lifecycle behavior and are forbidden from carrying the
|
||||||
|
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. Stages with a resume validator, currently extraction, may reject an
|
requires the persisted positive schema version and lowercase SHA-256 digest to
|
||||||
otherwise eligible skip when the recorded durable result is obsolete; the
|
match the current resolved stage semantics. Missing semantic evidence, malformed
|
||||||
runner marks it stale and executes it.
|
evidence, or a mismatch makes the stage and its fixed transitive dependents
|
||||||
|
stale. The existing stage-specific resume
|
||||||
|
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
|
||||||
|
reads the session manifest and accepts only `succeeded` or `skipped` for every
|
||||||
|
excluded canonical prefix stage. The first other status or absent record fails
|
||||||
|
the request before layout mutation, adapter initialization, session-manifest
|
||||||
|
writes, or run-directory creation. Excluded prefix records are not passed to
|
||||||
|
resume validators. Records after the selected end are not prerequisites and
|
||||||
|
may be made stale by selected work without being scheduled.
|
||||||
|
|
||||||
After a publish commits remotely, any configured local cleanup is first recorded
|
After a publish commits remotely, any configured local cleanup is first recorded
|
||||||
as a session-manifest obligation before deletion begins. Each target becomes
|
as a session-manifest obligation before deletion begins. Each target becomes
|
||||||
complete only after its confined deletion (or safe absence check) and a
|
complete only after its confined deletion (or safe absence check) and a
|
||||||
successful manifest save. An incomplete obligation is retried on later
|
successful manifest save. An incomplete obligation is retried when publish
|
||||||
invocations independently of their selected stages and retains the committed
|
executes again and retains the committed run and remote identity that authorized
|
||||||
run and remote identity that authorized it.
|
it; an invocation that does not execute publish does not perform cleanup.
|
||||||
|
|
||||||
Each invocation derives campaign, session, run, local-path, and remote-prefix
|
Each invocation derives campaign, session, run, local-path, and remote-prefix
|
||||||
metadata from the validated resolved configuration as one projection. A persisted
|
metadata from the validated resolved configuration as one projection. A persisted
|
||||||
@@ -127,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 downstream succeeded stages.
|
- 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,6 +52,26 @@ 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.
|
||||||
|
Pipeline configuration-path discovery, 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 the
|
||||||
|
canonical discovery and production defaults, while focused tests may provide
|
||||||
|
invocation-owned discovery candidates or replace only the external or sequencing
|
||||||
|
boundary they exercise without mutating package state.
|
||||||
|
|
||||||
|
Pipeline execution and `session plan` share the same inclusive contiguous-range
|
||||||
|
model and the same pure pre-execution decision over recorded status, force, and
|
||||||
|
normalized resume validation. Planning applies that decision and modeled
|
||||||
|
selected-stage transitions to cloned session state; it does not persist, create
|
||||||
|
invocation state, initialize stage-execution adapters, or claim to observe an
|
||||||
|
execution result. Command configuration loading can still retrieve a
|
||||||
|
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
|
||||||
|
|
||||||
The implemented canonical order is:
|
The implemented canonical order is:
|
||||||
@@ -53,26 +82,38 @@ The implemented canonical order is:
|
|||||||
4. [`polish`](stage-polish.md)
|
4. [`polish`](stage-polish.md)
|
||||||
5. [`normalize`](stage-normalize.md)
|
5. [`normalize`](stage-normalize.md)
|
||||||
6. [`trim`](stage-trim.md)
|
6. [`trim`](stage-trim.md)
|
||||||
7. [`extract`](stage-extract.md)
|
7. [`render`](stage-render.md)
|
||||||
8. [`render`](stage-render.md)
|
8. [`extract`](stage-extract.md)
|
||||||
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.
|
||||||
|
|
||||||
|
Execution order and invalidation are separate application contracts. The stage
|
||||||
|
registry owns the flat execution sequence. The application orchestration owner
|
||||||
|
uses a fixed, validated dependency relation to find transitive dependents in
|
||||||
|
canonical order. In particular, `render` and `extract` are sibling consumers of
|
||||||
|
trimmed transcript state: neither invalidates the other, while either can stale
|
||||||
|
`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.
|
||||||
@@ -84,10 +125,11 @@ and execution semantics.
|
|||||||
- [`polish`](stage-polish.md)
|
- [`polish`](stage-polish.md)
|
||||||
- [`normalize`](stage-normalize.md)
|
- [`normalize`](stage-normalize.md)
|
||||||
- [`trim`](stage-trim.md)
|
- [`trim`](stage-trim.md)
|
||||||
- [`extract`](stage-extract.md)
|
|
||||||
- [`render`](stage-render.md)
|
- [`render`](stage-render.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.
|
||||||
|
|||||||
@@ -2,11 +2,17 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Execute selected configured Scriptorium artifacts in dependency order and materialize outputs.
|
Reconcile configured Scriptorium artifacts, execute only required work in
|
||||||
|
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
|
||||||
@@ -14,35 +20,120 @@ Execute selected configured Scriptorium artifacts in dependency order and materi
|
|||||||
Supported source families:
|
Supported source families:
|
||||||
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
|
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
|
||||||
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`,
|
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`,
|
||||||
`narratio.input.glossary`
|
`narratio.input.glossary`, `narratio.input.spell_catalog`
|
||||||
- configured artifacts: `narratio.artifact.<key>`
|
- configured artifacts: `narratio.artifact.<key>`
|
||||||
- extraction lanes: `narratio.extraction.<key>`
|
- extraction lanes: `narratio.extraction.<key>`
|
||||||
- previous-session cache: `narratio.previous_session.artifact.<key>`
|
- previous-session cache: `narratio.previous_session.artifact.<key>`
|
||||||
|
|
||||||
## Outputs
|
## Outputs
|
||||||
|
|
||||||
- one materialized output per executed configured artifact (`output_path`)
|
- one current per-artifact manifest record per validated materialized output
|
||||||
- stage metadata describing selected/generated/reused artifacts
|
- stage metadata describing selected/generated/reused artifacts
|
||||||
|
|
||||||
## Key Behavior
|
## Key Behavior
|
||||||
|
|
||||||
- when Scriptorium is absent or no configured artifact is executable, completes
|
- when `pipeline.scriptorium` is absent or no configured artifact is
|
||||||
successfully with no outputs and records explanatory metadata. This is not an
|
executable, completes successfully and records explanatory metadata. Before
|
||||||
explicit self-skip: both manifests record success, satisfy publish's
|
returning, it reconciles and checkpoints the complete session collection:
|
||||||
prerequisite, and an ordinary later run reuses the result until forced.
|
records removed from configuration are dropped, while valid records for
|
||||||
|
configured but unselected artifacts remain authoritative. This is not an
|
||||||
|
explicit self-skip: the session manifest records success, satisfies publish's
|
||||||
|
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.
|
||||||
- uses enabled configured artifacts by default. An explicit `--artifacts`
|
- uses enabled configured artifacts by default. An explicit `--artifacts`
|
||||||
selection is a one-invocation override: it makes exactly the named configured
|
selection is a one-invocation override that makes exactly the named
|
||||||
artifacts executable even when disabled, and does not automatically include
|
configured artifacts explicit targets even when disabled. The work planner
|
||||||
dependencies. A selected artifact's dependencies must instead already be
|
adds required configured prerequisites, reuses current ones, and schedules
|
||||||
available to the catalog.
|
stale, missing, or otherwise non-current prerequisites before dependents.
|
||||||
- marks non-executable configured artifacts as reusable when output files already exist.
|
- makes a non-executable configured artifact reusable only when its current
|
||||||
|
manifest record and durable output pass the configured-artifact evidence
|
||||||
|
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).
|
||||||
- resolves required/optional inputs per artifact source definition.
|
- consumes only the concrete dependency graph finalized by configuration;
|
||||||
- omits an unavailable optional input; an unavailable required input fails.
|
planning, prerequisite closure, execution, reuse, invalidation, and semantic
|
||||||
- resolves prepared stable input sources from `inputs/*.yml` materialized by `prepare`.
|
evidence do not infer a second graph from inputs.
|
||||||
|
- resolves required/optional inputs per artifact source definition into an
|
||||||
|
ordered semantic identity. Each identity records the configured input name,
|
||||||
|
canonical source ID, required policy, explicit presence, source contract,
|
||||||
|
checksum, size, and a source-based logical identity. Workspace paths and
|
||||||
|
producer run IDs are excluded.
|
||||||
|
- orders input identities by configured input name independently of Go map
|
||||||
|
iteration. Runtime adapter paths remain a separate execution-only map.
|
||||||
|
- omits an unavailable optional input from the adapter request while retaining
|
||||||
|
explicit absence in its semantic identity; an unavailable required input
|
||||||
|
fails.
|
||||||
|
- resolves prepared stable input sources through the shared manifest-authoritative
|
||||||
|
identity resolver; it does not accept incidental files or fall back to
|
||||||
|
campaign/session source paths.
|
||||||
|
- reuses checksums and sizes from validated prepared, extraction, and current
|
||||||
|
configured-artifact evidence. Other resolved inputs are hashed as confined
|
||||||
|
regular files with streaming reads and the central resolved-artifact size
|
||||||
|
limit.
|
||||||
|
- owns a versioned SHA-256 fingerprint contract with one fixed-field canonical
|
||||||
|
JSON payload and no map serialization. Configured artifacts are fingerprinted
|
||||||
|
in deterministic dependency order.
|
||||||
|
- fingerprints the normalized artifact key, prompt and profile identifiers,
|
||||||
|
effective render-debug behavior, session-relative output identity, sorted
|
||||||
|
dependency keys, ordered input declarations and semantic identities,
|
||||||
|
validated current dependency-output identities, and sorted effective
|
||||||
|
Scriptorium variables (including Narratio's sticky session variable).
|
||||||
|
- provides read-only reconciliation that classifies each configured record as
|
||||||
|
current, stale, missing, failed, or otherwise non-resumable, and
|
||||||
|
separately identifies manifest records removed from current configuration.
|
||||||
|
A record is current only when its fingerprint version and value match and its
|
||||||
|
configured output still passes manifest-authoritative evidence validation.
|
||||||
|
- owns a read-only typed work planner. Its explicit targets are enabled
|
||||||
|
artifacts by default or the exact normalized `--artifacts` selection when
|
||||||
|
supplied. It closes targets over configured prerequisites, orders the closure
|
||||||
|
topologically, reuses current members, and schedules every non-current member
|
||||||
|
before its dependents.
|
||||||
|
- force applies only to explicit targets. A current prerequisite is reused
|
||||||
|
unless it is itself an explicit forced target; disabled prerequisites may be
|
||||||
|
rebuilt when required, while unrelated disabled artifacts are excluded.
|
||||||
|
- the work plan carries explicit targets, prerequisite-only work, deterministic
|
||||||
|
execution and reuse lists, invalidated and removed records, and a cloned
|
||||||
|
reconciled starting collection. Valid unrelated configured records survive
|
||||||
|
reconciliation, removed records are omitted, and incidental files never
|
||||||
|
become current without manifest-backed regeneration.
|
||||||
|
- implements aggregate resume validation by running the same read-only catalog,
|
||||||
|
fingerprint reconciliation, and work planner used by execution. A succeeded
|
||||||
|
aggregate record is reusable exactly when the selected closure schedules no
|
||||||
|
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
|
||||||
|
a cloned manifest after modeling earlier selected stage transitions, so
|
||||||
|
aggregate run/skip and artifact execute/reuse decisions match the ordinary
|
||||||
|
runner without creating durable state or invoking Scriptorium.
|
||||||
|
- executes only the work plan's scheduled entries. Manifest-validated current
|
||||||
|
prerequisites remain available through the runtime catalog without invoking
|
||||||
|
Scriptorium; newly produced prerequisites enter that catalog with the same
|
||||||
|
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
|
||||||
|
it is a safe, non-empty, bounded regular file with a calculated checksum and
|
||||||
|
complete output contract. Canonical replacement uses the shared atomic file
|
||||||
|
operation boundary and verifies that the installed checksum matches the
|
||||||
|
validated run-local bytes.
|
||||||
|
- records each successful artifact's freshly computed fingerprint, canonical
|
||||||
|
relative output path, contract, checksum, size, producer run ID, bounded
|
||||||
|
Scriptorium provenance, logs, and generated configuration references in the
|
||||||
|
analyze-owned manifest collection.
|
||||||
|
- preserves valid unrelated current records during partial execution. If a
|
||||||
|
rebuilt output's bytes and contract are unchanged, unselected dependents may
|
||||||
|
remain current. If that semantic identity changes, unselected transitive
|
||||||
|
dependents become stale without being executed; dependents included in the
|
||||||
|
invocation are evaluated in dependency order instead.
|
||||||
|
- returns aggregate outputs from every current session record in normalized-key
|
||||||
|
order after the complete reconciled collection has been checkpointed;
|
||||||
|
unrelated stale records do not make an otherwise successful partial
|
||||||
|
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.
|
||||||
- validates non-empty output files and materializes canonical outputs.
|
- validates non-empty output files and materializes canonical outputs.
|
||||||
@@ -57,17 +148,63 @@ 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 installs a retry collection on the live manifest
|
||||||
|
with the active artifact marked `failed`, a bounded error, and no output
|
||||||
|
authority. Current transitive dependents become stale without execution.
|
||||||
|
- earlier artifacts remain current only after their run-local output passed
|
||||||
|
validation, canonical materialization, and a successful complete-collection
|
||||||
|
manifest checkpoint; unattempted later artifacts retain their reconciled
|
||||||
|
session state.
|
||||||
|
- unrelated current records survive a partial failure. Old canonical bytes for
|
||||||
|
the failed artifact and newly materialized bytes whose checkpoint fails are
|
||||||
|
incidental, not current evidence.
|
||||||
|
- a checkpoint failure stops the queue immediately. The in-memory retry
|
||||||
|
collection starts from the last successful checkpoint, marks the active
|
||||||
|
artifact failed without output authority, and stales its current transitive
|
||||||
|
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,
|
||||||
|
materialize outputs, update status, or create run-local files.
|
||||||
|
- fingerprints exclude timeouts, retries, timestamps, producer and Narratio run
|
||||||
|
IDs, executable and config paths, workspace roots, diagnostic locations, and
|
||||||
|
executable or private transitive configuration contents. A change that is
|
||||||
|
visible only inside Scriptorium—such as a file privately loaded by its config
|
||||||
|
path—requires an explicit forced regeneration.
|
||||||
- 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
|
||||||
|
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_test.go`
|
`internal/stage/analyze_artifact_execution.go`,
|
||||||
|
`internal/stage/analyze_execution_state.go`,
|
||||||
|
`internal/stage/analyze_input_identity.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_reconciliation_test.go`,
|
||||||
|
`internal/stage/analyze_plan_test.go`,
|
||||||
|
`internal/stage/analyze_incremental_execution_test.go`,
|
||||||
|
`internal/stage/analyze_failure_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`
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Responsibility
|
## Responsibility
|
||||||
|
|
||||||
`extract` runs after `trim` and before `render`. It converts the canonical
|
`extract` runs after `render` and before `analyze`. It converts the canonical
|
||||||
`narratio.transcript.final_trimmed` JSON into configured Notarius lane artifacts.
|
`narratio.transcript.final_trimmed` JSON into configured Notarius lane artifacts.
|
||||||
An omitted or disabled Notarius section makes the stage explicitly self-skip
|
An omitted or disabled Notarius section makes the stage explicitly self-skip
|
||||||
with reason `notarius_disabled`, no outputs, and no Notarius runner.
|
with reason `notarius_disabled`, no outputs, and no Notarius runner.
|
||||||
@@ -17,15 +17,24 @@ procedures belong in [Operations](../operations.md).
|
|||||||
`internal/stage/extract.go`:
|
`internal/stage/extract.go`:
|
||||||
|
|
||||||
1. resolves the final trimmed transcript from the shared artifact catalog;
|
1. resolves the final trimmed transcript from the shared artifact catalog;
|
||||||
2. resolves and fingerprints the Notarius invocation contract;
|
2. resolves every configured prepared reference through the shared
|
||||||
3. creates a run-local staging directory and invokes the injected
|
manifest-authoritative identity resolver before creating run-local output;
|
||||||
`notarius.Runner`;
|
3. resolves each reference to a stable content-addressed session snapshot,
|
||||||
4. validates the successful receipt, confined index, configured required lane
|
reusing verified bytes or reconstructing a missing/corrupt regular entry,
|
||||||
descriptors, and regular payload files;
|
and rejects any source change observed while copying;
|
||||||
5. atomically promotes the complete bundle to its immutable durable location;
|
4. fingerprints the byte- and provenance-bearing Notarius invocation evidence,
|
||||||
6. records one non-selectable `notarius_index` output and one selectable
|
including sorted reference identities;
|
||||||
|
5. creates a run-local staging directory and invokes the injected
|
||||||
|
`notarius.Runner`, carrying the optional already-resolved credential
|
||||||
|
reference without exposing its value, plus effective resume and chunk-cache
|
||||||
|
controls after force precedence;
|
||||||
|
6. revalidates the reference snapshots, then validates the v2 successful
|
||||||
|
receipt, confined index, management documents, configured required lane
|
||||||
|
descriptors, validation summaries, and regular payload files;
|
||||||
|
7. atomically promotes the complete bundle to its immutable durable location;
|
||||||
|
8. records one non-selectable `notarius_index` output and one selectable
|
||||||
`notarius_lane` output per configured lane; and
|
`notarius_lane` output per configured lane; and
|
||||||
7. registers each lane as `narratio.extraction.<output_key>` for downstream
|
9. registers each lane as `narratio.extraction.<output_key>` for downstream
|
||||||
Scriptorium and publish resolution.
|
Scriptorium and publish resolution.
|
||||||
|
|
||||||
Lane records retain checksum, contract, producer run ID, and Notarius system,
|
Lane records retain checksum, contract, producer run ID, and Notarius system,
|
||||||
@@ -34,22 +43,55 @@ root, receipt, diagnostic paths, rejection/warning summaries, producing
|
|||||||
Narratio run ID, the resolved trimmed-input identity, and invocation
|
Narratio run ID, the resolved trimmed-input identity, and invocation
|
||||||
fingerprint. The input identity binds the exact transcript bytes, canonical
|
fingerprint. The input identity binds the exact transcript bytes, canonical
|
||||||
source ID, producer stage/output/run identity, and resolution provenance.
|
source ID, producer stage/output/run identity, and resolution provenance.
|
||||||
Validation completes before
|
Reference metadata contains only selector, source ID, canonical session-relative
|
||||||
promotion, so a rejected result cannot expose a partial durable bundle.
|
path, checksum, and size; adapter requests receive selector and absolute stable
|
||||||
|
snapshot path, never payload contents. Snapshot bytes must
|
||||||
|
match the prepared identity both before and after Notarius runs, so a concurrent
|
||||||
|
prepared-file replacement cannot make recorded provenance describe different
|
||||||
|
bytes from those supplied to Notarius.
|
||||||
|
Validation completes before the single durable rename, so a rejected result
|
||||||
|
cannot expose a partial durable bundle.
|
||||||
|
|
||||||
|
Snapshot destinations are owned by `internal/artifacts` under
|
||||||
|
`cache/notarius/references/<sha256>/<prepared-basename>`. They contain no run ID
|
||||||
|
or timestamp and can be reused across invocations. Identical bytes and basename
|
||||||
|
share a verified file; changed bytes select a different digest directory.
|
||||||
|
Unsafe path components and non-regular conflicts fail without recursive
|
||||||
|
deletion. Snapshot paths are delivery details and never become manifest output
|
||||||
|
evidence or prerequisites for reuse of an already complete extract bundle.
|
||||||
|
|
||||||
Any executed extraction outcome that replaces a different effective outcome
|
Any executed extraction outcome that replaces a different effective outcome
|
||||||
marks succeeded downstream stages stale. Repeating the same disabled self-skip
|
marks succeeded analysis and delivery dependents stale. Render is an independent
|
||||||
with no outputs is stable and does not repeatedly invalidate downstream stages.
|
sibling and remains current. Repeating the same disabled self-skip with no
|
||||||
|
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, and the current
|
canonical `narratio.extraction.<key>` output identity. It excludes executable,
|
||||||
direct trimmed-transcript identity. The same identity is resolved again for
|
timeout, working directory, config path, and private Notarius config contents.
|
||||||
artifact evidence, so changing the current transcript bytes or producer
|
Credential presence, reference name, and resolved value are likewise
|
||||||
identity makes the prior extraction obsolete.
|
operational-only and excluded. Ordinary command loading still resolves a
|
||||||
|
selected reference before resume evaluation, but a credential-only change does
|
||||||
|
not rerun a valid completed extraction or stale its dependents.
|
||||||
|
|
||||||
|
`internal/stage/extract_resume.go` then permits a skip only when the existing
|
||||||
|
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. This includes
|
||||||
|
`resume` and `chunk_cache`; changing them alone cannot make a complete
|
||||||
|
extraction non-resumable. If extraction executes, ordinary runs forward the
|
||||||
|
configured values, while force disables resume and selects `refresh`.
|
||||||
|
|
||||||
|
A valid prepared-reference change makes extraction non-resumable. Missing,
|
||||||
|
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
|
||||||
|
with prepare-force guidance because an immediate extract rerun cannot succeed.
|
||||||
|
|
||||||
The validator then checks the producing run identity, canonical immutable
|
The validator then checks the producing run identity, canonical immutable
|
||||||
bundle root, path confinement and absence of symlink components, receipt
|
bundle root, path confinement and absence of symlink components, receipt
|
||||||
@@ -58,33 +100,41 @@ 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, required-lane rejection, payload inspection, checksum, or
|
index compatibility, inconsistent warning or diagnostic envelopes,
|
||||||
promotion errors fail the stage through ordinary manifest transition handling.
|
required-lane rejection or incomplete validation, payload inspection,
|
||||||
|
checksum, or installation errors fail the stage through ordinary manifest
|
||||||
|
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`
|
||||||
|
- Stable snapshot materialization and verification:
|
||||||
|
`internal/stage/extract_references.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`,
|
||||||
`internal/artifacts/extraction_catalog_test.go`
|
`internal/artifacts/extraction_catalog_test.go`
|
||||||
- Composition and downstream behavior: `internal/app/runner_test.go`,
|
- Composition and downstream behavior: `internal/app/runner_test.go`,
|
||||||
`internal/stage/analyze_test.go`, `internal/stage/publish_test.go`
|
`internal/stage/analyze_test.go`, `internal/stage/publish_test.go`
|
||||||
|
- Opt-in Notarius v0.7.0 production-boundary coverage:
|
||||||
|
`internal/stage/notarius_cache_integration_test.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`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
|
|
||||||
- resolved campaign, session, and pipeline configuration
|
- resolved campaign, session, and pipeline configuration
|
||||||
- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
|
- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
|
||||||
|
- optional spell-catalog overlay
|
||||||
- one resolved local or S3 audio source
|
- one resolved local or S3 audio source
|
||||||
- enabled configured artifact input requirements for previous-session sources
|
- enabled configured artifact input requirements for previous-session sources
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
- `inputs/glossary.yml`
|
- `inputs/glossary.yml`
|
||||||
- `inputs/players.yml`
|
- `inputs/players.yml`
|
||||||
- `inputs/party.yml`
|
- `inputs/party.yml`
|
||||||
|
- optional `inputs/spell_catalog.json`
|
||||||
- `audio/*.flac`
|
- `audio/*.flac`
|
||||||
- optional `previous/manifest.json`
|
- optional `previous/manifest.json`
|
||||||
- optional `previous/artifacts/**`
|
- optional `previous/artifacts/**`
|
||||||
@@ -34,6 +36,11 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
- gives distinct local source paths with the same basename deterministic unique
|
- gives distinct local source paths with the same basename deterministic unique
|
||||||
prepared filenames so neither source is overwritten.
|
prepared filenames so neither source is overwritten.
|
||||||
- materializes S3 audio through spool/cache-aware logic.
|
- materializes S3 audio through spool/cache-aware logic.
|
||||||
|
- materializes a configured spell catalog with checksum and provenance, or
|
||||||
|
safely removes an obsolete canonical spell catalog and its manifest record
|
||||||
|
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;
|
||||||
@@ -41,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
|
||||||
|
|
||||||
@@ -52,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
|
||||||
@@ -61,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,36 +26,63 @@ 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.
|
||||||
- derives the durable previous-cache archive from its validated manifest using
|
- derives the durable previous-cache archive from its validated manifest using
|
||||||
the same confinement and regular-file checks.
|
the same confinement and regular-file checks.
|
||||||
- resolves publish output sources through runtime artifact catalog and manifest-aware resolution.
|
- resolves publish output sources through runtime artifact catalog and
|
||||||
|
manifest-aware resolution. Configured Scriptorium outputs are publishable
|
||||||
|
only from validated `current` per-artifact analyze evidence; an incidental
|
||||||
|
canonical file, aggregate output, stale/failed/unselected record, or
|
||||||
|
mismatched path, size, or checksum remains unavailable. Built-in and
|
||||||
|
extraction sources likewise require their own current manifest evidence.
|
||||||
- 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
|
||||||
|
|
||||||
@@ -62,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
|
||||||
|
|
||||||
@@ -77,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`
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
|
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
|
||||||
|
It runs after `trim` and before `extract` in the canonical sequence. Render and
|
||||||
|
extract are independent sibling consumers: replacing render output does not
|
||||||
|
invalidate extraction, but it does invalidate succeeded analysis and delivery
|
||||||
|
records that may consume rendered transcripts.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
@@ -17,12 +21,13 @@ Render Markdown transcript artifacts from normalized JSON transcripts via Seriat
|
|||||||
## 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
|
||||||
|
|
||||||
@@ -36,9 +41,20 @@ Render Markdown transcript artifacts from normalized JSON transcripts via Seriat
|
|||||||
- 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,15 +33,16 @@ 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).
|
||||||
|
|
||||||
|
Prepared references delivered to Notarius use session-durable paths owned by
|
||||||
|
`internal/artifacts` beneath
|
||||||
|
`cache/notarius/references/<sha256>/<prepared-basename>`. These verified
|
||||||
|
content-addressed snapshots are outside run-local layout, survive ordinary
|
||||||
|
published-run cleanup, and are excluded from publish and restore enumeration.
|
||||||
|
|
||||||
## Locking
|
## Locking
|
||||||
|
|
||||||
`artifacts.LocalStore` enforces the single-writer session lock via an
|
`artifacts.LocalStore` enforces the single-writer session lock via an
|
||||||
@@ -50,21 +51,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 +80,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 +90,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`
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ narratio session init 2026-04-04 --remote --force
|
|||||||
|
|
||||||
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
|
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
|
||||||
|
|
||||||
Campaigns must provide stable input files for speakers, autocorrect, glossary, players, and party. Session files may override those paths for one session. The `prepare` stage materializes them under `inputs/`; configured Scriptorium artifacts can reference prepared `players`, `party`, and `glossary` files with `narratio.input.players`, `narratio.input.party`, and `narratio.input.glossary`.
|
Campaigns must provide stable input files for speakers, autocorrect, glossary,
|
||||||
|
players, and party, and may provide an optional spell-catalog overlay. Session
|
||||||
|
files may override those paths for one session. The `prepare` stage
|
||||||
|
materializes them under `inputs/`; configured consumers use the prepared files,
|
||||||
|
never the original campaign or session source paths. Field definitions and
|
||||||
|
source IDs are in [Configuration](./config.md#notarius-reference-bindings).
|
||||||
|
|
||||||
## Standard Session Workflow
|
## Standard Session Workflow
|
||||||
|
|
||||||
@@ -65,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:
|
||||||
@@ -75,22 +96,45 @@ Canonical stage order:
|
|||||||
4. `polish`
|
4. `polish`
|
||||||
5. `normalize`
|
5. `normalize`
|
||||||
6. `trim`
|
6. `trim`
|
||||||
7. `extract`
|
7. `render`
|
||||||
8. `render`
|
8. `extract`
|
||||||
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 an upstream stage marks succeeded downstream stages as `stale` before
|
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
||||||
the replacement runs; and
|
replacement runs; render and extract are independent siblings; and
|
||||||
- an executed failure, changed self-skip, or success that replaces a different
|
- an executed failure, changed self-skip, or success that replaces a different
|
||||||
effective upstream outcome also marks succeeded downstream stages stale. A
|
effective outcome uses the same fixed dependency relation. A
|
||||||
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 downstream 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`
|
||||||
@@ -106,14 +150,105 @@ Single-stage execution:
|
|||||||
narratio run-stage normalize 2026-04-04 --force
|
narratio run-stage normalize 2026-04-04 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Contiguous bounded execution uses inclusive canonical endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session plan 2026-04-04 --from extract --through analyze --force
|
||||||
|
narratio run 2026-04-04 --from extract --through analyze --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `--from` selects from `prepare`; omitting `--through` selects through
|
||||||
|
`cleanup`. Force applies only within the selected range. Repeating `--from`,
|
||||||
|
`--through`, or `--force` is rejected instead of resolving by argument order.
|
||||||
|
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
|
||||||
|
stage transitions and invalidation in memory, and invokes resume validation
|
||||||
|
without writing the manifest, creating run directories, materializing files,
|
||||||
|
or invoking pipeline adapters. Analyze detail separates explicit targets,
|
||||||
|
prerequisite rebuilds, scheduled execution, and current reuse. This lets a
|
||||||
|
coarsely stale aggregate analyze stage show zero artifact executions when its
|
||||||
|
selected artifact evidence is still semantically current.
|
||||||
|
|
||||||
|
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
|
||||||
|
`skipped`. Narratio reports the first absent, pending, running, failed, stale,
|
||||||
|
or interrupted prerequisite without creating a run-local directory or changing
|
||||||
|
session state. Widen `--from` to include that stage, or recover it explicitly
|
||||||
|
before retrying. Excluded prefix stages are not resume-validated or repaired as
|
||||||
|
part of the bounded invocation; selected stages still reject missing, unsafe,
|
||||||
|
or manifest-inconsistent inputs at their owning boundary.
|
||||||
|
|
||||||
|
Stages after `--through` are not prerequisites and are never scheduled by the
|
||||||
|
bounded invocation. A selected forced stage can mark one of those succeeded
|
||||||
|
dependents stale through the fixed invalidation relation, but the dependent
|
||||||
|
does not execute until a later invocation selects it. Production composition
|
||||||
|
likewise initializes only collaborators needed by the selected range and
|
||||||
|
shared session lifecycle. In particular, render does not require Notarius or
|
||||||
|
Scriptorium, extract does not require Scriptorium, and analyze does not require
|
||||||
|
the transcription, Seriatim, Audita, or Notarius adapters.
|
||||||
|
|
||||||
|
## Artifact Development Workflows
|
||||||
|
|
||||||
|
Choose the narrowest workflow that matches the intended replacement:
|
||||||
|
|
||||||
|
| Intent | Command | Preserved and rebuilt state |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Rebuild analysis only | [`narratio analyze 2026-04-04`](./cli.md#analyze) | Preserves transcripts and the current extraction bundle; force-runs selected analysis artifacts and their required analysis prerequisites. |
|
||||||
|
| Rebuild extraction and analysis | [`narratio regenerate-artifacts 2026-04-04`](./cli.md#regenerate-artifacts) | Preserves transcripts; force-runs the complete configured extraction bundle, then selected analysis. `--artifacts` narrows analysis only. |
|
||||||
|
| Continue bounded work normally | [`narratio run 2026-04-04 --from extract --through analyze`](./cli.md#run) | Uses ordinary resume decisions: current extraction and analysis can be skipped, while incomplete or obsolete work runs. |
|
||||||
|
|
||||||
|
All three stop before publication. Inspect the results and run
|
||||||
|
[`narratio publish`](./cli.md#publish) separately when delivery is intended.
|
||||||
|
|
||||||
|
For the extraction-plus-analysis development loop, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio regenerate-artifacts 2026-04-04
|
||||||
|
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
|
||||||
|
```
|
||||||
|
|
||||||
|
This command is a transparent expansion to a forced bounded `run` from
|
||||||
|
`extract` through `analyze`. Extraction always rebuilds its complete configured
|
||||||
|
bundle. Analysis rebuilds the selected targets and their required analysis
|
||||||
|
prerequisites, or uses the normal default selection when no artifact names are
|
||||||
|
given. The command does not run publish, notify, or cleanup; delivery and
|
||||||
|
retention remain separate operator actions.
|
||||||
|
|
||||||
|
Inspect current artifact evidence, then publish explicitly when the regenerated
|
||||||
|
set is ready:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session artifacts 2026-04-04
|
||||||
|
narratio publish 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
|
If planning or execution reports stale, missing, failed, or tampered
|
||||||
|
analysis evidence, regenerate the affected target instead of copying an older
|
||||||
|
canonical file into place or editing the manifest. See
|
||||||
|
[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
|
||||||
|
|
||||||
`--artifacts` can be used on `run`, `run-stage`, `analyze`, and `publish`.
|
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
|
||||||
|
`publish`. For a bounded run or plan, the selected range must contain `analyze`
|
||||||
|
or `publish`.
|
||||||
|
|
||||||
Selection behavior:
|
Selection behavior:
|
||||||
|
|
||||||
- validates names against `pipeline.scriptorium.artifacts`;
|
- validates names against `pipeline.scriptorium.artifacts`;
|
||||||
- filters analyze execution to selected configured artifacts;
|
- selects explicit analyze targets and permits their required configured
|
||||||
|
prerequisites to be reused or rebuilt first;
|
||||||
- filters publish rules for `narratio.artifact.<name>` sources only;
|
- filters publish rules for `narratio.artifact.<name>` sources only;
|
||||||
- does not suppress built-in transcript, bounds, or explicitly configured
|
- does not suppress built-in transcript, bounds, or explicitly configured
|
||||||
`narratio.extraction.<name>` publish sources; and
|
`narratio.extraction.<name>` publish sources; and
|
||||||
@@ -135,17 +270,72 @@ The directory is immutable once promoted. Configured lanes become
|
|||||||
the bundle and `index.json` are retained for audit and resume validation but
|
the bundle and `index.json` are retained for audit and resume validation but
|
||||||
are not selectable or published implicitly.
|
are not selectable or published implicitly.
|
||||||
|
|
||||||
|
Configured Notarius references resolve only from the current manifest-backed
|
||||||
|
prepared inputs. Their canonical locations are `inputs/party.yml`,
|
||||||
|
`inputs/players.yml`, `inputs/glossary.yml`, and, when configured,
|
||||||
|
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified,
|
||||||
|
content-addressed copies under
|
||||||
|
`cache/notarius/references/<sha256>/<prepared-basename>`. The full lowercase
|
||||||
|
SHA-256 digest identifies the reference bytes, so retries and later invocations
|
||||||
|
reuse the same path while changed bytes select a new path. Older snapshots are
|
||||||
|
retained and are not current artifact evidence. A concurrent refresh of
|
||||||
|
canonical prepared files therefore 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
|
||||||
|
prepared-file readiness with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session status 2026-04-04
|
||||||
|
narratio session validate 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference metadata records selector, source ID, session-relative path,
|
||||||
|
checksum, and byte size, but never payload contents. Changing a prepared
|
||||||
|
reference changes extraction identity: ordinary continuation rejects the old
|
||||||
|
result, reruns Notarius, and marks successful downstream stages stale. If the
|
||||||
|
prepared file is missing or inconsistent with its manifest checksum, repair
|
||||||
|
the source configuration and refresh prepared state first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
`pipeline.notarius.resume` and `pipeline.notarius.chunk_cache` control work
|
||||||
|
inside an executed Notarius invocation. Ordinary extraction forwards their
|
||||||
|
configured values. Forced extraction always disables checkpoint loading and
|
||||||
|
requests chunk-plan `refresh`, even when the configured mode is `bypass`.
|
||||||
|
Refresh regenerates and replaces only the active input's accepted chunk plan;
|
||||||
|
it is not cache deletion.
|
||||||
|
|
||||||
|
Notarius checkpoint recording must be enabled in the deployed Notarius
|
||||||
|
configuration. Narratio neither creates a Notarius pipeline/configuration nor
|
||||||
|
reads it to infer checkpoint availability. Configure explicit persistent
|
||||||
|
Notarius chunk-plan and checkpoint roots when reuse must survive separate
|
||||||
|
Narratio subprocesses. A changed reference, transcript, selected profile,
|
||||||
|
profile content, endpoint, or other Notarius checkpoint identity input can
|
||||||
|
produce a legitimate cold miss.
|
||||||
|
|
||||||
|
There are two independent resume boundaries: Narratio may skip a completed
|
||||||
|
`extract` stage without invoking Notarius, while `--resume` allows Notarius to
|
||||||
|
reuse compatible accepted work only after Narratio has decided extraction must
|
||||||
|
execute. Deleting Narratio's local reference snapshots does not invalidate a
|
||||||
|
complete durable extraction; missing snapshots are reconstructed only when a
|
||||||
|
later extraction actually runs.
|
||||||
|
|
||||||
## External Command Lifecycle
|
## External Command Lifecycle
|
||||||
|
|
||||||
@@ -168,12 +358,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:
|
||||||
|
|
||||||
@@ -181,15 +374,27 @@ To intentionally replace the current extraction result, run:
|
|||||||
narratio run-stage extract 2026-04-04 --force
|
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,
|
||||||
or durable output validation changes. It cannot fingerprint configuration
|
prepared Narratio reference identities, or durable output validation changes.
|
||||||
files, profiles, prompts, modules, or references loaded transitively by
|
The semantic portion covers Notarius enablement, pipeline identity, declared
|
||||||
Notarius. Force extraction after changing any of those inputs, even when the
|
reference mapping, and output contracts. Executable, timeout, working directory,
|
||||||
top-level Narratio and Notarius config paths remain the same. A forced extract
|
and private config-file paths are operational and do not invalidate a current
|
||||||
|
result.
|
||||||
|
It cannot fingerprint configuration files, profiles, prompts, modules, or
|
||||||
|
other references loaded transitively by Notarius itself. Force extraction after
|
||||||
|
changing any of those inputs, even when the top-level Narratio and Notarius
|
||||||
|
config paths remain the same. A forced extract
|
||||||
marks successful downstream stages stale. Ordinary extraction failures or
|
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:
|
||||||
@@ -204,6 +409,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
|
||||||
@@ -227,31 +435,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:
|
||||||
|
|
||||||
@@ -285,13 +506,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`.
|
||||||
|
|
||||||
@@ -319,10 +549,24 @@ Durable session paths:
|
|||||||
- `logs/**`
|
- `logs/**`
|
||||||
- `config/**`
|
- `config/**`
|
||||||
- `runs/**`
|
- `runs/**`
|
||||||
|
- `cache/notarius/references/<sha256>/<prepared-basename>`
|
||||||
|
|
||||||
|
`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).
|
||||||
|
Stable reference snapshots live in the session cache path above. Ordinary
|
||||||
|
published-run cleanup removes its selected run-local target but retains this
|
||||||
|
cache, and publish/restore never enumerate it as an artifact. Remove a snapshot
|
||||||
|
or digest directory manually only while no command is active for the session;
|
||||||
|
regular missing or corrupt content is reconstructed on the next executed
|
||||||
|
extraction, while symlinks, directories, and other unsafe conflicts must be
|
||||||
|
investigated and removed deliberately.
|
||||||
|
|
||||||
Run-local layout:
|
Run-local layout:
|
||||||
|
|
||||||
@@ -344,6 +588,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
|
||||||
|
|
||||||
@@ -355,18 +601,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
|
||||||
@@ -399,13 +655,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.
|
||||||
Narratio to retry only the outstanding confined local cleanup.
|
|
||||||
|
Cleanup derives only the configured run-scoped spool and workspace targets,
|
||||||
|
uses root-confined deletion, and does not read or mutate object storage. A
|
||||||
|
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
|
||||||
|
|
||||||
@@ -413,4 +685,5 @@ Rules:
|
|||||||
- 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.
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ in the [integration documentation](../integrations/).
|
|||||||
The pipeline has one canonical ordered stage set. Configuration may enable,
|
The pipeline has one canonical ordered stage set. Configuration may enable,
|
||||||
disable, or parameterize supported behavior, but it must not turn that sequence
|
disable, or parameterize supported behavior, but it must not turn that sequence
|
||||||
into an arbitrary DAG or hide orchestration in generic workflow abstractions.
|
into an arbitrary DAG or hide orchestration in generic workflow abstractions.
|
||||||
|
An invocation selects either the full sequence or one inclusive contiguous
|
||||||
|
range of it. Execution remains flat and canonical even though invalidation is
|
||||||
|
dependency-aware: the application owns a separate fixed relation used only to
|
||||||
|
stale transitive dependents, including dependents outside a selected range.
|
||||||
The implemented stage inventory belongs in the
|
The implemented stage inventory belongs in the
|
||||||
[Internal Overview](../internal/overview.md).
|
[Internal Overview](../internal/overview.md).
|
||||||
|
|
||||||
@@ -87,8 +91,10 @@ merely on incidental files existing on disk.
|
|||||||
|
|
||||||
A failed or interrupted stage must not be presented as successful. Failure
|
A failed or interrupted stage must not be presented as successful. Failure
|
||||||
should preserve enough local state and diagnostics for inspection, recovery,
|
should preserve enough local state and diagnostics for inspection, recovery,
|
||||||
and resume. Forcing an upstream stage invalidates succeeded downstream work
|
and resume. Forcing a stage invalidates succeeded transitive dependents
|
||||||
according to the canonical stage order.
|
according to a fixed application-owned relation that is separate from canonical
|
||||||
|
execution order. The relation is validated against the stage inventory and is
|
||||||
|
not configurable.
|
||||||
|
|
||||||
A stage may explicitly self-skip with a stable reason and no outputs. That
|
A stage may explicitly self-skip with a stable reason and no outputs. That
|
||||||
outcome is persisted, clears older outputs owned by the stage, and is
|
outcome is persisted, clears older outputs owned by the stage, and is
|
||||||
@@ -110,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
|
||||||
@@ -137,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
|
||||||
@@ -171,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
|
||||||
@@ -188,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
|
||||||
|
|
||||||
@@ -207,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.
|
||||||
28
docs/releases/README.md
Normal file
28
docs/releases/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
This directory contains immutable historical release notes for Narratio.
|
||||||
|
Future notes are created with the matching stable version and use this minimum
|
||||||
|
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)
|
||||||
47
docs/releases/v1.5.0.md
Normal file
47
docs/releases/v1.5.0.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Narratio v1.5.0
|
||||||
|
|
||||||
|
Narratio v1.5.0 makes repeated post-transcript artifact development faster and
|
||||||
|
more explicit while retaining the fixed, stage-driven pipeline model.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- The canonical pipeline now completes deterministic rendering before
|
||||||
|
extraction, cleanly separating transcript-generating stages from
|
||||||
|
artifact-generating stages.
|
||||||
|
- `narratio run` and `narratio session plan` accept inclusive `--from` and
|
||||||
|
`--through` bounds. Excluded transcript stages are not executed or
|
||||||
|
invalidated by a bounded artifact-regeneration run.
|
||||||
|
- `narratio regenerate-artifacts SESSION` is an exact convenience alias for a
|
||||||
|
forced run from `extract` through `analyze`, including focused
|
||||||
|
`--artifacts` selections.
|
||||||
|
- Configured Scriptorium artifacts now have independent,
|
||||||
|
manifest-authoritative freshness. Narratio reuses validated current work,
|
||||||
|
rebuilds stale prerequisites in dependency order, and persists successful,
|
||||||
|
failed, and newly stale artifact state when an analysis invocation only
|
||||||
|
partially succeeds.
|
||||||
|
- Publish consumes only configured artifacts backed by current manifest
|
||||||
|
evidence; incidental or tampered files are not promoted as current output.
|
||||||
|
|
||||||
|
## Reliability And Administration
|
||||||
|
|
||||||
|
- Bounded prerequisites are checked again under the session lock before any
|
||||||
|
run mutation, closing a concurrent-run race.
|
||||||
|
- Analysis fingerprints are stable across executable and configuration path
|
||||||
|
changes and continue to cover only Narratio-observable semantic inputs.
|
||||||
|
- Runner composition now carries one validated execution plan from command
|
||||||
|
parsing through prerequisite validation, adapter composition, manifest
|
||||||
|
recording, and stage execution.
|
||||||
|
- `narratio version` reports the exact tag embedded in official release
|
||||||
|
binaries; ordinary source builds report `dev`.
|
||||||
|
|
||||||
|
## Upgrade Notes
|
||||||
|
|
||||||
|
- Existing unbounded commands and direct `run-stage`, `analyze`, and `publish`
|
||||||
|
workflows retain their meanings.
|
||||||
|
- Manifests written before artifact-level analysis state remain readable.
|
||||||
|
Legacy aggregate analysis success is not sufficient freshness evidence, so
|
||||||
|
the first analysis evaluation after upgrading may regenerate configured
|
||||||
|
artifacts once.
|
||||||
|
- Narratio cannot observe executable contents or configuration, prompt,
|
||||||
|
profile, module, and other files loaded privately by Scriptorium. Explicitly
|
||||||
|
force affected artifacts after changing those private inputs.
|
||||||
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.
|
||||||
70
docs/releases/v2.1.0.md
Normal file
70
docs/releases/v2.1.0.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Narratio v2.1.0
|
||||||
|
|
||||||
|
This release adds explicit Notarius credential forwarding and cache reuse,
|
||||||
|
with predictable forced regeneration and clearer artifact-development guidance.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Narratio can pass one typed API-key reference to Notarius and opt into reuse of
|
||||||
|
its accepted execution checkpoints. Stable, verified reference snapshots allow
|
||||||
|
unchanged references to retain their cache identity across Narratio invocations.
|
||||||
|
Configuration loading now uses ConfigKit v0.3.0.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
The new `notarius.llm_api_key`, `notarius.resume`, and `notarius.chunk_cache`
|
||||||
|
fields are optional. Existing v2 configuration needs no schema migration.
|
||||||
|
Configured credential references must resolve during configuration loading,
|
||||||
|
even when extraction will not execute; omitted references add no requirement.
|
||||||
|
|
||||||
|
Forced extraction now omits Notarius checkpoint loading and requests chunk-plan
|
||||||
|
refresh. This includes `regenerate-artifacts`, which remains an alias for a
|
||||||
|
forced run from extract through analyze. `analyze` continues to force analysis
|
||||||
|
only, preserving extraction results and transcripts.
|
||||||
|
|
||||||
|
Notarius v0.7.0 is the tested cache integration baseline, not an unconditional
|
||||||
|
minimum-version gate. The executable must support the flags selected for the
|
||||||
|
invocation, including `--chunk_cache refresh` for forced extraction. Reference
|
||||||
|
bindings retain the contract introduced in Notarius v0.6.0. Receipt and artifact
|
||||||
|
schemas are unchanged; reused producers may report zero production attempts.
|
||||||
|
|
||||||
|
Supported release platforms remain Linux amd64/arm64 and best-effort macOS
|
||||||
|
arm64. macOS validation is cross-compilation, not native runtime testing.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No special migration is required for existing v2 configuration. To use the new
|
||||||
|
features:
|
||||||
|
|
||||||
|
- Declare `notarius.llm_api_key` with the environment-variable name expected by
|
||||||
|
the deployed Notarius profile, supplying its value through the existing
|
||||||
|
environment or secrets-directory mechanism.
|
||||||
|
- Enable checkpoint recording in Notarius configuration before setting
|
||||||
|
Narratio's `notarius.resume: true`. Prefer explicit persistent Notarius cache
|
||||||
|
roots for service deployments. An optional `notarius.chunk_cache` overrides
|
||||||
|
its ordinary chunk-plan policy.
|
||||||
|
- Expect an initial checkpoint miss when moving from old run-local reference
|
||||||
|
paths to stable session snapshots. No migration or deletion of old caches is
|
||||||
|
required. Force refreshes the current input's plan, not the whole cache.
|
||||||
|
- Use `narratio analyze SESSION` for analysis-only iteration, and
|
||||||
|
`narratio regenerate-artifacts SESSION` when extraction should also be rebuilt.
|
||||||
|
|
||||||
|
See [Configuration](../config.md), the
|
||||||
|
[Notarius integration contract](../integrations/notarius.md), and
|
||||||
|
[Artifact Development Workflows](../operations.md#artifact-development-workflows)
|
||||||
|
for current details.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Upgraded ConfigKit from v0.1.2 to v0.3.0 and isolated pipeline-discovery tests
|
||||||
|
from machine configuration.
|
||||||
|
- Added optional typed Notarius credentials, exact-name environment delivery,
|
||||||
|
safe configuration projections, and captured-stream redaction for custom names.
|
||||||
|
- Added Notarius resume and chunk-cache controls with force taking precedence.
|
||||||
|
- Added verified, content-addressed session reference snapshots that survive
|
||||||
|
ordinary run cleanup without becoming published artifacts.
|
||||||
|
- Preserved relative-workspace support for snapshot delivery and confinement.
|
||||||
|
- Kept credentials and cache-policy controls outside extract's semantic resume
|
||||||
|
identity, so operational changes alone do not invalidate completed results.
|
||||||
|
- Added an opt-in real Notarius integration test using a local provider to
|
||||||
|
verify reuse, partial-failure recovery, and forced refresh without paid calls.
|
||||||
33
docs/releases/v2.1.1.md
Normal file
33
docs/releases/v2.1.1.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Narratio v2.1.1
|
||||||
|
|
||||||
|
This release fixes configuration loading for publish rules that explicitly set
|
||||||
|
requiredness while relying on a derived destination.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Publish rules with a derivable source and omitted `dest` now load correctly
|
||||||
|
whether `required` is omitted, explicitly true, or explicitly false. Previously,
|
||||||
|
explicit requiredness could trigger ConfigKit's `resolve.unrecorded_change`
|
||||||
|
diagnostic instead of accepting valid configuration.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible bookkeeping correction. Publish schemas,
|
||||||
|
destination derivation, requiredness defaults, and YAML syntax support are
|
||||||
|
unchanged. Explicit destinations and explicit false values remain intact.
|
||||||
|
No ConfigKit upgrade or publication/storage behavior change is involved.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No special upgrade action or configuration migration is required. Operators
|
||||||
|
need not add a derivable `dest`, remove explicit `required` values, or change
|
||||||
|
flow-style YAML to block-style YAML. See the
|
||||||
|
[configuration reference](../config.md#publish-configuration-summary).
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Recorded derived publish destinations at ConfigKit's atomic sequence boundary
|
||||||
|
while retaining field-level resolution records.
|
||||||
|
- Added real load/validation and safe effective-configuration regression coverage
|
||||||
|
for built-in and configured-artifact sources, explicit requiredness, and an
|
||||||
|
explicit destination that must remain unchanged.
|
||||||
26
docs/releases/v2.1.2.md
Normal file
26
docs/releases/v2.1.2.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Narratio v2.1.2
|
||||||
|
|
||||||
|
This release allows secrets directories to contain hidden metadata without
|
||||||
|
preventing credential loading.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
ConfigKit v0.3.1 ignores dot-prefixed entries such as `.git`, `.gitignore`, and
|
||||||
|
`.DS_Store` during secrets bootstrap. These entries no longer block otherwise
|
||||||
|
valid credentials from loading and are omitted from bootstrap reports.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible dependency bug fix. Configuration schemas,
|
||||||
|
existing-environment precedence, and validation of non-hidden entries are
|
||||||
|
unchanged. Hidden entries still count toward the directory entry limit.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No special upgrade action or configuration migration is required. See the
|
||||||
|
[credential configuration reference](../config.md) for secrets-directory usage.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Updated ConfigKit from v0.3.0 to v0.3.1.
|
||||||
|
- Clarified hidden-entry handling in the secrets-directory documentation.
|
||||||
171
docs/roadmap/fix-publish-output-destination-resolution.md
Normal file
171
docs/roadmap/fix-publish-output-destination-resolution.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# Bugfix Request: Preserve Publish Destination Derivation with Explicit Rules
|
||||||
|
|
||||||
|
Status: Implemented; release target v2.1.1
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Narratio should successfully derive a publish destination when a valid
|
||||||
|
`publish.outputs` rule omits `dest`, regardless of whether that rule explicitly
|
||||||
|
sets `required`. Narratio v2.1.0 currently rejects this valid configuration in
|
||||||
|
some cases with a ConfigKit `resolve.unrecorded_change` diagnostic.
|
||||||
|
|
||||||
|
This is a configuration-resolution bookkeeping defect, not a YAML formatting
|
||||||
|
restriction. The fix should preserve the existing public configuration
|
||||||
|
contract: operators may omit a derivable destination and may independently set
|
||||||
|
`required` to either `true` or `false`.
|
||||||
|
|
||||||
|
## Observed Behavior
|
||||||
|
|
||||||
|
A pipeline containing a rule equivalent to the following fails during native
|
||||||
|
configuration loading:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
publish:
|
||||||
|
outputs: [{"source": "narratio.transcript.final", "required": true}]
|
||||||
|
```
|
||||||
|
|
||||||
|
Narratio reports:
|
||||||
|
|
||||||
|
```text
|
||||||
|
error [resolve.unrecorded_change] /publish/outputs: application resolution changed a value without a covering record
|
||||||
|
```
|
||||||
|
|
||||||
|
The same failure is possible with ordinary block-style YAML when `required` is
|
||||||
|
explicit and `dest` is omitted. Flow-style YAML is valid input and is not the
|
||||||
|
cause of the defect.
|
||||||
|
|
||||||
|
The existing destination-derivation tests do not expose this case because
|
||||||
|
their rules also omit `required`. Narratio then supplies the default value for
|
||||||
|
`required` and records a change covering the complete `publish.outputs`
|
||||||
|
sequence. That broader record happens to cover the destination mutation as
|
||||||
|
well, masking the missing resolution record that occurs when `required` is
|
||||||
|
already explicit.
|
||||||
|
|
||||||
|
## Expected Contract
|
||||||
|
|
||||||
|
The current configuration contract remains unchanged:
|
||||||
|
|
||||||
|
- `publish.outputs[].source` is required;
|
||||||
|
- `publish.outputs[].dest` may be omitted when Narratio can derive it from the
|
||||||
|
selected built-in or configured artifact source;
|
||||||
|
- `publish.outputs[].required` defaults to `true`, but an operator may set it
|
||||||
|
explicitly to either `true` or `false`; and
|
||||||
|
- equivalent valid YAML representations have equivalent configuration
|
||||||
|
semantics.
|
||||||
|
|
||||||
|
For example, both rules below should load and validate, retain their explicit
|
||||||
|
requiredness, and receive the canonical derived destination:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
publish:
|
||||||
|
outputs:
|
||||||
|
- source: narratio.transcript.final
|
||||||
|
required: true
|
||||||
|
- source: narratio.transcript.final_markdown
|
||||||
|
required: false
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
ConfigKit intentionally treats sequences as atomic when comparing values
|
||||||
|
before and after application resolution. Changing a field within one
|
||||||
|
`publish.outputs` entry is therefore observed as a change to the
|
||||||
|
`/publish/outputs` sequence.
|
||||||
|
|
||||||
|
Narratio currently derives each missing destination by recording a
|
||||||
|
normalization at the individual `publish.outputs[index].dest` path. That child
|
||||||
|
record does not cover the atomic parent-sequence change. A separate
|
||||||
|
container-level record is currently created only when Narratio also defaults
|
||||||
|
an omitted `required` value, so configurations with explicit `required` values
|
||||||
|
lose that incidental coverage.
|
||||||
|
|
||||||
|
## Recommended Fix
|
||||||
|
|
||||||
|
Make Narratio's publish-output resolution bookkeeping conform to ConfigKit's
|
||||||
|
atomic-sequence contract. Whenever resolution derives one or more destinations
|
||||||
|
inside `publish.outputs`, the recorded resolution changes must cover the
|
||||||
|
`/publish/outputs` container. Destination derivation must not depend on an
|
||||||
|
unrelated `required` default being applied in the same list.
|
||||||
|
|
||||||
|
The implementation may retain more specific provenance where useful, but it
|
||||||
|
must provide valid container-level coverage for the sequence mutation. Do not
|
||||||
|
change ConfigKit's sequence comparison semantics merely to accommodate this
|
||||||
|
call site.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Correct destination derivation for publish rules that explicitly set
|
||||||
|
`required` and omit a derivable `dest`.
|
||||||
|
- Preserve explicit `required: true` and `required: false` values.
|
||||||
|
- Preserve canonical destination derivation for built-in transcript sources
|
||||||
|
and configured artifact sources.
|
||||||
|
- Add focused regression coverage through Narratio's real configuration load
|
||||||
|
and validation boundary.
|
||||||
|
- Prepare a backward-compatible patch release after the fix passes the normal
|
||||||
|
repository validation.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Changing the `publish.outputs` schema, defaults, source vocabulary, or
|
||||||
|
destination derivation rules.
|
||||||
|
- Requiring operators or configuration generators to supply `dest` when it is
|
||||||
|
derivable.
|
||||||
|
- Requiring operators to omit explicit `required: true` values.
|
||||||
|
- Treating flow-style YAML or JSON-compatible YAML values as unsupported.
|
||||||
|
- Changing ConfigKit's documented atomic treatment of sequences.
|
||||||
|
- Adding storage, publication, or network integration behavior.
|
||||||
|
|
||||||
|
## Regression Coverage
|
||||||
|
|
||||||
|
Add one focused package-level behavioral test that loads and validates a
|
||||||
|
pipeline with omitted destinations and explicit requiredness. A representative
|
||||||
|
list should include both `required: true` and `required: false`, then assert
|
||||||
|
that:
|
||||||
|
|
||||||
|
- loading and validation succeed;
|
||||||
|
- each destination is derived from its source; and
|
||||||
|
- each explicit `required` value is preserved.
|
||||||
|
|
||||||
|
Using the compact flow-style representation that exposed the integration
|
||||||
|
failure is reasonable, while the existing block-style cases continue to cover
|
||||||
|
omitted `required`. The test should exercise the public configuration behavior,
|
||||||
|
not ConfigKit call choreography or Narratio's private recording mechanism.
|
||||||
|
|
||||||
|
No network service, object store, credential, or test double is required.
|
||||||
|
|
||||||
|
## Target End State
|
||||||
|
|
||||||
|
All valid publish rules with derivable sources behave consistently across
|
||||||
|
these cases:
|
||||||
|
|
||||||
|
| `dest` | `required` | Result |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| omitted | omitted | destination derived; required defaults to `true` |
|
||||||
|
| omitted | `true` | destination derived; explicit `true` retained |
|
||||||
|
| omitted | `false` | destination derived; explicit `false` retained |
|
||||||
|
| explicit | either value | explicit destination and requiredness retained |
|
||||||
|
|
||||||
|
`narratio config validate`, `narratio config show`, and ordinary pipeline
|
||||||
|
loading accept the corrected cases without an unrecorded-change diagnostic.
|
||||||
|
No operator configuration migration or workaround is required.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
This is a backward-compatible correction to documented configuration behavior
|
||||||
|
and should be released as Narratio `v2.1.1`. The release note should identify
|
||||||
|
the corrected explicit-required/derived-destination interaction and state that
|
||||||
|
no special upgrade action is required.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- The focused regression test fails against v2.1.0 behavior and passes after
|
||||||
|
the fix.
|
||||||
|
- Publish rules with explicit `required: true` or `required: false` and omitted
|
||||||
|
derivable destinations load and validate successfully.
|
||||||
|
- Derived destinations and explicit requiredness appear correctly in the safe
|
||||||
|
effective configuration.
|
||||||
|
- Existing destination derivation, publish validation, imports, profiles, and
|
||||||
|
configuration inspection tests remain green.
|
||||||
|
- The complete repository validation and release-candidate checks pass.
|
||||||
|
- The correction is published as the immutable `v2.1.1` tag with the required
|
||||||
|
release note.
|
||||||
@@ -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:
|
||||||
@@ -117,6 +178,75 @@ Safe fix:
|
|||||||
|
|
||||||
Relevant reference: [CLI artifact selection](./cli.md).
|
Relevant reference: [CLI artifact selection](./cli.md).
|
||||||
|
|
||||||
|
## Bounded run prerequisite is unusable
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- `run` or `session plan` reports that a prerequisite stage is absent or has a
|
||||||
|
pending, running, failed, stale, or interrupted status before the selected
|
||||||
|
start.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- `--from` excludes upstream work that has not reached the terminal
|
||||||
|
`succeeded` or `skipped` state in the session manifest.
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session status 2026-04-04
|
||||||
|
narratio session plan 2026-04-04 --from render --through analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- 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
|
||||||
|
create a run-local directory or modify the manifest. Narratio does not
|
||||||
|
resume-validate excluded prefix stages, and stages after `--through` are not
|
||||||
|
prerequisites.
|
||||||
|
|
||||||
|
If prerequisite statuses are terminal but a selected stage reports a missing,
|
||||||
|
unsafe, or checksum-inconsistent artifact, repair the artifact at the stage
|
||||||
|
that owns it; do not edit the manifest to bypass the selected stage's concrete
|
||||||
|
input validation.
|
||||||
|
|
||||||
|
Relevant reference: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior).
|
||||||
|
|
||||||
|
## Notarius credential reference unavailable
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- configuration loading reports `secret.unset`, `secret.empty`, or an invalid
|
||||||
|
name for `pipeline.notarius.llm_api_key`, including during inspection or a
|
||||||
|
run that would not execute extraction.
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- the named environment variable is absent or empty;
|
||||||
|
- the selected secrets directory lacks a file with that exact name;
|
||||||
|
- a process environment variable exists but is empty, so bootstrap correctly
|
||||||
|
leaves it in place; or
|
||||||
|
- the Narratio reference name does not match `api_key_env` in the selected
|
||||||
|
Notarius/PromptKit profile.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- set the exact nonempty environment variable or provide an equivalently named
|
||||||
|
file through the conventional or explicit secrets directory;
|
||||||
|
- remove or set the field to `null` when that deployment needs no credential;
|
||||||
|
and
|
||||||
|
- use `config show`, `config sources`, and `config diff` to inspect the public
|
||||||
|
reference name and ownership without exposing its value.
|
||||||
|
|
||||||
|
Narratio resolves selected credential references during ordinary pipeline
|
||||||
|
loading, before stage selection or resume. Credential changes alone do not
|
||||||
|
invalidate a completed extraction, but the current reference must still resolve
|
||||||
|
for the command to proceed.
|
||||||
|
|
||||||
|
Relevant references: [Secrets](./config.md#secrets-handling) and
|
||||||
|
[Notarius credential](./config.md#notarius-credential).
|
||||||
|
|
||||||
## Notarius executable missing
|
## Notarius executable missing
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
@@ -158,28 +288,118 @@ is expected audit state, not a signal to relink the old bundle manually.
|
|||||||
|
|
||||||
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||||
|
|
||||||
## Atomic Notarius promotion unsupported
|
## Prepared Notarius reference missing or inconsistent
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
|
|
||||||
- extraction fails with `atomic no-replace directory promotion is unsupported`
|
- extraction or resume validation reports that a configured reference source is
|
||||||
before a durable bundle or temporary promotion tree is created.
|
unavailable, unsafe, empty, or checksum-inconsistent and recommends
|
||||||
|
`prepare --force`.
|
||||||
|
|
||||||
Likely cause:
|
Likely causes:
|
||||||
|
|
||||||
- Narratio is running on an operating system other than Linux, macOS, or
|
- `prepare` has not run since the campaign/session stable input changed;
|
||||||
Windows, where the required atomic no-replace directory primitive has not
|
- the configured source file is missing;
|
||||||
been implemented and verified.
|
- a prepared `inputs/` file or its manifest record was modified independently;
|
||||||
|
- a spell-catalog binding exists without an effective `spell_catalog_file`.
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session status 2026-04-04
|
||||||
|
narratio session validate 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
Safe fix:
|
Safe fix:
|
||||||
|
|
||||||
- run extraction on Linux, macOS, or Windows. Do not replace the atomic commit
|
- correct the campaign/session input path, then refresh canonical prepared
|
||||||
with a manual copy or move; the session manifest must never observe a partial
|
evidence before extraction:
|
||||||
or overwritten bundle.
|
|
||||||
|
|
||||||
This is an extraction-specific platform boundary, not a support statement for
|
```bash
|
||||||
unrelated Narratio workflows. See
|
narratio run-stage prepare 2026-04-04 --force
|
||||||
[Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
```
|
||||||
|
|
||||||
|
Do not point Notarius directly at the original source path or edit the manifest
|
||||||
|
checksum. Relevant references: [Notarius reference configuration](./config.md#notarius-reference-bindings)
|
||||||
|
and [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||||
|
|
||||||
|
## Notarius reference snapshot cache conflict
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- extraction reports that a path beneath
|
||||||
|
`cache/notarius/references/<sha256>/` is a symlink, directory, or another
|
||||||
|
non-regular or unsafe entry.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- stop commands for the affected session and inspect the exact reported path;
|
||||||
|
- investigate unexpected links or special files as possible tampering; and
|
||||||
|
- remove only the reported conflicting leaf or digest directory after
|
||||||
|
confirming it belongs to the reconstructible reference cache.
|
||||||
|
|
||||||
|
A missing snapshot or corrupt regular snapshot is reconstructed automatically
|
||||||
|
from currently verified prepared input. Do not recursively delete the session
|
||||||
|
root, edit the manifest checksum, or remove cache entries while extraction is
|
||||||
|
active.
|
||||||
|
|
||||||
|
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||||
|
|
||||||
|
## Notarius resume executes work instead of reusing it
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- Narratio executes `extract`, and Notarius makes provider calls despite
|
||||||
|
`pipeline.notarius.resume: true`.
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- checkpoint recording is disabled in the Notarius configuration;
|
||||||
|
- Notarius is using a nonpersistent or different checkpoint root;
|
||||||
|
- transcript/reference bytes, reference paths, pipeline/profile content,
|
||||||
|
local-backend endpoint, selected lanes, or another checkpoint identity input
|
||||||
|
changed; or
|
||||||
|
- Narratio extraction was forced, which deliberately omits `--resume`.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- validate the deployed Notarius configuration and configure explicit
|
||||||
|
persistent chunk-plan and checkpoint roots;
|
||||||
|
- inspect Notarius checkpoint decision reason codes for a legitimate cold miss;
|
||||||
|
and
|
||||||
|
- retry without force only when reuse is intended.
|
||||||
|
|
||||||
|
Remember that Narratio skipping a completed `extract` stage is separate from
|
||||||
|
Notarius reusing work inside an extraction that does execute. Narratio does not
|
||||||
|
read the Notarius configuration to infer checkpoint enablement.
|
||||||
|
|
||||||
|
Relevant references: [Notarius cache controls](./config.md#notarius-cache-controls)
|
||||||
|
and [Notarius integration](./integrations/notarius.md).
|
||||||
|
|
||||||
|
## Notarius reference selector or generated-handoff collision
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- Notarius exits nonzero with an undeclared reference-slot, incompatible media,
|
||||||
|
or external/generated reference collision error.
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- a selector does not identify a slot declared by the selected Notarius target;
|
||||||
|
- a prepared file does not satisfy that slot's Notarius media contract; or
|
||||||
|
- a CLI binding attempts to replace a same-run generated D&D handoff.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- compare external bindings with the selected Notarius pipeline's canonical
|
||||||
|
consumer documentation;
|
||||||
|
- keep only campaign-owned external slots on the CLI; and
|
||||||
|
- leave registry, scene, combat, and occurrence handoffs to Notarius pipeline
|
||||||
|
composition.
|
||||||
|
|
||||||
|
Narratio validates selector structure and prepared evidence, while Notarius
|
||||||
|
owns slot declarations, media compatibility, and generated-handoff conflicts.
|
||||||
|
Relevant reference: [Notarius integration](./integrations/notarius.md).
|
||||||
|
|
||||||
## Notarius receipt or index incompatible
|
## Notarius receipt or index incompatible
|
||||||
|
|
||||||
@@ -198,7 +418,7 @@ Safe fix:
|
|||||||
|
|
||||||
- compare installed Notarius output with the canonical Notarius contracts,
|
- compare installed Notarius output with the canonical Notarius contracts,
|
||||||
including receipt `index_file: index.json` and index management names
|
including receipt `index_file: index.json` and index management names
|
||||||
`manifest.json`, `rejected.json`, and `warnings.json`; align
|
`manifest.json`, `rejected.json`, `warnings.json`, and `diagnostics.json`; align
|
||||||
`pipeline.notarius` constraints and rerun. Do not bypass confinement or schema
|
`pipeline.notarius` constraints and rerun. Do not bypass confinement or schema
|
||||||
checks.
|
checks.
|
||||||
|
|
||||||
@@ -228,16 +448,23 @@ Symptom:
|
|||||||
|
|
||||||
Likely causes:
|
Likely causes:
|
||||||
|
|
||||||
- the executable/config path, pipeline ID, timeout, working directory, or
|
- the pipeline ID or configured output contracts changed;
|
||||||
configured output contracts changed;
|
- a configured prepared reference selector, source, path, checksum, or byte
|
||||||
|
size changed;
|
||||||
|
- the direct trimmed transcript identity or bytes changed; or
|
||||||
- the durable bundle, index, lane set, provenance, regular-file status, or
|
- the durable bundle, index, lane set, provenance, regular-file status, or
|
||||||
checksum no longer validates.
|
checksum no longer validates.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Executable, config path, timeout, working directory, and Notarius credential
|
||||||
|
changes are operational and do not by themselves invalidate reusable extraction
|
||||||
|
evidence. A newly selected credential must nevertheless resolve during command
|
||||||
|
configuration loading.
|
||||||
|
|
||||||
## Notarius transitive configuration changed
|
## Notarius transitive configuration changed
|
||||||
|
|
||||||
@@ -252,12 +479,158 @@ Safe fix:
|
|||||||
narratio run-stage extract 2026-04-04 --force
|
narratio run-stage extract 2026-04-04 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Narratio fingerprints its invocation contract, not the contents of transitive
|
Narratio fingerprints its invocation contract and prepared Narratio reference
|
||||||
Notarius inputs. Always force extraction after changing them; downstream
|
identities, not the contents of other transitive Notarius inputs. Always force
|
||||||
|
extraction after changing those external inputs; downstream
|
||||||
successful stages are then marked stale normally.
|
successful stages are then marked stale normally.
|
||||||
|
|
||||||
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||||
|
|
||||||
|
## Analysis artifact evidence is not current
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- ordinary continuation or `session plan` schedules one or more configured
|
||||||
|
artifacts even though a canonical output file exists; or
|
||||||
|
- publish reports a configured artifact source unavailable.
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- the per-artifact record is stale, missing, failed, unselected, or malformed;
|
||||||
|
- a configured prompt/profile, dependency, input identity, output path, or
|
||||||
|
effective variable changed; or
|
||||||
|
- the recorded output is missing, unsafe, empty, or has a size/checksum that no
|
||||||
|
longer matches its manifest evidence.
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session status 2026-04-04
|
||||||
|
narratio session artifacts 2026-04-04
|
||||||
|
narratio session plan 2026-04-04 --from analyze --through analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- investigate unexpected path or checksum changes as possible tampering;
|
||||||
|
- otherwise let the selected analyze work rerun, or explicitly regenerate only
|
||||||
|
the affected targets; and
|
||||||
|
- never edit the fingerprint/checksum in the manifest or copy an old file into
|
||||||
|
the canonical path as a substitute for current evidence.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio analyze 2026-04-04 --artifacts session_recap
|
||||||
|
```
|
||||||
|
|
||||||
|
Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection)
|
||||||
|
and [Artifact Internals](./internal/artifacts.md#resolution-rules).
|
||||||
|
|
||||||
|
## Analysis manifest checkpoint failed
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- analyze reports that it could not save the session manifest after completing
|
||||||
|
an artifact.
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- the workspace is unwritable or out of space;
|
||||||
|
- the manifest path or its parent directory has unsafe permissions or type; or
|
||||||
|
- a transient local filesystem error interrupted atomic manifest replacement.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- correct the reported filesystem or permission problem;
|
||||||
|
- inspect `session artifacts` to identify the last durable current records; and
|
||||||
|
- rerun analyze normally. Narratio reuses earlier successful checkpoints and
|
||||||
|
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
|
||||||
|
narratio run 2026-04-04
|
||||||
|
narratio publish 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
|
After current records exist, inspect them and publish explicitly. Do not copy
|
||||||
|
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)
|
||||||
|
and [Manifest Internals](./internal/manifest.md#analyze-owned-artifact-state).
|
||||||
|
|
||||||
|
## Scriptorium private input changed without a rerun
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- a prompt, profile, imported configuration file, executable, or other input
|
||||||
|
loaded privately by Scriptorium changed, but Narratio still considers an
|
||||||
|
artifact current.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- analysis fingerprints cover Narratio-observable semantic identities, not
|
||||||
|
executable contents or arbitrary files and transitive configuration that
|
||||||
|
Scriptorium loads behind its configured paths and identifiers.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- explicitly force the affected target after changing an unobserved private
|
||||||
|
input. Force applies to explicit targets; current prerequisites remain
|
||||||
|
reusable unless selected themselves.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio analyze 2026-04-04 --artifacts session_recap
|
||||||
|
```
|
||||||
|
|
||||||
|
Relevant reference: [Analyze Internals](./internal/stage-analyze.md#invariants).
|
||||||
|
|
||||||
## Previous-session artifact input missing
|
## Previous-session artifact input missing
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
@@ -267,7 +640,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:
|
||||||
|
|
||||||
@@ -325,6 +700,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:
|
||||||
|
|
||||||
@@ -335,7 +712,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).
|
||||||
|
|
||||||
@@ -428,7 +809,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.
|
||||||
|
|
||||||
@@ -436,12 +818,12 @@ Diagnostics:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls -la /path/to/secrets_dir
|
ls -la /path/to/secrets_dir
|
||||||
env | sed 's/=.*//' | grep -E 'OBJECT_STORAGE|AWS|AUDITA|SCRIPTORIUM'
|
env | sed 's/=.*//' | grep -E 'OBJECT_STORAGE|AWS|AUDITA|NOTARIUS|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,12 +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.
|
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
|
||||||
|
follows the Notarius v0.7 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`, `AUDITA_LLM_API_KEY`, and
|
||||||
|
`NOTARIUS_LLM_API_KEY` to nonempty synthetic or real values before loading the
|
||||||
|
examples that reference them. 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,5 +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
|
||||||
|
|||||||
@@ -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
|
|
||||||
18
examples/campaigns/sample-campaign/spell_catalog.json
Normal file
18
examples/campaigns/sample-campaign/spell_catalog.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
|
||||||
|
"catalogs": [
|
||||||
|
{
|
||||||
|
"id": "narratio.sample-campaign",
|
||||||
|
"ruleset": "dnd-5e-2014",
|
||||||
|
"source": {
|
||||||
|
"title": "Narratio sample campaign spell names"
|
||||||
|
},
|
||||||
|
"spells": [
|
||||||
|
{
|
||||||
|
"name": "Aegis of Emberfall",
|
||||||
|
"aliases": ["Emberfall Aegis"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -14,6 +14,11 @@ notarius:
|
|||||||
config_path: /usr/local/etc/notarius/config.yml
|
config_path: /usr/local/etc/notarius/config.yml
|
||||||
pipeline_id: dnd-session
|
pipeline_id: dnd-session
|
||||||
timeout: 3h
|
timeout: 3h
|
||||||
|
references:
|
||||||
|
glossary: narratio.input.glossary
|
||||||
|
party: narratio.input.party
|
||||||
|
players: narratio.input.players
|
||||||
|
spell_catalog: narratio.input.spell_catalog
|
||||||
outputs:
|
outputs:
|
||||||
npc_registry:
|
npc_registry:
|
||||||
lane_id: npc-registry
|
lane_id: npc-registry
|
||||||
@@ -52,4 +57,3 @@ scriptorium:
|
|||||||
scenes:
|
scenes:
|
||||||
source: narratio.extraction.scene_descriptions
|
source: narratio.extraction.scene_descriptions
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -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: ""
|
||||||
@@ -129,13 +128,29 @@ trim:
|
|||||||
report: false
|
report: false
|
||||||
|
|
||||||
notarius:
|
notarius:
|
||||||
# Optional structured extraction between trim and render.
|
# Optional structured extraction after render and before analyze.
|
||||||
enabled: true
|
enabled: true
|
||||||
binary: notarius
|
binary: notarius
|
||||||
|
# Optional environment-variable reference. Match this name to api_key_env in
|
||||||
|
# the selected Notarius/PromptKit profile; never put the credential here.
|
||||||
|
llm_api_key: NOTARIUS_LLM_API_KEY
|
||||||
config_path: /usr/local/etc/notarius/config.yml
|
config_path: /usr/local/etc/notarius/config.yml
|
||||||
pipeline_id: dnd-session
|
pipeline_id: dnd-session
|
||||||
timeout: 3h
|
timeout: 3h
|
||||||
working_directory: /usr/local/etc/notarius
|
working_directory: /usr/local/etc/notarius
|
||||||
|
# Reuse compatible accepted Notarius checkpoints during executed extraction.
|
||||||
|
# The Notarius config must enable recording and provide persistent cache roots.
|
||||||
|
resume: true
|
||||||
|
# auto uses or creates a source-addressed chunk plan. Forced Narratio
|
||||||
|
# extraction overrides this with refresh and disables checkpoint loading.
|
||||||
|
chunk_cache: auto
|
||||||
|
# External campaign references use prepared Narratio source IDs. Omit an
|
||||||
|
# optional binding when the selected Notarius pipeline does not need it.
|
||||||
|
references:
|
||||||
|
glossary: narratio.input.glossary
|
||||||
|
party: narratio.input.party
|
||||||
|
players: narratio.input.players
|
||||||
|
spell_catalog: narratio.input.spell_catalog
|
||||||
# Each key creates source narratio.extraction.<key>. These constraints match
|
# Each key creates source narratio.extraction.<key>. These constraints match
|
||||||
# the current Notarius D&D lane contracts; update them with Notarius.
|
# the current Notarius D&D lane contracts; update them with Notarius.
|
||||||
outputs:
|
outputs:
|
||||||
|
|||||||
@@ -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.3.1
|
||||||
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.3.1 h1:etfTI/dxO3NnS18La0V6IeUh/Iv+3PjVfEyaN8hTuOM=
|
||||||
|
gitea.maximumdirect.net/eric/ConfigKit v0.3.1/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")
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return RunResult{}, err
|
return RunResult{}, err
|
||||||
}
|
}
|
||||||
f.Requests = append(f.Requests, req)
|
copyRequest := req
|
||||||
|
copyRequest.References = append([]ReferenceBinding(nil), req.References...)
|
||||||
|
f.Requests = append(f.Requests, copyRequest)
|
||||||
if f.Err != nil {
|
if f.Err != nil {
|
||||||
return RunResult{}, f.Err
|
return RunResult{}, f.Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,29 @@ package notarius
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ReceiptSchemaVersion = "notarius.run-result.v1"
|
const ReceiptSchemaVersion = "notarius.run-result.v2"
|
||||||
|
|
||||||
// Runner is the adapter boundary for a complete Notarius pipeline invocation.
|
// Runner is the adapter boundary for a complete Notarius pipeline invocation.
|
||||||
type Runner interface {
|
type Runner interface {
|
||||||
Run(ctx context.Context, req RunRequest) (RunResult, error)
|
Run(ctx context.Context, req RunRequest) (RunResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReferenceBinding maps one normalized Notarius selector to an absolute
|
||||||
|
// external reference path.
|
||||||
|
type ReferenceBinding struct {
|
||||||
|
Selector string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
|
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
|
||||||
type RunRequest struct {
|
type RunRequest struct {
|
||||||
Binary string
|
Binary string
|
||||||
|
// LLMAPIKey is an optional already-resolved credential reference.
|
||||||
|
LLMAPIKey *configkit.Secret
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
PipelineID string
|
PipelineID string
|
||||||
InputPath string
|
InputPath string
|
||||||
@@ -24,20 +35,43 @@ type RunRequest struct {
|
|||||||
ReceiptPath string
|
ReceiptPath string
|
||||||
LogPath string
|
LogPath string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
|
Resume bool
|
||||||
|
ChunkCache string
|
||||||
|
References []ReferenceBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receipt is the transport-neutral successful run receipt.
|
// Receipt is the transport-neutral successful run receipt.
|
||||||
type Receipt struct {
|
type Receipt struct {
|
||||||
SchemaVersion string
|
SchemaVersion string
|
||||||
RunID string
|
RunID string
|
||||||
PipelineID string
|
PipelineID string
|
||||||
OutputDirectory string
|
OutputDirectory string
|
||||||
IndexFile string
|
IndexFile string
|
||||||
NormalizedOutputCount int
|
NormalizedOutputCount int
|
||||||
RejectedOutputCount int
|
RejectedOutputCount int
|
||||||
WarningCount int
|
WarningGroupCount int
|
||||||
ValidationStatus string
|
WarningOccurrenceCount int
|
||||||
DebugDirectory string
|
DiagnosticGroupCount int
|
||||||
|
DiagnosticOccurrenceCount int
|
||||||
|
DiagnosticsTruncated bool
|
||||||
|
ValidationStatus string
|
||||||
|
ValidationSummaries []ValidationSummary
|
||||||
|
DebugDirectory string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationSummary retains the bounded outcome of one Notarius producer result.
|
||||||
|
type ValidationSummary struct {
|
||||||
|
Stage string
|
||||||
|
StepID string
|
||||||
|
LaneID string
|
||||||
|
ModuleKey string
|
||||||
|
ChunkID string
|
||||||
|
Status string
|
||||||
|
RejectingValidators []string
|
||||||
|
ReasonCodes []string
|
||||||
|
IncompleteValidators []string
|
||||||
|
ProducerAttemptCount int
|
||||||
|
TerminalAction string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LaneDescriptor identifies one normalized lane payload discovered through the index.
|
// LaneDescriptor identifies one normalized lane payload discovered through the index.
|
||||||
@@ -72,6 +106,8 @@ type Index struct {
|
|||||||
RejectedPath string
|
RejectedPath string
|
||||||
WarningsFile string
|
WarningsFile string
|
||||||
WarningsPath string
|
WarningsPath string
|
||||||
|
DiagnosticsFile string
|
||||||
|
DiagnosticsPath string
|
||||||
Lanes []LaneDescriptor
|
Lanes []LaneDescriptor
|
||||||
ChunkMap *PipelineDescriptor
|
ChunkMap *PipelineDescriptor
|
||||||
EvidenceContext *PipelineDescriptor
|
EvidenceContext *PipelineDescriptor
|
||||||
@@ -90,8 +126,29 @@ type RejectionSummary struct {
|
|||||||
|
|
||||||
// WarningSummary retains structured warning identity without free-form messages.
|
// WarningSummary retains structured warning identity without free-form messages.
|
||||||
type WarningSummary struct {
|
type WarningSummary struct {
|
||||||
Scope string
|
Disposition string
|
||||||
ReasonCode string
|
Category string
|
||||||
|
ReasonCode string
|
||||||
|
Origin DiagnosticOrigin
|
||||||
|
OccurrenceCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiagnosticOrigin identifies the framework-owned pipeline location of a finding.
|
||||||
|
type DiagnosticOrigin struct {
|
||||||
|
Stage string
|
||||||
|
StepID string
|
||||||
|
LaneID string
|
||||||
|
ModuleKey string
|
||||||
|
ValidatorKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiagnosticSummary retains bounded advisory or observation group metadata.
|
||||||
|
type DiagnosticSummary struct {
|
||||||
|
Disposition string
|
||||||
|
Category string
|
||||||
|
ReasonCode string
|
||||||
|
Origin DiagnosticOrigin
|
||||||
|
OccurrenceCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunResult describes a successfully decoded and validated Notarius bundle.
|
// RunResult describes a successfully decoded and validated Notarius bundle.
|
||||||
@@ -105,4 +162,5 @@ type RunResult struct {
|
|||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Rejections []RejectionSummary
|
Rejections []RejectionSummary
|
||||||
Warnings []WarningSummary
|
Warnings []WarningSummary
|
||||||
|
Diagnostics []DiagnosticSummary
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,24 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxReceiptBytes = 1 << 20
|
maxReceiptBytes = 1 << 20
|
||||||
maxIndexBytes = 4 << 20
|
maxIndexBytes = 4 << 20
|
||||||
maxSummaryBytes = 4 << 20
|
maxSummaryBytes = 4 << 20
|
||||||
canonicalIndexFile = "index.json"
|
canonicalIndexFile = "index.json"
|
||||||
canonicalManifestFile = "manifest.json"
|
canonicalManifestFile = "manifest.json"
|
||||||
canonicalRejectedFile = "rejected.json"
|
canonicalRejectedFile = "rejected.json"
|
||||||
canonicalWarningsFile = "warnings.json"
|
canonicalWarningsFile = "warnings.json"
|
||||||
|
canonicalDiagnosticsFile = "diagnostics.json"
|
||||||
|
warningsSchemaVersion = "notarius.warnings.v2"
|
||||||
|
diagnosticsSchemaVersion = "notarius.diagnostics.v1"
|
||||||
|
maxWarningGroups = 128
|
||||||
|
maxDiagnosticGroups = 256
|
||||||
|
maxFindingSamples = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
type subprocessRun func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error)
|
type subprocessRun func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error)
|
||||||
@@ -41,25 +48,51 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
|||||||
if r == nil || r.run == nil {
|
if r == nil || r.run == nil {
|
||||||
return RunResult{}, fmt.Errorf("notarius subprocess runner is nil")
|
return RunResult{}, fmt.Errorf("notarius subprocess runner is nil")
|
||||||
}
|
}
|
||||||
if err := validateRunRequest(req); err != nil {
|
references, err := validateRunRequest(req)
|
||||||
|
if err != nil {
|
||||||
return RunResult{}, err
|
return RunResult{}, err
|
||||||
}
|
}
|
||||||
|
var envOverrides map[string]string
|
||||||
|
var sensitiveEnvNames []string
|
||||||
|
if req.LLMAPIKey != nil {
|
||||||
|
if !req.LLMAPIKey.IsResolved() {
|
||||||
|
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
|
||||||
|
}
|
||||||
|
name := req.LLMAPIKey.EnvName()
|
||||||
|
value := req.LLMAPIKey.Value()
|
||||||
|
if value == "" {
|
||||||
|
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
|
||||||
|
}
|
||||||
|
envOverrides = map[string]string{name: value}
|
||||||
|
sensitiveEnvNames = []string{name}
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{
|
args := []string{
|
||||||
"run", req.PipelineID,
|
"run", req.PipelineID,
|
||||||
"--config", req.ConfigPath,
|
"--config", req.ConfigPath,
|
||||||
"--input", req.InputPath,
|
"--input", req.InputPath,
|
||||||
"--output-dir", req.OutputRoot,
|
"--output-dir", req.OutputRoot,
|
||||||
"--json",
|
|
||||||
}
|
}
|
||||||
|
if req.Resume {
|
||||||
|
args = append(args, "--resume")
|
||||||
|
}
|
||||||
|
if req.ChunkCache != "" {
|
||||||
|
args = append(args, "--chunk_cache", req.ChunkCache)
|
||||||
|
}
|
||||||
|
for _, reference := range references {
|
||||||
|
args = append(args, "--reference", reference.Selector+"="+reference.Path)
|
||||||
|
}
|
||||||
|
args = append(args, "--json")
|
||||||
processResult, err := r.run(ctx, subprocess.RunRequest{
|
processResult, err := r.run(ctx, subprocess.RunRequest{
|
||||||
Executable: req.Binary,
|
Executable: req.Binary,
|
||||||
Args: args,
|
Args: args,
|
||||||
WorkingDir: req.WorkingDirectory,
|
WorkingDir: req.WorkingDirectory,
|
||||||
Timeout: req.Timeout,
|
Timeout: req.Timeout,
|
||||||
DiagnosticOwner: "notarius",
|
DiagnosticOwner: "notarius",
|
||||||
StdoutLogPath: req.ReceiptPath,
|
StdoutLogPath: req.ReceiptPath,
|
||||||
StderrLogPath: req.LogPath,
|
StderrLogPath: req.LogPath,
|
||||||
|
EnvOverrides: envOverrides,
|
||||||
|
SensitiveEnvNames: sensitiveEnvNames,
|
||||||
})
|
})
|
||||||
baseResult := RunResult{
|
baseResult := RunResult{
|
||||||
ReceiptPath: req.ReceiptPath,
|
ReceiptPath: req.ReceiptPath,
|
||||||
@@ -95,24 +128,45 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return baseResult, err
|
return baseResult, err
|
||||||
}
|
}
|
||||||
|
diagnostics, diagnosticOccurrences, diagnosticsTruncated, err := loadDiagnostics(index.DiagnosticsPath)
|
||||||
|
if err != nil {
|
||||||
|
return baseResult, err
|
||||||
|
}
|
||||||
|
if receipt.NormalizedOutputCount != len(index.Lanes) || receipt.RejectedOutputCount != len(rejections) ||
|
||||||
|
receipt.WarningGroupCount != len(warnings) || receipt.DiagnosticGroupCount != len(diagnostics) {
|
||||||
|
return baseResult, fmt.Errorf("notarius receipt counts do not match published bundle")
|
||||||
|
}
|
||||||
|
warningOccurrences, err := sumWarningOccurrences(warnings)
|
||||||
|
if err != nil {
|
||||||
|
return baseResult, err
|
||||||
|
}
|
||||||
|
if receipt.WarningOccurrenceCount != warningOccurrences ||
|
||||||
|
receipt.DiagnosticOccurrenceCount != diagnosticOccurrences ||
|
||||||
|
receipt.DiagnosticsTruncated != diagnosticsTruncated {
|
||||||
|
return baseResult, fmt.Errorf("notarius receipt occurrence counts do not match published bundle")
|
||||||
|
}
|
||||||
|
|
||||||
baseResult.Receipt = receipt
|
baseResult.Receipt = receipt
|
||||||
baseResult.Index = index
|
baseResult.Index = index
|
||||||
baseResult.BundleRoot = bundleRoot
|
baseResult.BundleRoot = bundleRoot
|
||||||
baseResult.Rejections = rejections
|
baseResult.Rejections = rejections
|
||||||
baseResult.Warnings = warnings
|
baseResult.Warnings = warnings
|
||||||
|
baseResult.Diagnostics = diagnostics
|
||||||
return baseResult, nil
|
return baseResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRunRequest(req RunRequest) error {
|
func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
|
||||||
if strings.TrimSpace(req.Binary) == "" {
|
if strings.TrimSpace(req.Binary) == "" {
|
||||||
return fmt.Errorf("notarius binary is required")
|
return nil, fmt.Errorf("notarius binary is required")
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.PipelineID) == "" {
|
if strings.TrimSpace(req.PipelineID) == "" {
|
||||||
return fmt.Errorf("notarius pipeline id is required")
|
return nil, fmt.Errorf("notarius pipeline id is required")
|
||||||
}
|
}
|
||||||
if req.Timeout <= 0 {
|
if req.Timeout <= 0 {
|
||||||
return fmt.Errorf("notarius timeout must be positive")
|
return nil, fmt.Errorf("notarius timeout must be positive")
|
||||||
|
}
|
||||||
|
if !validChunkCacheMode(req.ChunkCache) {
|
||||||
|
return nil, fmt.Errorf("notarius chunk cache mode must be empty, auto, bypass, or refresh")
|
||||||
}
|
}
|
||||||
for label, path := range map[string]string{
|
for label, path := range map[string]string{
|
||||||
"config": req.ConfigPath,
|
"config": req.ConfigPath,
|
||||||
@@ -123,47 +177,133 @@ func validateRunRequest(req RunRequest) error {
|
|||||||
"log": req.LogPath,
|
"log": req.LogPath,
|
||||||
} {
|
} {
|
||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return fmt.Errorf("notarius %s path is required", label)
|
return nil, fmt.Errorf("notarius %s path is required", label)
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(path) {
|
if !filepath.IsAbs(path) {
|
||||||
return fmt.Errorf("notarius %s path must be absolute", label)
|
return nil, fmt.Errorf("notarius %s path must be absolute", label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) {
|
if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) {
|
||||||
return fmt.Errorf("notarius receipt and log paths must be different")
|
return nil, fmt.Errorf("notarius receipt and log paths must be different")
|
||||||
|
}
|
||||||
|
references := make([]ReferenceBinding, 0, len(req.References))
|
||||||
|
selectors := make(map[string]struct{}, len(req.References))
|
||||||
|
for index, binding := range req.References {
|
||||||
|
selector, err := notariusref.NormalizeSelector(binding.Selector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("notarius reference %d selector: %w", index, err)
|
||||||
|
}
|
||||||
|
if _, duplicate := selectors[selector]; duplicate {
|
||||||
|
return nil, fmt.Errorf("notarius reference selector %q is duplicated", selector)
|
||||||
|
}
|
||||||
|
selectors[selector] = struct{}{}
|
||||||
|
if strings.TrimSpace(binding.Path) == "" {
|
||||||
|
return nil, fmt.Errorf("notarius reference %q path is required", selector)
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(binding.Path) {
|
||||||
|
return nil, fmt.Errorf("notarius reference %q path must be absolute", selector)
|
||||||
|
}
|
||||||
|
references = append(references, ReferenceBinding{Selector: selector, Path: binding.Path})
|
||||||
}
|
}
|
||||||
if err := requireRegularFile(req.ConfigPath); err != nil {
|
if err := requireRegularFile(req.ConfigPath); err != nil {
|
||||||
return fmt.Errorf("validate notarius config path: %w", err)
|
return nil, fmt.Errorf("validate notarius config path: %w", err)
|
||||||
}
|
}
|
||||||
if err := requireRegularFile(req.InputPath); err != nil {
|
if err := requireRegularFile(req.InputPath); err != nil {
|
||||||
return fmt.Errorf("validate notarius input path: %w", err)
|
return nil, fmt.Errorf("validate notarius input path: %w", err)
|
||||||
}
|
}
|
||||||
if err := requireDirectory(req.OutputRoot); err != nil {
|
if err := requireDirectory(req.OutputRoot); err != nil {
|
||||||
return fmt.Errorf("validate notarius output root: %w", err)
|
return nil, fmt.Errorf("validate notarius output root: %w", err)
|
||||||
}
|
}
|
||||||
if err := requireDirectory(req.WorkingDirectory); err != nil {
|
if err := requireDirectory(req.WorkingDirectory); err != nil {
|
||||||
return fmt.Errorf("validate notarius working directory: %w", err)
|
return nil, fmt.Errorf("validate notarius working directory: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateLogDestination(req.ReceiptPath); err != nil {
|
if err := validateLogDestination(req.ReceiptPath); err != nil {
|
||||||
return fmt.Errorf("validate notarius receipt path: %w", err)
|
return nil, fmt.Errorf("validate notarius receipt path: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateLogDestination(req.LogPath); err != nil {
|
if err := validateLogDestination(req.LogPath); err != nil {
|
||||||
return fmt.Errorf("validate notarius log path: %w", err)
|
return nil, fmt.Errorf("validate notarius log path: %w", err)
|
||||||
|
}
|
||||||
|
return references, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validChunkCacheMode(mode string) bool {
|
||||||
|
switch mode {
|
||||||
|
case "", "auto", "bypass", "refresh":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type receiptDocument struct {
|
type receiptDocument struct {
|
||||||
SchemaVersion string `json:"schema_version"`
|
SchemaVersion string `json:"schema_version"`
|
||||||
RunID string `json:"run_id"`
|
RunID string `json:"run_id"`
|
||||||
PipelineID string `json:"pipeline_id"`
|
PipelineID string `json:"pipeline_id"`
|
||||||
OutputDirectory string `json:"output_directory"`
|
OutputDirectory string `json:"output_directory"`
|
||||||
IndexFile string `json:"index_file"`
|
IndexFile string `json:"index_file"`
|
||||||
NormalizedOutputCount *int `json:"normalized_output_count"`
|
NormalizedOutputCount *int `json:"normalized_output_count"`
|
||||||
RejectedOutputCount *int `json:"rejected_output_count"`
|
RejectedOutputCount *int `json:"rejected_output_count"`
|
||||||
WarningCount *int `json:"warning_count"`
|
WarningGroupCount *int `json:"warning_group_count"`
|
||||||
ValidationStatus string `json:"validation_status"`
|
WarningOccurrenceCount *int `json:"warning_occurrence_count"`
|
||||||
DebugDirectory string `json:"debug_directory"`
|
DiagnosticGroupCount *int `json:"diagnostic_group_count"`
|
||||||
|
DiagnosticOccurrenceCount *int `json:"diagnostic_occurrence_count"`
|
||||||
|
DiagnosticsTruncated *bool `json:"diagnostics_truncated"`
|
||||||
|
ValidationStatus string `json:"validation_status"`
|
||||||
|
ValidationSummaries []validationSummaryDocument `json:"validation_summaries"`
|
||||||
|
DebugDirectory string `json:"debug_directory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type validationSummaryDocument struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
StepID string `json:"step_id"`
|
||||||
|
LaneID string `json:"lane_id"`
|
||||||
|
ModuleKey string `json:"module_key"`
|
||||||
|
ChunkID string `json:"chunk_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RejectingValidators []string `json:"rejecting_validators"`
|
||||||
|
ReasonCodes []string `json:"reason_codes"`
|
||||||
|
IncompleteValidators []string `json:"incomplete_validators"`
|
||||||
|
ProducerAttemptCount *int `json:"producer_attempt_count"`
|
||||||
|
TerminalAction string `json:"terminal_action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validValidationStatus(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "approved", "rejected", "incomplete":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateValidationSummaries(documents []validationSummaryDocument) ([]ValidationSummary, error) {
|
||||||
|
summaries := make([]ValidationSummary, 0, len(documents))
|
||||||
|
for _, document := range documents {
|
||||||
|
if document.Status != "complete" && document.Status != "rejected" && document.Status != "incomplete" {
|
||||||
|
return nil, fmt.Errorf("notarius validation summary status %q is invalid", document.Status)
|
||||||
|
}
|
||||||
|
if document.ProducerAttemptCount == nil || *document.ProducerAttemptCount < 0 || !validTerminalAction(document.TerminalAction) {
|
||||||
|
return nil, fmt.Errorf("notarius validation summary is missing required fields")
|
||||||
|
}
|
||||||
|
summaries = append(summaries, ValidationSummary{
|
||||||
|
Stage: document.Stage, StepID: document.StepID, LaneID: document.LaneID,
|
||||||
|
ModuleKey: document.ModuleKey, ChunkID: document.ChunkID, Status: document.Status,
|
||||||
|
RejectingValidators: append([]string(nil), document.RejectingValidators...),
|
||||||
|
ReasonCodes: append([]string(nil), document.ReasonCodes...),
|
||||||
|
IncompleteValidators: append([]string(nil), document.IncompleteValidators...),
|
||||||
|
ProducerAttemptCount: *document.ProducerAttemptCount, TerminalAction: document.TerminalAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return summaries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTerminalAction(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "accepted", "reject_output", "warn_continue", "fail_run":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadReceipt(path, pipelineID string) (Receipt, error) {
|
func loadReceipt(path, pipelineID string) (Receipt, error) {
|
||||||
@@ -177,7 +317,9 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
|
|||||||
if strings.TrimSpace(document.RunID) == "" || strings.TrimSpace(document.PipelineID) == "" ||
|
if strings.TrimSpace(document.RunID) == "" || strings.TrimSpace(document.PipelineID) == "" ||
|
||||||
strings.TrimSpace(document.OutputDirectory) == "" || strings.TrimSpace(document.ValidationStatus) == "" ||
|
strings.TrimSpace(document.OutputDirectory) == "" || strings.TrimSpace(document.ValidationStatus) == "" ||
|
||||||
document.NormalizedOutputCount == nil ||
|
document.NormalizedOutputCount == nil ||
|
||||||
document.RejectedOutputCount == nil || document.WarningCount == nil {
|
document.RejectedOutputCount == nil || document.WarningGroupCount == nil ||
|
||||||
|
document.WarningOccurrenceCount == nil || document.DiagnosticGroupCount == nil ||
|
||||||
|
document.DiagnosticOccurrenceCount == nil || document.DiagnosticsTruncated == nil {
|
||||||
return Receipt{}, fmt.Errorf("notarius receipt is missing required fields")
|
return Receipt{}, fmt.Errorf("notarius receipt is missing required fields")
|
||||||
}
|
}
|
||||||
if document.IndexFile != canonicalIndexFile {
|
if document.IndexFile != canonicalIndexFile {
|
||||||
@@ -186,9 +328,18 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
|
|||||||
if document.PipelineID != pipelineID {
|
if document.PipelineID != pipelineID {
|
||||||
return Receipt{}, fmt.Errorf("notarius receipt pipeline id %q does not match requested pipeline %q", document.PipelineID, pipelineID)
|
return Receipt{}, fmt.Errorf("notarius receipt pipeline id %q does not match requested pipeline %q", document.PipelineID, pipelineID)
|
||||||
}
|
}
|
||||||
if *document.NormalizedOutputCount < 0 || *document.RejectedOutputCount < 0 || *document.WarningCount < 0 {
|
if *document.NormalizedOutputCount < 0 || *document.RejectedOutputCount < 0 ||
|
||||||
|
*document.WarningGroupCount < 0 || *document.WarningOccurrenceCount < 0 ||
|
||||||
|
*document.DiagnosticGroupCount < 0 || *document.DiagnosticOccurrenceCount < 0 {
|
||||||
return Receipt{}, fmt.Errorf("notarius receipt counts must be non-negative")
|
return Receipt{}, fmt.Errorf("notarius receipt counts must be non-negative")
|
||||||
}
|
}
|
||||||
|
if !validValidationStatus(document.ValidationStatus) {
|
||||||
|
return Receipt{}, fmt.Errorf("notarius receipt validation_status %q is invalid", document.ValidationStatus)
|
||||||
|
}
|
||||||
|
validationSummaries, err := validateValidationSummaries(document.ValidationSummaries)
|
||||||
|
if err != nil {
|
||||||
|
return Receipt{}, err
|
||||||
|
}
|
||||||
if !filepath.IsAbs(document.OutputDirectory) {
|
if !filepath.IsAbs(document.OutputDirectory) {
|
||||||
return Receipt{}, fmt.Errorf("notarius receipt output directory must be absolute")
|
return Receipt{}, fmt.Errorf("notarius receipt output directory must be absolute")
|
||||||
}
|
}
|
||||||
@@ -196,16 +347,21 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
|
|||||||
return Receipt{}, fmt.Errorf("notarius receipt debug directory must be absolute when present")
|
return Receipt{}, fmt.Errorf("notarius receipt debug directory must be absolute when present")
|
||||||
}
|
}
|
||||||
return Receipt{
|
return Receipt{
|
||||||
SchemaVersion: document.SchemaVersion,
|
SchemaVersion: document.SchemaVersion,
|
||||||
RunID: document.RunID,
|
RunID: document.RunID,
|
||||||
PipelineID: document.PipelineID,
|
PipelineID: document.PipelineID,
|
||||||
OutputDirectory: filepath.Clean(document.OutputDirectory),
|
OutputDirectory: filepath.Clean(document.OutputDirectory),
|
||||||
IndexFile: document.IndexFile,
|
IndexFile: document.IndexFile,
|
||||||
NormalizedOutputCount: *document.NormalizedOutputCount,
|
NormalizedOutputCount: *document.NormalizedOutputCount,
|
||||||
RejectedOutputCount: *document.RejectedOutputCount,
|
RejectedOutputCount: *document.RejectedOutputCount,
|
||||||
WarningCount: *document.WarningCount,
|
WarningGroupCount: *document.WarningGroupCount,
|
||||||
ValidationStatus: document.ValidationStatus,
|
WarningOccurrenceCount: *document.WarningOccurrenceCount,
|
||||||
DebugDirectory: document.DebugDirectory,
|
DiagnosticGroupCount: *document.DiagnosticGroupCount,
|
||||||
|
DiagnosticOccurrenceCount: *document.DiagnosticOccurrenceCount,
|
||||||
|
DiagnosticsTruncated: *document.DiagnosticsTruncated,
|
||||||
|
ValidationStatus: document.ValidationStatus,
|
||||||
|
ValidationSummaries: validationSummaries,
|
||||||
|
DebugDirectory: document.DebugDirectory,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +370,7 @@ type indexDocument struct {
|
|||||||
OutputFiles *[]laneDocument `json:"output_files"`
|
OutputFiles *[]laneDocument `json:"output_files"`
|
||||||
RejectedFile string `json:"rejected_file"`
|
RejectedFile string `json:"rejected_file"`
|
||||||
WarningsFile string `json:"warnings_file"`
|
WarningsFile string `json:"warnings_file"`
|
||||||
|
DiagnosticsFile string `json:"diagnostics_file"`
|
||||||
ChunkMap *pipelineDocument `json:"chunk_map"`
|
ChunkMap *pipelineDocument `json:"chunk_map"`
|
||||||
EvidenceContext *pipelineDocument `json:"evidence_context"`
|
EvidenceContext *pipelineDocument `json:"evidence_context"`
|
||||||
}
|
}
|
||||||
@@ -250,6 +407,7 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
|
|||||||
{name: "manifest_file", got: document.ManifestFile, want: canonicalManifestFile},
|
{name: "manifest_file", got: document.ManifestFile, want: canonicalManifestFile},
|
||||||
{name: "rejected_file", got: document.RejectedFile, want: canonicalRejectedFile},
|
{name: "rejected_file", got: document.RejectedFile, want: canonicalRejectedFile},
|
||||||
{name: "warnings_file", got: document.WarningsFile, want: canonicalWarningsFile},
|
{name: "warnings_file", got: document.WarningsFile, want: canonicalWarningsFile},
|
||||||
|
{name: "diagnostics_file", got: document.DiagnosticsFile, want: canonicalDiagnosticsFile},
|
||||||
} {
|
} {
|
||||||
if field.got != field.want {
|
if field.got != field.want {
|
||||||
return Index{}, fmt.Errorf("notarius index %s %q is incompatible; want %q", field.name, field.got, field.want)
|
return Index{}, fmt.Errorf("notarius index %s %q is incompatible; want %q", field.name, field.got, field.want)
|
||||||
@@ -260,10 +418,11 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
index := Index{
|
index := Index{
|
||||||
Path: indexPath,
|
Path: indexPath,
|
||||||
ManifestFile: document.ManifestFile,
|
ManifestFile: document.ManifestFile,
|
||||||
RejectedFile: document.RejectedFile,
|
RejectedFile: document.RejectedFile,
|
||||||
WarningsFile: document.WarningsFile,
|
WarningsFile: document.WarningsFile,
|
||||||
|
DiagnosticsFile: document.DiagnosticsFile,
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
if index.ManifestPath, err = resolveRegularFile(bundleRoot, index.ManifestFile); err != nil {
|
if index.ManifestPath, err = resolveRegularFile(bundleRoot, index.ManifestFile); err != nil {
|
||||||
@@ -275,6 +434,9 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
|
|||||||
if index.WarningsPath, err = resolveRegularFile(bundleRoot, index.WarningsFile); err != nil {
|
if index.WarningsPath, err = resolveRegularFile(bundleRoot, index.WarningsFile); err != nil {
|
||||||
return Index{}, fmt.Errorf("resolve notarius warning file: %w", err)
|
return Index{}, fmt.Errorf("resolve notarius warning file: %w", err)
|
||||||
}
|
}
|
||||||
|
if index.DiagnosticsPath, err = resolveRegularFile(bundleRoot, index.DiagnosticsFile); err != nil {
|
||||||
|
return Index{}, fmt.Errorf("resolve notarius diagnostics file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
seenLanes := make(map[string]struct{}, len(*document.OutputFiles))
|
seenLanes := make(map[string]struct{}, len(*document.OutputFiles))
|
||||||
for _, lane := range *document.OutputFiles {
|
for _, lane := range *document.OutputFiles {
|
||||||
@@ -362,12 +524,34 @@ func loadRejections(path string) ([]RejectionSummary, error) {
|
|||||||
return summaries, nil
|
return summaries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type warningDocument struct {
|
type findingGroupDocument struct {
|
||||||
Warnings *[]struct {
|
Disposition string `json:"disposition"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
ReasonCode string `json:"reason_code"`
|
||||||
|
Origin diagnosticOriginDocument `json:"origin"`
|
||||||
|
OccurrenceCount *int `json:"occurrence_count"`
|
||||||
|
Samples *[]struct {
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
ReasonCode string `json:"reason_code"`
|
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
} `json:"warnings"`
|
ChunkID string `json:"chunk_id"`
|
||||||
|
ChunkIndex *int `json:"chunk_index"`
|
||||||
|
} `json:"samples"`
|
||||||
|
OmittedSampleCount *int `json:"omitted_sample_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type diagnosticOriginDocument struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
StepID string `json:"step_id"`
|
||||||
|
LaneID string `json:"lane_id"`
|
||||||
|
ModuleKey string `json:"module_key"`
|
||||||
|
ValidatorKey string `json:"validator_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type warningDocument struct {
|
||||||
|
SchemaVersion string `json:"schema_version"`
|
||||||
|
GroupCount *int `json:"group_count"`
|
||||||
|
OccurrenceCount *int `json:"occurrence_count"`
|
||||||
|
Groups *[]findingGroupDocument `json:"groups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadWarnings(path string) ([]WarningSummary, error) {
|
func loadWarnings(path string) ([]WarningSummary, error) {
|
||||||
@@ -375,21 +559,153 @@ func loadWarnings(path string) ([]WarningSummary, error) {
|
|||||||
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
|
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
|
||||||
return nil, fmt.Errorf("decode notarius warnings: %w", err)
|
return nil, fmt.Errorf("decode notarius warnings: %w", err)
|
||||||
}
|
}
|
||||||
if document.Warnings == nil {
|
if document.SchemaVersion != warningsSchemaVersion || document.GroupCount == nil ||
|
||||||
return nil, fmt.Errorf("notarius warning document is missing warnings array")
|
document.OccurrenceCount == nil || document.Groups == nil {
|
||||||
|
return nil, fmt.Errorf("notarius warning document is missing or incompatible required fields")
|
||||||
}
|
}
|
||||||
summaries := make([]WarningSummary, 0, len(*document.Warnings))
|
if *document.GroupCount < 0 || *document.GroupCount > maxWarningGroups || *document.OccurrenceCount < 0 ||
|
||||||
for _, item := range *document.Warnings {
|
*document.GroupCount != len(*document.Groups) {
|
||||||
if strings.TrimSpace(item.ReasonCode) == "" || strings.TrimSpace(item.Message) == "" {
|
return nil, fmt.Errorf("notarius warning document counts are inconsistent")
|
||||||
return nil, fmt.Errorf("notarius warning entries require reason_code and message")
|
}
|
||||||
|
summaries := make([]WarningSummary, 0, len(*document.Groups))
|
||||||
|
occurrences := 0
|
||||||
|
for _, group := range *document.Groups {
|
||||||
|
if err := validateFindingGroup(group); err != nil {
|
||||||
|
return nil, fmt.Errorf("notarius warning group: %w", err)
|
||||||
}
|
}
|
||||||
summaries = append(summaries, WarningSummary{Scope: item.Scope, ReasonCode: item.ReasonCode})
|
if group.Disposition != "warning" {
|
||||||
|
return nil, fmt.Errorf("notarius warning group disposition %q is invalid", group.Disposition)
|
||||||
|
}
|
||||||
|
if *group.OccurrenceCount > int(^uint(0)>>1)-occurrences {
|
||||||
|
return nil, fmt.Errorf("notarius warning occurrence count overflows")
|
||||||
|
}
|
||||||
|
occurrences += *group.OccurrenceCount
|
||||||
|
summaries = append(summaries, WarningSummary{
|
||||||
|
Disposition: group.Disposition, Category: group.Category, ReasonCode: group.ReasonCode,
|
||||||
|
Origin: diagnosticOrigin(group.Origin), OccurrenceCount: *group.OccurrenceCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if occurrences != *document.OccurrenceCount {
|
||||||
|
return nil, fmt.Errorf("notarius warning document occurrence count is inconsistent")
|
||||||
}
|
}
|
||||||
return summaries, nil
|
return summaries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type diagnosticDocument struct {
|
||||||
|
SchemaVersion string `json:"schema_version"`
|
||||||
|
GroupCount *int `json:"group_count"`
|
||||||
|
OccurrenceCount *int `json:"occurrence_count"`
|
||||||
|
Truncated *bool `json:"truncated"`
|
||||||
|
UnrepresentedOccurrenceCount *int `json:"unrepresented_occurrence_count"`
|
||||||
|
Groups *[]findingGroupDocument `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDiagnostics(path string) ([]DiagnosticSummary, int, bool, error) {
|
||||||
|
var document diagnosticDocument
|
||||||
|
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
|
||||||
|
return nil, 0, false, fmt.Errorf("decode notarius diagnostics: %w", err)
|
||||||
|
}
|
||||||
|
if document.SchemaVersion != diagnosticsSchemaVersion || document.GroupCount == nil ||
|
||||||
|
document.OccurrenceCount == nil || document.Truncated == nil ||
|
||||||
|
document.UnrepresentedOccurrenceCount == nil || document.Groups == nil {
|
||||||
|
return nil, 0, false, fmt.Errorf("notarius diagnostics document is missing or incompatible required fields")
|
||||||
|
}
|
||||||
|
if *document.GroupCount < 0 || *document.GroupCount > maxDiagnosticGroups || *document.OccurrenceCount < 0 ||
|
||||||
|
*document.UnrepresentedOccurrenceCount < 0 || *document.GroupCount != len(*document.Groups) {
|
||||||
|
return nil, 0, false, fmt.Errorf("notarius diagnostics document counts are inconsistent")
|
||||||
|
}
|
||||||
|
if !*document.Truncated && *document.UnrepresentedOccurrenceCount != 0 {
|
||||||
|
return nil, 0, false, fmt.Errorf("notarius diagnostics document has unrepresented occurrences without truncation")
|
||||||
|
}
|
||||||
|
summaries := make([]DiagnosticSummary, 0, len(*document.Groups))
|
||||||
|
representedOccurrences := 0
|
||||||
|
for _, group := range *document.Groups {
|
||||||
|
if err := validateFindingGroup(group); err != nil {
|
||||||
|
return nil, 0, false, fmt.Errorf("notarius diagnostic group: %w", err)
|
||||||
|
}
|
||||||
|
if group.Disposition != "advisory" && group.Disposition != "observation" {
|
||||||
|
return nil, 0, false, fmt.Errorf("notarius diagnostic group disposition %q is invalid", group.Disposition)
|
||||||
|
}
|
||||||
|
if *group.OccurrenceCount > int(^uint(0)>>1)-representedOccurrences {
|
||||||
|
return nil, 0, false, fmt.Errorf("notarius diagnostic occurrence count overflows")
|
||||||
|
}
|
||||||
|
representedOccurrences += *group.OccurrenceCount
|
||||||
|
summaries = append(summaries, DiagnosticSummary{
|
||||||
|
Disposition: group.Disposition, Category: group.Category, ReasonCode: group.ReasonCode,
|
||||||
|
Origin: diagnosticOrigin(group.Origin), OccurrenceCount: *group.OccurrenceCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if *document.UnrepresentedOccurrenceCount > int(^uint(0)>>1)-representedOccurrences ||
|
||||||
|
representedOccurrences+*document.UnrepresentedOccurrenceCount != *document.OccurrenceCount {
|
||||||
|
return nil, 0, false, fmt.Errorf("notarius diagnostics document occurrence count is inconsistent")
|
||||||
|
}
|
||||||
|
return summaries, *document.OccurrenceCount, *document.Truncated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFindingGroup(group findingGroupDocument) error {
|
||||||
|
if strings.TrimSpace(group.Disposition) == "" || strings.TrimSpace(group.Category) == "" ||
|
||||||
|
strings.TrimSpace(group.ReasonCode) == "" || !validDiagnosticOriginStage(group.Origin.Stage) ||
|
||||||
|
!validDiagnosticCategory(group.Disposition, group.Category) ||
|
||||||
|
group.OccurrenceCount == nil || *group.OccurrenceCount <= 0 || group.Samples == nil ||
|
||||||
|
group.OmittedSampleCount == nil || *group.OmittedSampleCount < 0 {
|
||||||
|
return fmt.Errorf("missing required fields")
|
||||||
|
}
|
||||||
|
if len(*group.Samples) == 0 || len(*group.Samples) > maxFindingSamples ||
|
||||||
|
*group.OmittedSampleCount != *group.OccurrenceCount-len(*group.Samples) {
|
||||||
|
return fmt.Errorf("sample counts are inconsistent")
|
||||||
|
}
|
||||||
|
for _, sample := range *group.Samples {
|
||||||
|
if strings.TrimSpace(sample.Scope) == "" || strings.TrimSpace(sample.Message) == "" ||
|
||||||
|
(sample.ChunkIndex != nil && *sample.ChunkIndex < 0) {
|
||||||
|
return fmt.Errorf("samples require scope and message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDiagnosticCategory(disposition, category string) bool {
|
||||||
|
switch disposition {
|
||||||
|
case "warning":
|
||||||
|
return category == "configuration" || category == "degradation" ||
|
||||||
|
category == "validation_incomplete" || category == "fallback"
|
||||||
|
case "advisory":
|
||||||
|
return category == "data_quality"
|
||||||
|
case "observation":
|
||||||
|
return category == "normalization"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDiagnosticOriginStage(stage string) bool {
|
||||||
|
switch stage {
|
||||||
|
case "references", "chunk", "extract", "merge", "normalize":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticOrigin(document diagnosticOriginDocument) DiagnosticOrigin {
|
||||||
|
return DiagnosticOrigin{
|
||||||
|
Stage: document.Stage, StepID: document.StepID, LaneID: document.LaneID,
|
||||||
|
ModuleKey: document.ModuleKey, ValidatorKey: document.ValidatorKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sumWarningOccurrences(values []WarningSummary) (int, error) {
|
||||||
|
total := 0
|
||||||
|
for _, value := range values {
|
||||||
|
if value.OccurrenceCount > int(^uint(0)>>1)-total {
|
||||||
|
return 0, fmt.Errorf("notarius warning occurrence count overflows")
|
||||||
|
}
|
||||||
|
total += value.OccurrenceCount
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
sharedsubprocess "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
sharedsubprocess "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,6 +53,10 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
|
|||||||
if result.Receipt.SchemaVersion != ReceiptSchemaVersion || result.Receipt.RunID != "notarius-run-1" {
|
if result.Receipt.SchemaVersion != ReceiptSchemaVersion || result.Receipt.RunID != "notarius-run-1" {
|
||||||
t.Fatalf("receipt = %#v", result.Receipt)
|
t.Fatalf("receipt = %#v", result.Receipt)
|
||||||
}
|
}
|
||||||
|
if len(result.Receipt.ValidationSummaries) != 1 || result.Receipt.ValidationSummaries[0].LaneID != "npc-registry" ||
|
||||||
|
result.Receipt.ValidationSummaries[0].Status != "complete" {
|
||||||
|
t.Fatalf("validation summaries = %#v", result.Receipt.ValidationSummaries)
|
||||||
|
}
|
||||||
if len(result.Index.Lanes) != 1 || result.Index.Lanes[0].LaneID != "npc-registry" {
|
if len(result.Index.Lanes) != 1 || result.Index.Lanes[0].LaneID != "npc-registry" {
|
||||||
t.Fatalf("lanes = %#v", result.Index.Lanes)
|
t.Fatalf("lanes = %#v", result.Index.Lanes)
|
||||||
}
|
}
|
||||||
@@ -64,22 +69,248 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
|
|||||||
if len(result.Rejections) != 1 || result.Rejections[0].LaneID != "spells" || result.Rejections[0].ReasonCode != "invalid_spell" {
|
if len(result.Rejections) != 1 || result.Rejections[0].LaneID != "spells" || result.Rejections[0].ReasonCode != "invalid_spell" {
|
||||||
t.Fatalf("rejections = %#v", result.Rejections)
|
t.Fatalf("rejections = %#v", result.Rejections)
|
||||||
}
|
}
|
||||||
if len(result.Warnings) != 1 || result.Warnings[0].Scope != "lane:npc-registry" || result.Warnings[0].ReasonCode != "normalized_name" {
|
if len(result.Warnings) != 1 || result.Warnings[0].Category != "degradation" || result.Warnings[0].ReasonCode != "normalized_name" {
|
||||||
t.Fatalf("warnings = %#v", result.Warnings)
|
t.Fatalf("warnings = %#v", result.Warnings)
|
||||||
}
|
}
|
||||||
|
if len(result.Diagnostics) != 1 || result.Diagnostics[0].Category != "data_quality" || result.Diagnostics[0].ReasonCode != "low_confidence" {
|
||||||
|
t.Fatalf("diagnostics = %#v", result.Diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerMapsCacheControlsToArguments(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
resume bool
|
||||||
|
chunkCache string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "resume", resume: true, want: []string{"--resume"}},
|
||||||
|
{name: "auto", chunkCache: "auto", want: []string{"--chunk_cache", "auto"}},
|
||||||
|
{name: "bypass", chunkCache: "bypass", want: []string{"--chunk_cache", "bypass"}},
|
||||||
|
{name: "refresh", chunkCache: "refresh", want: []string{"--chunk_cache", "refresh"}},
|
||||||
|
{name: "resume and mode", resume: true, chunkCache: "auto", want: []string{"--resume", "--chunk_cache", "auto"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
req := validRunRequest(t)
|
||||||
|
req.Resume = test.resume
|
||||||
|
req.ChunkCache = test.chunkCache
|
||||||
|
var captured sharedsubprocess.RunRequest
|
||||||
|
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||||
|
captured = processReq
|
||||||
|
writeValidBundleAndReceipt(t, req, false)
|
||||||
|
return sharedsubprocess.RunResult{ExitCode: 0}, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"run", req.PipelineID, "--config", req.ConfigPath, "--input", req.InputPath,
|
||||||
|
"--output-dir", req.OutputRoot,
|
||||||
|
}
|
||||||
|
want = append(want, test.want...)
|
||||||
|
want = append(want, "--json")
|
||||||
|
if !reflect.DeepEqual(captured.Args, want) {
|
||||||
|
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerRejectsInvalidChunkCacheBeforeLaunch(t *testing.T) {
|
||||||
|
for _, mode := range []string{"AUTO", "invalid", " auto "} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
req := validRunRequest(t)
|
||||||
|
req.ChunkCache = mode
|
||||||
|
started := false
|
||||||
|
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||||
|
started = true
|
||||||
|
return sharedsubprocess.RunResult{}, nil
|
||||||
|
}}
|
||||||
|
_, err := runner.Run(context.Background(), req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "chunk cache mode") {
|
||||||
|
t.Fatalf("Run() error = %v, want chunk cache validation failure", err)
|
||||||
|
}
|
||||||
|
if started {
|
||||||
|
t.Fatal("subprocess started with invalid chunk cache mode")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *testing.T) {
|
||||||
|
const envName = "NARRATIO_NOTARIUS_CUSTOM_LLM_KEY"
|
||||||
|
const loadedValue = "loaded-notarius-credential"
|
||||||
|
credential := loadTestCredential(t, envName, loadedValue)
|
||||||
|
t.Setenv(envName, "changed-parent-credential")
|
||||||
|
|
||||||
|
req := validRunRequest(t)
|
||||||
|
req.LLMAPIKey = credential
|
||||||
|
var captured sharedsubprocess.RunRequest
|
||||||
|
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||||
|
captured = processReq
|
||||||
|
writeValidBundleAndReceipt(t, req, false)
|
||||||
|
return sharedsubprocess.RunResult{ExitCode: 0}, nil
|
||||||
|
}}
|
||||||
|
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(captured.EnvOverrides) != 1 || captured.EnvOverrides[envName] != loadedValue {
|
||||||
|
t.Fatal("subprocess did not receive exactly the loaded credential override")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName}) {
|
||||||
|
t.Fatalf("sensitive environment names = %#v, want configured reference name", captured.SensitiveEnvNames)
|
||||||
|
}
|
||||||
|
wantArgs := []string{
|
||||||
|
"run", req.PipelineID, "--config", req.ConfigPath, "--input", req.InputPath,
|
||||||
|
"--output-dir", req.OutputRoot, "--json",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured.Args, wantArgs) {
|
||||||
|
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerRejectsInvalidProgrammaticCredentialBeforeLaunch(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
reference configkit.Secret
|
||||||
|
}{
|
||||||
|
{name: "unresolved", reference: configkit.SecretEnv("NARRATIO_NOTARIUS_UNRESOLVED_KEY")},
|
||||||
|
{name: "empty", reference: configkit.SecretEnv("")},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
req := validRunRequest(t)
|
||||||
|
req.LLMAPIKey = &test.reference
|
||||||
|
started := false
|
||||||
|
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||||
|
started = true
|
||||||
|
return sharedsubprocess.RunResult{}, nil
|
||||||
|
}}
|
||||||
|
_, err := runner.Run(context.Background(), req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "resolved and nonempty") {
|
||||||
|
t.Fatalf("Run() error = %v, want credential validation failure", err)
|
||||||
|
}
|
||||||
|
if started {
|
||||||
|
t.Fatal("subprocess started with an invalid credential")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) {
|
||||||
|
req := validRunRequest(t)
|
||||||
|
referenceRoot := t.TempDir()
|
||||||
|
req.References = []ReferenceBinding{
|
||||||
|
{Selector: " party ", Path: filepath.Join(referenceRoot, "party context=primary.json")},
|
||||||
|
{Selector: " npc-registry . extract . glossary ", Path: filepath.Join(referenceRoot, "glossary.json")},
|
||||||
|
}
|
||||||
|
originalReferences := append([]ReferenceBinding(nil), req.References...)
|
||||||
|
var captured sharedsubprocess.RunRequest
|
||||||
|
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||||
|
captured = processReq
|
||||||
|
writeValidBundleAndReceipt(t, req, false)
|
||||||
|
return sharedsubprocess.RunResult{ExitCode: 0}, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
wantArgs := []string{
|
||||||
|
"run", req.PipelineID,
|
||||||
|
"--config", req.ConfigPath,
|
||||||
|
"--input", req.InputPath,
|
||||||
|
"--output-dir", req.OutputRoot,
|
||||||
|
"--reference", "party=" + req.References[0].Path,
|
||||||
|
"--reference", "npc-registry.extract.glossary=" + req.References[1].Path,
|
||||||
|
"--json",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured.Args, wantArgs) {
|
||||||
|
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(req.References, originalReferences) {
|
||||||
|
t.Fatalf("Run() mutated caller references = %#v, want %#v", req.References, originalReferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
references func(string) []ReferenceBinding
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid selector",
|
||||||
|
references: func(root string) []ReferenceBinding {
|
||||||
|
return []ReferenceBinding{{Selector: "lane.prepare.party", Path: filepath.Join(root, "party.json")}}
|
||||||
|
},
|
||||||
|
wantErr: "selector",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate normalized selector",
|
||||||
|
references: func(root string) []ReferenceBinding {
|
||||||
|
return []ReferenceBinding{
|
||||||
|
{Selector: "lane.party", Path: filepath.Join(root, "party.json")},
|
||||||
|
{Selector: " lane . party ", Path: filepath.Join(root, "party-2.json")},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: "duplicated",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty path",
|
||||||
|
references: func(string) []ReferenceBinding {
|
||||||
|
return []ReferenceBinding{{Selector: "party", Path: " "}}
|
||||||
|
},
|
||||||
|
wantErr: "path is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative path",
|
||||||
|
references: func(string) []ReferenceBinding {
|
||||||
|
return []ReferenceBinding{{Selector: "party", Path: "references/party.json"}}
|
||||||
|
},
|
||||||
|
wantErr: "path must be absolute",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := validRunRequest(t)
|
||||||
|
req.References = tt.references(t.TempDir())
|
||||||
|
started := false
|
||||||
|
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||||
|
started = true
|
||||||
|
return sharedsubprocess.RunResult{}, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := runner.Run(context.Background(), req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||||
|
t.Fatalf("Run() error = %v, want containing %q", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if started {
|
||||||
|
t.Fatal("subprocess started after request validation failure")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
|
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
|
||||||
|
const envName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_KEY"
|
||||||
|
const canary = "notarius-captured-stream-canary"
|
||||||
|
const unrelated = "NARRATIO_NOTARIUS_UNRELATED_PARENT_KEY"
|
||||||
req := validRunRequest(t)
|
req := validRunRequest(t)
|
||||||
|
req.LLMAPIKey = loadTestCredential(t, envName, canary)
|
||||||
writeValidBundleAndReceipt(t, req, false)
|
writeValidBundleAndReceipt(t, req, false)
|
||||||
receiptFixture := req.ReceiptPath + ".fixture"
|
receiptFixture := req.ReceiptPath + ".fixture"
|
||||||
data, err := os.ReadFile(req.ReceiptPath)
|
data, err := os.ReadFile(req.ReceiptPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReadFile(receipt) error = %v", err)
|
t.Fatalf("ReadFile(receipt) error = %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(receiptFixture, data, 0o644); err != nil {
|
var receipt map[string]any
|
||||||
t.Fatalf("WriteFile(receipt fixture) error = %v", err)
|
if err := json.Unmarshal(data, &receipt); err != nil {
|
||||||
|
t.Fatalf("decode receipt fixture: %v", err)
|
||||||
}
|
}
|
||||||
|
receipt["credential_echo"] = canary
|
||||||
|
writeJSONFile(t, receiptFixture, receipt)
|
||||||
if err := os.Remove(req.ReceiptPath); err != nil {
|
if err := os.Remove(req.ReceiptPath); err != nil {
|
||||||
t.Fatalf("Remove(receipt) error = %v", err)
|
t.Fatalf("Remove(receipt) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -91,27 +322,55 @@ func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T)
|
|||||||
script := writeShellScript(t, `#!/bin/sh
|
script := writeShellScript(t, `#!/bin/sh
|
||||||
pwd > "$NOTARIUS_CAPTURE_DIR/working-directory"
|
pwd > "$NOTARIUS_CAPTURE_DIR/working-directory"
|
||||||
printf '%s' "$NOTARIUS_INHERITED_VALUE" > "$NOTARIUS_CAPTURE_DIR/environment"
|
printf '%s' "$NOTARIUS_INHERITED_VALUE" > "$NOTARIUS_CAPTURE_DIR/environment"
|
||||||
printf 'diagnostic stream\n' >&2
|
if [ "$`+envName+`" != '`+canary+`' ]; then
|
||||||
|
printf 'selected credential mismatch\n' >&2
|
||||||
|
exit 8
|
||||||
|
fi
|
||||||
|
if [ -n "$`+unrelated+`" ]; then
|
||||||
|
printf 'unrelated credential inherited\n' >&2
|
||||||
|
exit 9
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$`+envName+`" >&2
|
||||||
cat "$NOTARIUS_RECEIPT_FIXTURE"
|
cat "$NOTARIUS_RECEIPT_FIXTURE"
|
||||||
`)
|
`)
|
||||||
req.Binary = script
|
req.Binary = script
|
||||||
t.Setenv("NOTARIUS_CAPTURE_DIR", captureDir)
|
t.Setenv("NOTARIUS_CAPTURE_DIR", captureDir)
|
||||||
t.Setenv("NOTARIUS_INHERITED_VALUE", "inherited-value")
|
t.Setenv("NOTARIUS_INHERITED_VALUE", "inherited-value")
|
||||||
t.Setenv("NOTARIUS_RECEIPT_FIXTURE", receiptFixture)
|
t.Setenv("NOTARIUS_RECEIPT_FIXTURE", receiptFixture)
|
||||||
|
t.Setenv(unrelated, "unrelated-parent-credential")
|
||||||
|
|
||||||
if _, err := NewSubprocessRunner().Run(context.Background(), req); err != nil {
|
if _, err := NewSubprocessRunner().Run(context.Background(), req); err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n")
|
assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n")
|
||||||
assertTextFile(t, filepath.Join(captureDir, "environment"), "")
|
assertTextFile(t, filepath.Join(captureDir, "environment"), "")
|
||||||
assertTextFile(t, req.LogPath, "diagnostic stream\n")
|
assertRedactedCredentialFile(t, req.LogPath, canary)
|
||||||
receiptBytes, err := os.ReadFile(req.ReceiptPath)
|
receiptBytes, err := os.ReadFile(req.ReceiptPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ReadFile(receipt) error = %v", err)
|
t.Fatalf("ReadFile(receipt) error = %v", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(receiptBytes), "diagnostic stream") {
|
assertRedactedCredentialBytes(t, receiptBytes, canary)
|
||||||
t.Fatal("receipt contains stderr output")
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerRedactsCredentialOnProcessFailure(t *testing.T) {
|
||||||
|
const envName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_KEY"
|
||||||
|
const canary = "notarius-failure-stream-canary"
|
||||||
|
req := validRunRequest(t)
|
||||||
|
req.LLMAPIKey = loadTestCredential(t, envName, canary)
|
||||||
|
req.Binary = writeShellScript(t, `#!/bin/sh
|
||||||
|
printf '%s\n' "$`+envName+`"
|
||||||
|
printf '%s\n' "$`+envName+`" >&2
|
||||||
|
exit 7
|
||||||
|
`)
|
||||||
|
_, err := NewSubprocessRunner().Run(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want process failure")
|
||||||
}
|
}
|
||||||
|
if strings.Contains(err.Error(), canary) || !strings.Contains(err.Error(), "<redacted>") {
|
||||||
|
t.Fatal("process failure did not return redacted diagnostics")
|
||||||
|
}
|
||||||
|
assertRedactedCredentialFile(t, req.ReceiptPath, canary)
|
||||||
|
assertRedactedCredentialFile(t, req.LogPath, canary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) {
|
func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) {
|
||||||
@@ -171,8 +430,10 @@ func TestLoadReceiptValidation(t *testing.T) {
|
|||||||
valid := map[string]any{
|
valid := map[string]any{
|
||||||
"schema_version": ReceiptSchemaVersion, "run_id": "run-1", "pipeline_id": "pipeline-1",
|
"schema_version": ReceiptSchemaVersion, "run_id": "run-1", "pipeline_id": "pipeline-1",
|
||||||
"output_directory": filepath.Join(root, "outputs", "run-1"), "index_file": "index.json",
|
"output_directory": filepath.Join(root, "outputs", "run-1"), "index_file": "index.json",
|
||||||
"normalized_output_count": 1, "rejected_output_count": 0, "warning_count": 0,
|
"normalized_output_count": 1, "rejected_output_count": 0,
|
||||||
"validation_status": "approved", "future_field": true,
|
"warning_group_count": 0, "warning_occurrence_count": 0,
|
||||||
|
"diagnostic_group_count": 0, "diagnostic_occurrence_count": 0,
|
||||||
|
"diagnostics_truncated": false, "validation_status": "approved", "future_field": true,
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -183,11 +444,12 @@ func TestLoadReceiptValidation(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "unknown fields tolerated", wantOK: true},
|
{name: "unknown fields tolerated", wantOK: true},
|
||||||
{name: "malformed", raw: []byte("{")},
|
{name: "malformed", raw: []byte("{")},
|
||||||
{name: "unsupported version", mutate: func(v map[string]any) { v["schema_version"] = "notarius.run-result.v2" }},
|
{name: "unsupported version", mutate: func(v map[string]any) { v["schema_version"] = "notarius.run-result.v1" }},
|
||||||
{name: "missing field", mutate: func(v map[string]any) { delete(v, "run_id") }},
|
{name: "missing field", mutate: func(v map[string]any) { delete(v, "run_id") }},
|
||||||
{name: "pipeline mismatch", mutate: func(v map[string]any) { v["pipeline_id"] = "other" }},
|
{name: "pipeline mismatch", mutate: func(v map[string]any) { v["pipeline_id"] = "other" }},
|
||||||
{name: "relative output", mutate: func(v map[string]any) { v["output_directory"] = "run-1" }},
|
{name: "relative output", mutate: func(v map[string]any) { v["output_directory"] = "run-1" }},
|
||||||
{name: "negative count", mutate: func(v map[string]any) { v["warning_count"] = -1 }},
|
{name: "negative count", mutate: func(v map[string]any) { v["warning_group_count"] = -1 }},
|
||||||
|
{name: "invalid validation status", mutate: func(v map[string]any) { v["validation_status"] = "valid" }},
|
||||||
{
|
{
|
||||||
name: "nested index", mutate: func(v map[string]any) { v["index_file"] = "nested/index.json" },
|
name: "nested index", mutate: func(v map[string]any) { v["index_file"] = "nested/index.json" },
|
||||||
wantError: `index_file "nested/index.json"`,
|
wantError: `index_file "nested/index.json"`,
|
||||||
@@ -369,22 +631,26 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
rejectedPath := filepath.Join(root, "rejected.json")
|
rejectedPath := filepath.Join(root, "rejected.json")
|
||||||
warningsPath := filepath.Join(root, "warnings.json")
|
warningsPath := filepath.Join(root, "warnings.json")
|
||||||
|
diagnosticsPath := filepath.Join(root, "diagnostics.json")
|
||||||
writeJSONFile(t, rejectedPath, map[string]any{"rejected": []any{map[string]any{
|
writeJSONFile(t, rejectedPath, map[string]any{"rejected": []any{map[string]any{
|
||||||
"stage": "validate", "lane_id": "spells", "reason_code": "invalid", "message": "do not retain this", "future": true,
|
"stage": "validate", "lane_id": "spells", "reason_code": "invalid", "message": "do not retain this", "future": true,
|
||||||
}}, "future": true})
|
}}, "future": true})
|
||||||
writeJSONFile(t, warningsPath, map[string]any{"warnings": []any{map[string]any{
|
writeJSONFile(t, warningsPath, findingEnvelope(warningsSchemaVersion, []any{findingGroup("warning", "degradation", "bounded", "normalize", 2)}, 2, false, 0))
|
||||||
"scope": "lane:spells", "reason_code": "bounded", "message": "do not retain this", "future": true,
|
writeJSONFile(t, diagnosticsPath, findingEnvelope(diagnosticsSchemaVersion, []any{findingGroup("advisory", "data_quality", "low_confidence", "normalize", 3)}, 4, true, 1))
|
||||||
}}, "future": true})
|
|
||||||
rejections, err := loadRejections(rejectedPath)
|
rejections, err := loadRejections(rejectedPath)
|
||||||
if err != nil || len(rejections) != 1 || rejections[0].ReasonCode != "invalid" {
|
if err != nil || len(rejections) != 1 || rejections[0].ReasonCode != "invalid" {
|
||||||
t.Fatalf("loadRejections() = %#v, %v", rejections, err)
|
t.Fatalf("loadRejections() = %#v, %v", rejections, err)
|
||||||
}
|
}
|
||||||
warnings, err := loadWarnings(warningsPath)
|
warnings, err := loadWarnings(warningsPath)
|
||||||
if err != nil || len(warnings) != 1 || warnings[0].Scope != "lane:spells" {
|
if err != nil || len(warnings) != 1 || warnings[0].Category != "degradation" || warnings[0].OccurrenceCount != 2 {
|
||||||
t.Fatalf("loadWarnings() = %#v, %v", warnings, err)
|
t.Fatalf("loadWarnings() = %#v, %v", warnings, err)
|
||||||
}
|
}
|
||||||
|
diagnostics, occurrences, truncated, err := loadDiagnostics(diagnosticsPath)
|
||||||
|
if err != nil || len(diagnostics) != 1 || occurrences != 4 || !truncated || diagnostics[0].Category != "data_quality" {
|
||||||
|
t.Fatalf("loadDiagnostics() = %#v, %d, %t, %v", diagnostics, occurrences, truncated, err)
|
||||||
|
}
|
||||||
|
|
||||||
for name, path := range map[string]string{"rejections": rejectedPath, "warnings": warningsPath} {
|
for name, path := range map[string]string{"rejections": rejectedPath, "warnings": warningsPath, "diagnostics": diagnosticsPath} {
|
||||||
t.Run("malformed "+name, func(t *testing.T) {
|
t.Run("malformed "+name, func(t *testing.T) {
|
||||||
if err := os.WriteFile(path, []byte("{"), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte("{"), 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
@@ -392,8 +658,10 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
|||||||
var err error
|
var err error
|
||||||
if name == "rejections" {
|
if name == "rejections" {
|
||||||
_, err = loadRejections(path)
|
_, err = loadRejections(path)
|
||||||
} else {
|
} else if name == "warnings" {
|
||||||
_, err = loadWarnings(path)
|
_, err = loadWarnings(path)
|
||||||
|
} else {
|
||||||
|
_, _, _, err = loadDiagnostics(path)
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("summary decoder error = nil")
|
t.Fatal("summary decoder error = nil")
|
||||||
@@ -410,16 +678,23 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
|||||||
if _, err := loadRejections(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
if _, err := loadRejections(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||||
t.Fatalf("loadRejections(oversized) error = %v", err)
|
t.Fatalf("loadRejections(oversized) error = %v", err)
|
||||||
}
|
}
|
||||||
|
if _, _, _, err := loadDiagnostics(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||||
|
t.Fatalf("loadDiagnostics(oversized) error = %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
|
func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
|
||||||
req := RunRequest{PipelineID: "pipeline"}
|
req := RunRequest{PipelineID: "pipeline", References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}}
|
||||||
want := RunResult{BundleRoot: "/bundle"}
|
want := RunResult{BundleRoot: "/bundle"}
|
||||||
fake := &FakeRunner{Result: want}
|
fake := &FakeRunner{Result: want}
|
||||||
got, err := fake.Run(context.Background(), req)
|
got, err := fake.Run(context.Background(), req)
|
||||||
if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) {
|
if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) {
|
||||||
t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests)
|
t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests)
|
||||||
}
|
}
|
||||||
|
req.References[0].Path = "/references/changed.json"
|
||||||
|
if fake.Requests[0].References[0].Path != "/references/party.json" {
|
||||||
|
t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References)
|
||||||
|
}
|
||||||
|
|
||||||
wantErr := errors.New("configured failure")
|
wantErr := errors.New("configured failure")
|
||||||
fake.Err = wantErr
|
fake.Err = wantErr
|
||||||
@@ -461,6 +736,56 @@ func validRunRequest(t *testing.T) RunRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadTestCredential(t *testing.T, envName, value string) *configkit.Secret {
|
||||||
|
t.Helper()
|
||||||
|
t.Setenv(envName, value)
|
||||||
|
type testConfig struct {
|
||||||
|
Credential *configkit.Secret `yaml:"credential"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("credential.yml", []byte("credential: "+envName+"\n")), configkit.Options[testConfig]{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load test credential: %v", err)
|
||||||
|
}
|
||||||
|
return result.Config.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRedactedCredentialFile(t *testing.T, path, canary string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
assertRedactedCredentialBytes(t, data, canary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRedactedCredentialBytes(t *testing.T, data []byte, canary string) {
|
||||||
|
t.Helper()
|
||||||
|
text := string(data)
|
||||||
|
if strings.Contains(text, canary) || !strings.Contains(text, "<redacted>") {
|
||||||
|
t.Fatal("captured stream did not contain the required redaction marker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateValidationSummariesAcceptsReusedProducerWithoutAttempts(t *testing.T) {
|
||||||
|
zero := 0
|
||||||
|
summaries, err := validateValidationSummaries([]validationSummaryDocument{{
|
||||||
|
Stage: "chunk", Status: "complete", ProducerAttemptCount: &zero, TerminalAction: "accepted",
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateValidationSummaries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(summaries) != 1 || summaries[0].ProducerAttemptCount != 0 {
|
||||||
|
t.Fatalf("summaries = %#v, want one reused producer with zero attempts", summaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
negative := -1
|
||||||
|
if _, err := validateValidationSummaries([]validationSummaryDocument{{
|
||||||
|
Stage: "chunk", Status: "complete", ProducerAttemptCount: &negative, TerminalAction: "accepted",
|
||||||
|
}}); err == nil {
|
||||||
|
t.Fatal("validateValidationSummaries() negative producer attempts error = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown bool) {
|
func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
bundle := filepath.Join(req.OutputRoot, "notarius-run-1")
|
bundle := filepath.Join(req.OutputRoot, "notarius-run-1")
|
||||||
@@ -478,13 +803,12 @@ func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
rejection := map[string]any{"stage": "validate", "lane_id": "spells", "reason_code": "invalid_spell", "message": strings.Repeat("external detail", 20)}
|
rejection := map[string]any{"stage": "validate", "lane_id": "spells", "reason_code": "invalid_spell", "message": strings.Repeat("external detail", 20)}
|
||||||
warning := map[string]any{"scope": "lane:npc-registry", "reason_code": "normalized_name", "message": strings.Repeat("external warning", 20)}
|
|
||||||
if includeUnknown {
|
if includeUnknown {
|
||||||
rejection["future"] = true
|
rejection["future"] = true
|
||||||
warning["future"] = true
|
|
||||||
}
|
}
|
||||||
writeJSONFile(t, filepath.Join(bundle, "rejected.json"), map[string]any{"rejected": []any{rejection}, "future": true})
|
writeJSONFile(t, filepath.Join(bundle, "rejected.json"), map[string]any{"rejected": []any{rejection}, "future": true})
|
||||||
writeJSONFile(t, filepath.Join(bundle, "warnings.json"), map[string]any{"warnings": []any{warning}, "future": true})
|
writeJSONFile(t, filepath.Join(bundle, "warnings.json"), findingEnvelope(warningsSchemaVersion, []any{findingGroup("warning", "degradation", "normalized_name", "normalize", 2)}, 2, false, 0))
|
||||||
|
writeJSONFile(t, filepath.Join(bundle, "diagnostics.json"), findingEnvelope(diagnosticsSchemaVersion, []any{findingGroup("advisory", "data_quality", "low_confidence", "normalize", 3)}, 4, true, 1))
|
||||||
index := validIndexValue([]any{map[string]any{
|
index := validIndexValue([]any{map[string]any{
|
||||||
"lane_id": "npc-registry", "file": "lanes/npc.json", "media_type": "application/json",
|
"lane_id": "npc-registry", "file": "lanes/npc.json", "media_type": "application/json",
|
||||||
"module_key": "dnd/npc-registry", "schema_id": "notarius.dnd.npc_registry",
|
"module_key": "dnd/npc-registry", "schema_id": "notarius.dnd.npc_registry",
|
||||||
@@ -503,7 +827,13 @@ func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown boo
|
|||||||
receipt := map[string]any{
|
receipt := map[string]any{
|
||||||
"schema_version": ReceiptSchemaVersion, "run_id": "notarius-run-1", "pipeline_id": req.PipelineID,
|
"schema_version": ReceiptSchemaVersion, "run_id": "notarius-run-1", "pipeline_id": req.PipelineID,
|
||||||
"output_directory": bundle, "index_file": "index.json", "normalized_output_count": 1,
|
"output_directory": bundle, "index_file": "index.json", "normalized_output_count": 1,
|
||||||
"rejected_output_count": 1, "warning_count": 1, "validation_status": "rejected",
|
"rejected_output_count": 1, "warning_group_count": 1, "warning_occurrence_count": 2,
|
||||||
|
"diagnostic_group_count": 1, "diagnostic_occurrence_count": 4,
|
||||||
|
"diagnostics_truncated": true, "validation_status": "rejected",
|
||||||
|
"validation_summaries": []any{map[string]any{
|
||||||
|
"stage": "normalize", "lane_id": "npc-registry", "status": "complete",
|
||||||
|
"producer_attempt_count": 1, "terminal_action": "accepted",
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
if includeUnknown {
|
if includeUnknown {
|
||||||
receipt["future"] = true
|
receipt["future"] = true
|
||||||
@@ -517,7 +847,7 @@ func createBundleSkeleton(t *testing.T) string {
|
|||||||
if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll(bundle) error = %v", err)
|
t.Fatalf("MkdirAll(bundle) error = %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"manifest.json", "rejected.json", "warnings.json", "lanes/npc.json", "chunk-map.json"} {
|
for _, name := range []string{"manifest.json", "rejected.json", "warnings.json", "diagnostics.json", "lanes/npc.json", "chunk-map.json"} {
|
||||||
if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(name)), []byte("{}"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(name)), []byte("{}"), 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile(%q) error = %v", name, err)
|
t.Fatalf("WriteFile(%q) error = %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -528,10 +858,31 @@ func createBundleSkeleton(t *testing.T) string {
|
|||||||
func validIndexValue(lanes []any) map[string]any {
|
func validIndexValue(lanes []any) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"manifest_file": "manifest.json", "output_files": lanes,
|
"manifest_file": "manifest.json", "output_files": lanes,
|
||||||
"rejected_file": "rejected.json", "warnings_file": "warnings.json",
|
"rejected_file": "rejected.json", "warnings_file": "warnings.json", "diagnostics_file": "diagnostics.json",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findingGroup(disposition, category, reasonCode, origin string, occurrences int) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"disposition": disposition, "category": category, "reason_code": reasonCode,
|
||||||
|
"origin": map[string]any{"stage": origin, "lane_id": "npc-registry"}, "occurrence_count": occurrences,
|
||||||
|
"samples": []any{map[string]any{"scope": "lane:npc-registry", "message": "external detail"}},
|
||||||
|
"omitted_sample_count": occurrences - 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findingEnvelope(schema string, groups []any, occurrences int, truncated bool, unrepresented int) map[string]any {
|
||||||
|
value := map[string]any{
|
||||||
|
"schema_version": schema, "group_count": len(groups), "occurrence_count": occurrences,
|
||||||
|
"groups": groups,
|
||||||
|
}
|
||||||
|
if schema == diagnosticsSchemaVersion {
|
||||||
|
value["truncated"] = truncated
|
||||||
|
value["unrepresented_occurrence_count"] = unrepresented
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSONFile(t *testing.T, path string, value any) {
|
func writeJSONFile(t *testing.T, path string, value any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := json.Marshal(value)
|
data, err := json.Marshal(value)
|
||||||
|
|||||||
@@ -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,3 +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 {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
||||||
@@ -39,12 +38,8 @@ 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() {
|
for _, s := range plan.Stages() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
|
||||||
})
|
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
|
||||||
for _, s := range stages {
|
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
@@ -53,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,
|
||||||
@@ -63,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())
|
||||||
@@ -115,8 +111,8 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage() error = %v", err)
|
t.Fatalf("RunStage() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") {
|
if !strings.Contains(out.String(), "stage=analyze executed=1 skipped=0 force=false") {
|
||||||
t.Fatalf("output = %q, want analyze skip without force", out.String())
|
t.Fatalf("output = %q, want legacy analyze evidence rebuilt without implying force", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,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,
|
||||||
@@ -144,8 +151,8 @@ 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=1 skipped=11") {
|
if !strings.Contains(out.String(), "executed=5 skipped=9") {
|
||||||
t.Fatalf("output = %q, want all stages skipped", out.String())
|
t.Fatalf("output = %q, want extract reconsidered and legacy analyze plus delivery rebuilt", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +162,8 @@ 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() {
|
for _, s := range plan.Stages() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
|
||||||
})
|
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
|
||||||
for _, s := range stages {
|
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
@@ -172,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())
|
||||||
@@ -196,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, _ []stage.Stage, 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",
|
||||||
@@ -218,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())
|
||||||
@@ -271,13 +272,18 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
||||||
|
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"analyze", "2026-05-03"}, &stdout, &stderr)
|
code := executeWithDependencies([]string{"analyze", "2026-05-03"}, &stdout, &stderr, deps, executor)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "analyze: no pipeline config path provided and no default pipeline config found; searched:") {
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
|
}
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "analyze: no pipeline config path provided") ||
|
||||||
|
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||||
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,12 +295,8 @@ 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() {
|
for _, s := range plan.Stages() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
|
||||||
})
|
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
|
||||||
for _, s := range stages {
|
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
@@ -307,10 +309,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())
|
||||||
@@ -373,13 +376,18 @@ func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
||||||
|
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"publish", "2026-05-03"}, &stdout, &stderr)
|
code := executeWithDependencies([]string{"publish", "2026-05-03"}, &stdout, &stderr, deps, executor)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "publish: no pipeline config path provided and no default pipeline config found; searched:") {
|
if stdout.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
|
}
|
||||||
|
if got := stderr.String(); !strings.Contains(got, "publish: no pipeline config path provided") ||
|
||||||
|
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||||
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
t.Fatalf("stderr = %q, want pipeline discovery error", 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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user