Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -2,64 +2,26 @@ when:
|
|||||||
- event: tag
|
- event: tag
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
validate:
|
validate-release:
|
||||||
image: golang:1.25
|
image: golang:1.25.5
|
||||||
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.25.5
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
150
docs/cli.md
150
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, or compare resolved pipeline configuration.
|
||||||
|
|
||||||
Session subcommands:
|
Session subcommands:
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ 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>`
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@@ -49,6 +53,10 @@ 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 a declared pipeline profile. It may be supplied once;
|
||||||
|
an explicit empty or unknown value fails configuration resolution. When it is
|
||||||
|
omitted, a declared `default_profile` is used. The same selection applies to
|
||||||
|
all common-flag commands, including `regenerate-artifacts`.
|
||||||
- `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 +78,119 @@ Commands with additional positionals keep their command-specific order:
|
|||||||
|
|
||||||
## Command Reference
|
## Command Reference
|
||||||
|
|
||||||
|
### `config validate`, `config show`, `config sources`, and `config diff`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]
|
||||||
|
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]
|
||||||
|
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]
|
||||||
|
narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>]
|
||||||
|
```
|
||||||
|
|
||||||
|
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, remote
|
||||||
|
connection, or credential environment.
|
||||||
|
|
||||||
|
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 composition declarations, 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 mode/source, and digest. The
|
||||||
|
remaining tab-separated records are sorted as `path`, `role`, and `source`.
|
||||||
|
Roles distinguish root, import, profile, centralized default, campaign, party,
|
||||||
|
legacy-player, 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 diff` resolves both supplied profile names from one parsed root source
|
||||||
|
set 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
|
||||||
|
`notify`, 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;
|
||||||
- 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 session and run manifests.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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,8 +206,8 @@ Valid stage names:
|
|||||||
- `polish`
|
- `polish`
|
||||||
- `normalize`
|
- `normalize`
|
||||||
- `trim`
|
- `trim`
|
||||||
- `extract`
|
|
||||||
- `render`
|
- `render`
|
||||||
|
- `extract`
|
||||||
- `analyze`
|
- `analyze`
|
||||||
- `publish`
|
- `publish`
|
||||||
- `notify`
|
- `notify`
|
||||||
@@ -153,10 +259,19 @@ post-publish cleanup behavior.
|
|||||||
### `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`
|
||||||
|
|
||||||
@@ -252,14 +367,23 @@ and precedence.
|
|||||||
|
|
||||||
## `--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 +415,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
|
||||||
|
|||||||
194
docs/config.md
194
docs/config.md
@@ -42,6 +42,50 @@ 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`, and `narratio config
|
||||||
|
sources` use the same `--config`, `--campaign`, `--campaign-file`, and
|
||||||
|
`--profile` selection rules as pipeline commands, but do not select, discover,
|
||||||
|
or load a session. They do not read credential values or create runtime state.
|
||||||
|
`narratio config diff <left-profile> <right-profile>` uses the same pipeline and
|
||||||
|
campaign selectors, resolves each named profile independently from one parsed
|
||||||
|
root source set, 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
|
||||||
|
composition and family declarations omitted. `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; a legacy configured players file is explicitly
|
||||||
|
marked as a legacy player source. The [CLI reference](cli.md#config-validate-config-show-config-sources-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. Split stable pipeline settings
|
||||||
|
into explicit additive imports, place production/testing differences in one
|
||||||
|
selected overlay, and retain a production `default_profile`. Convert campaign
|
||||||
|
rosters to [canonical party input](integrations/party.md), remove a separate
|
||||||
|
`players_file`, then express character work as families. Inspect the result
|
||||||
|
with `config validate`, `config show`, and `config sources`; use `config diff`
|
||||||
|
to review profiles before running a session. Unversioned parties and their
|
||||||
|
`players_file` remain a clearly bounded legacy compatibility path.
|
||||||
|
|
||||||
### 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
|
||||||
@@ -55,19 +99,100 @@ remote state with an unsafe legacy identity must be migrated before use.
|
|||||||
|
|
||||||
- YAML decode is strict (`KnownFields(true)`) and accepts exactly one document:
|
- YAML decode is strict (`KnownFields(true)`) and accepts exactly one document:
|
||||||
unknown fields or trailing documents fail load.
|
unknown fields or trailing documents fail load.
|
||||||
|
- A pipeline file may explicitly import additive YAML fragments through the
|
||||||
|
root-only `composition.imports` list. Imported files contribute fields to one
|
||||||
|
logical pipeline document; they do not override fields supplied by the root
|
||||||
|
or another import.
|
||||||
|
- A root pipeline may declare named profiles. Exactly one profile is selected
|
||||||
|
by an option-aware caller or by `composition.default_profile`; a caller's
|
||||||
|
explicit selection takes precedence. Declaring profiles without either form
|
||||||
|
of selection is an error.
|
||||||
- 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.
|
||||||
- Pipeline defaults are applied before validation.
|
- Pipeline defaults are applied before validation.
|
||||||
- Campaign and session identities must agree.
|
- Campaign and session identities must agree.
|
||||||
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
||||||
`players_file`, `party_file`) and the optional `spell_catalog_file` resolve
|
`party_file`) and the optional `spell_catalog_file` resolve from session
|
||||||
from session overrides when provided, otherwise from campaign defaults. An
|
overrides when provided, otherwise from campaign defaults. An empty or
|
||||||
empty or omitted session spell-catalog value inherits the campaign value.
|
omitted session spell-catalog value inherits the campaign value.
|
||||||
|
- `party_file` is classified when pipeline and campaign configuration are
|
||||||
|
combined. A versioned [canonical party](integrations/party.md) is
|
||||||
|
campaign-owned, derives the players input internally, and forbids both a
|
||||||
|
separate `players_file` and a session `party_file` override. An unversioned
|
||||||
|
party remains a bounded legacy input and requires `players_file`; its normal
|
||||||
|
campaign/session overrides continue to apply.
|
||||||
- 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 composition
|
||||||
|
|
||||||
|
Large pipeline configurations may be split into explicitly named fragments and
|
||||||
|
may declare one overlay per selectable profile:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
composition:
|
||||||
|
imports:
|
||||||
|
- config/storage.yml
|
||||||
|
- config/integrations.yaml
|
||||||
|
default_profile: production
|
||||||
|
profiles:
|
||||||
|
production:
|
||||||
|
overlay: profiles/production.yml
|
||||||
|
testing:
|
||||||
|
overlay: profiles/testing.yml
|
||||||
|
|
||||||
|
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. Only the root pipeline may
|
||||||
|
contain `composition`; nested composition is rejected.
|
||||||
|
|
||||||
|
Composition 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. The assembled YAML is
|
||||||
|
then decoded against the normal strict pipeline schema and defaults are applied
|
||||||
|
once.
|
||||||
|
|
||||||
|
Profile names are case-sensitive, non-empty, trimmed, and cannot contain
|
||||||
|
control characters. If `profiles` is present, it must contain at least one
|
||||||
|
entry and every entry must contain only an `overlay` path. An explicit profile
|
||||||
|
selection overrides `default_profile`; unknown and explicitly empty selections
|
||||||
|
fail. Narratio never selects the first profile implicitly and does not read a
|
||||||
|
profile selection from the environment.
|
||||||
|
|
||||||
|
Every declared overlay is resolved relative to the root pipeline directory and
|
||||||
|
must satisfy the same confined regular-YAML-file rules as an import. Narratio
|
||||||
|
parses every declared overlay even when it is not selected, then applies only
|
||||||
|
the selected one. Maps merge recursively, overlay scalars replace base scalars,
|
||||||
|
and overlay lists replace base lists completely. Explicit `false`, zero, empty
|
||||||
|
lists, and empty maps remain meaningful. YAML null cannot delete a value, and
|
||||||
|
kind changes are rejected. Profiles cannot inherit from or stack with other
|
||||||
|
profiles, and overlays cannot import files or declare profiles.
|
||||||
|
|
||||||
|
After composition, Narratio strictly decodes the result, applies centralized
|
||||||
|
defaults once, resolves ordinary paths, and computes a deterministic effective
|
||||||
|
configuration digest. The digest represents the normalized, secret-free
|
||||||
|
runtime pipeline mapping; it excludes composition declarations, 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`
|
||||||
@@ -155,6 +280,9 @@ Rules:
|
|||||||
|
|
||||||
| Field | Type | Required | Default / Rule |
|
| Field | Type | Required | Default / Rule |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| `composition.imports[]` | list of strings | No | explicit additive pipeline fragments relative to the root pipeline directory; `.yml` or `.yaml` regular files only |
|
||||||
|
| `composition.default_profile` | string | Conditional | selected when profiles exist and no caller explicitly selects one; must name a declared profile |
|
||||||
|
| `composition.profiles.<name>.overlay` | string | Conditional | required for every declared profile; one confined `.yml` or `.yaml` overlay relative to the root pipeline directory |
|
||||||
| `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` |
|
||||||
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
|
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
|
||||||
@@ -243,6 +371,7 @@ Rules:
|
|||||||
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
||||||
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
||||||
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
||||||
|
| `pipeline.scriptorium.artifact_families` | map | No | empty; expands one ordinary artifact per canonical party character |
|
||||||
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
|
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
|
||||||
|
|
||||||
### Notarius Reference Bindings
|
### Notarius Reference Bindings
|
||||||
@@ -310,7 +439,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 | must reference configured artifact keys; no self-reference; configured graph must be acyclic |
|
||||||
| `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 |
|
||||||
@@ -323,12 +452,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 `depends_on`: a current prerequisite is reused, while a
|
||||||
configured with valid executable fields, and their configured dependencies must
|
stale, missing, failed, or legacy 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>`:
|
||||||
|
|
||||||
@@ -341,6 +471,42 @@ 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.
|
||||||
|
|
||||||
|
### 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. A legacy party cannot be used for a family.
|
||||||
|
|
||||||
|
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>`. The referenced family must be named in
|
||||||
|
that family's `member_dependencies`; resolution rewrites the source to the
|
||||||
|
corresponding ordinary `narratio.artifact.<family>_<character_id>` source.
|
||||||
|
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
|
||||||
@@ -358,8 +524,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.players_file` | string | Conditional | required only with an unversioned legacy `party_file`; forbidden for a canonical party |
|
||||||
| `inputs.party_file` | string | Yes | stable input default |
|
| `inputs.party_file` | string | Yes | stable campaign party source; relative paths resolve from `campaign.yml` |
|
||||||
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
||||||
|
|
||||||
### Session
|
### Session
|
||||||
@@ -374,8 +540,8 @@ 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.players_file` | string | No | legacy-party override; forbidden for a canonical party |
|
||||||
| `inputs.party_file` | string | No | overrides campaign stable input |
|
| `inputs.party_file` | string | No | legacy-party override; forbidden for a canonical campaign party |
|
||||||
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
||||||
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
||||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||||
|
|||||||
@@ -25,6 +25,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,9 +47,9 @@ 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
|
||||||
|
|||||||
@@ -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,6 +41,12 @@ selector, as direct argument-vector entries without shell interpretation. A CLI
|
|||||||
binding takes precedence over a matching external path in Notarius
|
binding takes precedence over a matching external path in Notarius
|
||||||
configuration. Narratio never emits `--without-reference`.
|
configuration. Narratio never emits `--without-reference`.
|
||||||
|
|
||||||
|
For canonical party configuration, the `party` binding is the unchanged,
|
||||||
|
validated authored roster and the `players` binding is its generated
|
||||||
|
projection. Both retain their established `narratio.input.party` and
|
||||||
|
`narratio.input.players` source IDs, and both are resolved from the prepared
|
||||||
|
manifest rather than from campaign configuration at extraction time.
|
||||||
|
|
||||||
The maintained D&D boundary binds only the four campaign-owned external slots:
|
The maintained D&D boundary binds only the four campaign-owned external slots:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
73
docs/integrations/party.md
Normal file
73
docs/integrations/party.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Canonical Party Input
|
||||||
|
|
||||||
|
`party.yml` is a campaign-owned roster input. Narratio recognizes the
|
||||||
|
versioned `narratio.party.v1` document below when it 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. A
|
||||||
|
wrong or malformed `schema_version` is an error.
|
||||||
|
|
||||||
|
## Legacy migration boundary
|
||||||
|
|
||||||
|
An unversioned party input remains supported only as opaque legacy reference
|
||||||
|
material while campaigns migrate. It requires a separate `players_file` and
|
||||||
|
retains the existing session override behavior. It cannot be mixed with a
|
||||||
|
canonical party: canonical campaigns must omit `players_file`, and sessions
|
||||||
|
must not override their party or players inputs.
|
||||||
|
|
||||||
|
Use the canonical document for new campaigns. 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,16 +35,29 @@ Adapters do not own:
|
|||||||
|
|
||||||
## Default Wiring
|
## Default Wiring
|
||||||
|
|
||||||
`internal/app/runner.go` initializes default adapters when not injected:
|
`internal/app/runner.go` initializes default adapters when not injected 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.
|
||||||
|
|
||||||
|
Remote publish locks are loaded only for a selected, enabled publish that
|
||||||
|
uploads a run. Shared session lifecycle setup still applies to every selected
|
||||||
|
range, but an unselected integration is neither initialized nor validated by
|
||||||
|
runner composition. Each selected stage retains its own fail-fast configuration
|
||||||
|
and input validation.
|
||||||
|
|
||||||
|
`session plan` is outside production adapter composition. It performs
|
||||||
|
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
|
Notarius is composed only when extraction is enabled; the extract stage owns
|
||||||
prepared reference resolution, receipt, bundle, and configured-lane policy.
|
prepared reference resolution, receipt, bundle, and configured-lane policy.
|
||||||
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ ID.
|
|||||||
Prepared stable source IDs are `narratio.input.players`,
|
Prepared stable source IDs are `narratio.input.players`,
|
||||||
`narratio.input.party`, `narratio.input.glossary`, and
|
`narratio.input.party`, `narratio.input.glossary`, and
|
||||||
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest
|
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest
|
||||||
kind and prepared filename vocabulary.
|
kind and prepared filename vocabulary. Canonical party mode preserves the
|
||||||
|
party source bytes in the party record and supplies the players record from the
|
||||||
|
deterministic `derived_from_party` projection; both remain ordinary prepared
|
||||||
|
source IDs for consumers.
|
||||||
|
|
||||||
## Runtime Catalog
|
## Runtime Catalog
|
||||||
|
|
||||||
@@ -47,22 +50,40 @@ kind and prepared filename vocabulary.
|
|||||||
|
|
||||||
- `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; the analyze work planner closes those targets over their
|
||||||
may be reused from a canonical non-empty file, while an executable definition
|
configured prerequisite graph. Availability is separate from executability.
|
||||||
is generated by analyze. Extraction entries are registered from configuration
|
Configuration may normalize a family selection into its concrete generated
|
||||||
and become available only after compatible extraction evidence is hydrated.
|
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 and a legacy aggregate
|
||||||
|
analyze output are unavailable.
|
||||||
|
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`
|
||||||
|
|
||||||
@@ -75,7 +96,21 @@ Built-ins:
|
|||||||
|
|
||||||
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, legacy or 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.*`):
|
Prepared stable sources (`narratio.input.*`):
|
||||||
|
|
||||||
|
|||||||
180
docs/internal/configuration.md
Normal file
180
docs/internal/configuration.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# 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. Parse the root YAML into a presence-aware composition tree. The tree retains
|
||||||
|
source names, full field paths, node kinds, declaration order, and explicit
|
||||||
|
zero, false, empty-map, and empty-list values.
|
||||||
|
2. Remove the root-only `composition` envelope and validate its explicit
|
||||||
|
`imports`, `default_profile`, and named `profiles` declarations. A load
|
||||||
|
option retains the difference between omitted and explicitly empty profile
|
||||||
|
selection.
|
||||||
|
3. Open each import relative to the root pipeline directory through the
|
||||||
|
confined regular-file boundary. Imports must use a `.yml` or `.yaml`
|
||||||
|
extension and cannot traverse, use symlinks, repeat a file, import the root,
|
||||||
|
or contain another composition envelope.
|
||||||
|
4. Resolve and structurally parse every declared profile overlay through the
|
||||||
|
same confined regular-file boundary. Missing or malformed unselected
|
||||||
|
overlays fail the load. Overlays cannot contain a composition envelope.
|
||||||
|
5. Additively merge the root body and imports. Distinct map leaves compose;
|
||||||
|
repeated scalar or list paths and node-kind disagreements are conflicts.
|
||||||
|
6. Select exactly one declared profile from an explicit option or the default,
|
||||||
|
then recursively merge its overlay. Overlay leaves replace base leaves,
|
||||||
|
lists are atomic replacements, and null or kind changes fail.
|
||||||
|
7. Emit deterministic canonical YAML and strictly decode it into
|
||||||
|
`PipelineConfig`.
|
||||||
|
8. Apply pipeline defaults once, resolve ordinary relative pipeline paths from
|
||||||
|
the root pipeline file, and digest the normalized effective mapping.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The application also has a separate read-only inspection resolver for `config
|
||||||
|
validate`, `config show`, and `config sources`. It uses the same production root/profile and
|
||||||
|
campaign selection functions, but never routes through session discovery,
|
||||||
|
remote-session download, secret loading, 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.
|
||||||
|
|
||||||
|
`MarshalEffectivePipeline` is the configuration-owned projection for `config
|
||||||
|
show`. It serializes the typed, defaulted effective mapping through the
|
||||||
|
deterministic composition renderer, then removes resolution-only artifact
|
||||||
|
family declarations. The result contains no composition envelope or private
|
||||||
|
provenance fields and 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 retained during composition 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, while legacy players retain a
|
||||||
|
dedicated legacy-player role. The application command only joins these sorted
|
||||||
|
records with selection metadata and never reparses configuration files.
|
||||||
|
|
||||||
|
`config diff` uses a paired profile loader that parses the root, imports, and
|
||||||
|
declared overlays once, then clones the additive base before independently
|
||||||
|
selecting, decoding, defaulting, and finalizing each profile. 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 into sorted logical paths;
|
||||||
|
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 also reads and classifies the campaign-owned
|
||||||
|
party source through `ParseParty`. A canonical party retains its raw bytes and
|
||||||
|
normalized roster in runtime-only `ResolvedParty` provenance, while a legacy
|
||||||
|
party remains opaque. Canonical resolution creates a virtual
|
||||||
|
`derived_from_party` players input and rejects competing campaign or session
|
||||||
|
players files and session party overrides. The compact legacy compatibility
|
||||||
|
path resolves the effective campaign/session party and players files together.
|
||||||
|
|
||||||
|
## Canonical Party Domain
|
||||||
|
|
||||||
|
`ParseParty` is the package-owned boundary for classifying a party source.
|
||||||
|
When a top-level `schema_version` is present, it strictly validates the
|
||||||
|
`narratio.party.v1` contract into ordered character domain values. 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 versioned players-only
|
||||||
|
projection.
|
||||||
|
|
||||||
|
An unversioned source is classified by the small legacy compatibility boundary
|
||||||
|
in `party_legacy.go`; it deliberately exposes no parsed roster information.
|
||||||
|
That boundary exists solely to isolate removable compatibility behavior from
|
||||||
|
the canonical parser.
|
||||||
|
|
||||||
|
## Diagnostics And Runtime Metadata
|
||||||
|
|
||||||
|
Syntax, duplicate-key, composition, conflict, 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, contributing sources, 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 deterministic canonical YAML produced from
|
||||||
|
the defaulted `PipelineConfig`. 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 composition and resolution metadata
|
||||||
|
are private, the digest excludes source layout, profile name, and ownership.
|
||||||
|
Configuration stores environment variable names rather than resolving raw
|
||||||
|
credentials, so raw secret values are neither loaded nor hashed.
|
||||||
|
`recomputePipelineEffectiveDigest` is the single package-owned refresh point
|
||||||
|
for later runtime expansion.
|
||||||
|
|
||||||
|
## Test Surfaces
|
||||||
|
|
||||||
|
`composition_test.go` protects the presence and merge algebra independently of
|
||||||
|
the public schema. `pipeline_composition_test.go` exercises explicit imports,
|
||||||
|
confinement, conflicts, strict decoding, metadata, and root-relative path
|
||||||
|
behavior through `LoadPipeline`. `pipeline_profiles_test.go` covers selection,
|
||||||
|
all-overlay validation, overlay behavior, provenance, option propagation, and
|
||||||
|
effective-digest stability. 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 party schema, domain invariants, and
|
||||||
|
deterministic players projection without involving campaign or runtime wiring.
|
||||||
|
`party_resolution_test.go` protects campaign-owned party loading, canonical
|
||||||
|
input restrictions, legacy overrides, 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.
|
||||||
@@ -25,6 +25,12 @@ 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.
|
||||||
|
|
||||||
|
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,11 +41,90 @@ The model admits these stage states:
|
|||||||
- `stale`
|
- `stale`
|
||||||
- `interrupted`
|
- `interrupted`
|
||||||
|
|
||||||
|
### Analyze-owned artifact state
|
||||||
|
|
||||||
|
The `analyze` stage record may carry `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, while an absent version is legacy aggregate-only state and provides
|
||||||
|
no current configured-artifact evidence.
|
||||||
|
|
||||||
|
Each analyze artifact record has one disposition:
|
||||||
|
|
||||||
|
- `current`: the configured artifact is available and carries a versioned
|
||||||
|
fingerprint plus a complete output record and separate output size;
|
||||||
|
- `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.
|
||||||
|
The corresponding collection on an invocation's `analyze` stage record is an
|
||||||
|
audit of only the artifacts evaluated or attempted by that run. These records
|
||||||
|
remain analyze-owned data inside the fixed stage; they are not dynamic stages
|
||||||
|
or generic subtasks.
|
||||||
|
|
||||||
|
The stage result contract has one analyze-specific projection boundary. On
|
||||||
|
success, the runner validates and deep-copies the complete reconciled session
|
||||||
|
collection and the invocation subset. Aggregate session outputs are rebuilt in
|
||||||
|
configured-key order from current session records only; invocation outputs are
|
||||||
|
limited to current records produced by that invocation's run ID. Ordinary
|
||||||
|
stage outputs cannot accompany this projection, so there is one source of
|
||||||
|
artifact authority.
|
||||||
|
|
||||||
|
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 projection.
|
||||||
|
|
||||||
|
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;
|
||||||
|
the successful projection then restores the aggregate status.
|
||||||
|
|
||||||
|
Analyze may return a projection together with an error. That restricted result
|
||||||
|
cannot carry ordinary outputs, skip state, aggregate logs, generated configs,
|
||||||
|
or metadata. The runner persists only the validated per-artifact collections,
|
||||||
|
then marks the aggregate analyze and run state failed and invalidates delivery
|
||||||
|
dependents conservatively. Unrelated current records survive because the
|
||||||
|
session projection is complete. A malformed projection is not applied, and a
|
||||||
|
failed session projection save restores the prior per-artifact authority before
|
||||||
|
terminal failure persistence.
|
||||||
|
|
||||||
|
The incremental executor constructs this restricted projection at each
|
||||||
|
scheduled artifact boundary. The active record is failed without output,
|
||||||
|
current transitive dependents are stale, unrelated current records survive, and
|
||||||
|
only earlier validated and materialized completions remain current in the
|
||||||
|
invocation subset. Session failure state is persisted before invocation failure
|
||||||
|
state. If either terminal save fails, its persistence error is joined with the
|
||||||
|
original adapter, validation, or filesystem cause; a failed projection save
|
||||||
|
does not turn incidental canonical bytes into manifest authority.
|
||||||
|
|
||||||
## Run Manifest
|
## Run Manifest
|
||||||
|
|
||||||
`manifest.RunManifest` is created for each invocation and records:
|
`manifest.RunManifest` is created for each invocation and records:
|
||||||
|
|
||||||
- invocation identity and `force` flag
|
- invocation identity and `force` flag
|
||||||
|
- the selected profile (when any) and secret-free effective configuration digest
|
||||||
- requested stages
|
- requested stages
|
||||||
- per-stage action (`run` or `skip`)
|
- per-stage action (`run` or `skip`)
|
||||||
- per-stage status
|
- per-stage status
|
||||||
@@ -83,43 +168,114 @@ durable; callers must reload it before retrying.
|
|||||||
|
|
||||||
The application runner marks an executing stage running and then succeeded or
|
The application runner marks an executing stage running and then succeeded or
|
||||||
failed in both manifests, persisting each transition. On success it records
|
failed in both manifests, persisting each transition. On success it records
|
||||||
outputs, logs, generated configuration references, and metadata. Artifact
|
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, and notify.
|
||||||
|
|
||||||
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. Invocation run manifests remain immutable audit
|
||||||
records of their own outcomes.
|
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 both manifests, clears older outputs for the
|
||||||
session-stage record along with older logs, generated configuration references,
|
session-stage record along with older logs, generated configuration references,
|
||||||
and metadata, then applies any bounded details from the current skip and
|
and metadata, then applies any 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 invocation run manifest records
|
||||||
the `skip` action and reason. The session manifest deliberately retains its
|
the `skip` action and reason. The session manifest deliberately retains its
|
||||||
existing succeeded record because it remains the cross-invocation progress
|
existing succeeded record because it remains the cross-invocation progress
|
||||||
authority. Stages with a resume validator, currently extraction, may reject an
|
authority. If a stage supplies semantic configuration evidence, reuse first
|
||||||
otherwise eligible skip when the recorded durable result is obsolete; the
|
requires the persisted positive schema version and lowercase SHA-256 digest to
|
||||||
runner marks it stale and executes it.
|
match the current resolved stage semantics. Missing legacy evidence, malformed
|
||||||
|
evidence, or a mismatch makes the stage and its fixed transitive dependents
|
||||||
|
stale. The invocation skip copies the matched fingerprint for provenance but
|
||||||
|
does not rewrite session authority. The existing stage-specific resume
|
||||||
|
validator runs only after this semantic check succeeds; both checks are
|
||||||
|
required. Extraction and analyze 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.
|
||||||
|
|
||||||
|
Implemented reuse coverage is deliberately split between aggregate semantic
|
||||||
|
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) |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
Session manifest is the authoritative stage-progress ledger across invocations.
|
Session manifest is the authoritative stage-progress ledger across invocations.
|
||||||
Run manifest is invocation-scoped audit state.
|
Run manifest is invocation-scoped audit state.
|
||||||
|
|
||||||
|
Both manifests retain 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 same
|
||||||
|
comparison and invalidation rules to a cloned manifest. It predicts the runner
|
||||||
|
decision without persisting session or invocation state. The shared helper
|
||||||
|
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-manifest 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
|
||||||
@@ -136,10 +292,13 @@ 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; invocation reuse copies evidence without replacing session state.
|
||||||
- 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.
|
- run manifest does not replace session manifest as progress authority.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ progress and artifact services resolve durable inputs and outputs.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 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, cleanup gates, and user-facing reporting. |
|
||||||
| Configuration | `internal/config` | Strict YAML loading, discovery, defaults, normalization, session templating, and validation. |
|
| Configuration | [`internal/config`](configuration.md) | Presence-aware root/import/profile composition, 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, invocation audit state, stage transitions, validation, and atomic persistence. |
|
||||||
@@ -43,6 +43,16 @@ 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).
|
||||||
|
|
||||||
|
Pipeline execution and `session plan` share the same inclusive contiguous-range
|
||||||
|
model. Planning clones session state and applies selected-stage transitions and
|
||||||
|
resume validation in memory; it does not create invocation state or initialize
|
||||||
|
stage-execution adapters. 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,20 +63,30 @@ 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)
|
||||||
|
|
||||||
`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`, and `notify`.
|
||||||
|
|
||||||
## Focused Documentation
|
## Focused Documentation
|
||||||
|
|
||||||
|
- [Configuration Internals](configuration.md): pipeline composition, import
|
||||||
|
confinement, 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,
|
||||||
@@ -84,8 +104,8 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
## 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
|
||||||
- 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
|
||||||
@@ -21,30 +25,104 @@ Supported source families:
|
|||||||
|
|
||||||
## 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 with no outputs and records explanatory
|
||||||
explicit self-skip: both manifests record success, satisfy publish's
|
metadata. This is not an explicit self-skip: both manifests record success,
|
||||||
prerequisite, and an ordinary later run reuses the result until forced.
|
satisfy 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.
|
- resolves required/optional inputs per artifact source definition into an
|
||||||
- omits an unavailable optional input; an unavailable required input fails.
|
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
|
- resolves prepared stable input sources through the shared manifest-authoritative
|
||||||
identity resolver; it does not accept incidental files or fall back to
|
identity resolver; it does not accept incidental files or fall back to
|
||||||
campaign/session source paths.
|
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, legacy, 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
|
||||||
|
projected record collection. Valid unrelated configured records survive the
|
||||||
|
projection, removed records are omitted, and legacy files never become
|
||||||
|
current without 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; 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.
|
||||||
|
- 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 projection.
|
||||||
|
- 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.
|
||||||
|
- reports all evaluated targets and prerequisites in invocation state. The
|
||||||
|
runner reconstructs aggregate session outputs from every current session
|
||||||
|
record and invocation outputs from only records produced by the current run.
|
||||||
|
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.
|
||||||
@@ -59,17 +137,49 @@ Supported source families:
|
|||||||
guidance.
|
guidance.
|
||||||
- dependency cycles or unavailable required dependencies fail.
|
- dependency cycles or unavailable required dependencies fail.
|
||||||
- adapter validation failures fail stage.
|
- adapter validation failures fail stage.
|
||||||
|
- a scheduled artifact failure returns the restricted analyze-state projection
|
||||||
|
with the active artifact marked `failed`, a bounded error, and no output
|
||||||
|
authority. Current transitive dependents become stale without execution.
|
||||||
|
- earlier artifacts from the invocation remain current only after their
|
||||||
|
run-local output passed validation and canonical materialization. They remain
|
||||||
|
in invocation history; unattempted later artifacts do not appear there.
|
||||||
|
- unrelated current records survive a partial failure. Old canonical bytes for
|
||||||
|
the failed artifact and newly materialized bytes whose projection cannot be
|
||||||
|
persisted are incidental, not current evidence.
|
||||||
|
- the runner persists a valid partial projection before it marks aggregate
|
||||||
|
analyze failed and invalidates publish and notify through the application
|
||||||
|
dependency relation. Projection-persistence errors retain the last durable
|
||||||
|
per-artifact authority and are joined with the original failure context.
|
||||||
|
|
||||||
## 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 records.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## 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_input_identity.go`, `internal/stage/analyze_test.go`,
|
||||||
|
`internal/stage/analyze_input_identity_test.go`,
|
||||||
|
`internal/stage/analyze_fingerprint.go`,
|
||||||
|
`internal/stage/analyze_fingerprint_test.go`,
|
||||||
|
`internal/stage/analyze_reconciliation.go`, and
|
||||||
|
`internal/stage/analyze_reconciliation_test.go`,
|
||||||
|
`internal/stage/analyze_plan.go`, `internal/stage/analyze_plan_test.go`, and
|
||||||
|
`internal/stage/analyze_incremental_execution_test.go`, and
|
||||||
|
`internal/stage/analyze_failure_test.go`,
|
||||||
|
`internal/stage/analyze_resume.go`, and `internal/stage/analyze_resume_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.
|
||||||
@@ -21,8 +21,8 @@ procedures belong in [Operations](../operations.md).
|
|||||||
manifest-authoritative identity resolver before creating run-local output;
|
manifest-authoritative identity resolver before creating run-local output;
|
||||||
3. streams each verified reference into an invocation-local snapshot and
|
3. streams each verified reference into an invocation-local snapshot and
|
||||||
rejects any source change observed while copying;
|
rejects any source change observed while copying;
|
||||||
4. fingerprints the Notarius invocation contract, including sorted reference
|
4. fingerprints the byte- and provenance-bearing Notarius invocation evidence,
|
||||||
identities;
|
including sorted reference identities;
|
||||||
5. creates a run-local staging directory and invokes the injected
|
5. creates a run-local staging directory and invokes the injected
|
||||||
`notarius.Runner`;
|
`notarius.Runner`;
|
||||||
6. revalidates the reference snapshots, then validates the v2 successful
|
6. revalidates the reference snapshots, then validates the v2 successful
|
||||||
@@ -50,19 +50,26 @@ Validation completes before
|
|||||||
promotion, so a rejected result cannot expose a partial durable bundle.
|
promotion, so a rejected result cannot expose a partial durable bundle.
|
||||||
|
|
||||||
Any executed extraction outcome that replaces a different effective outcome
|
Any executed extraction outcome that replaces a different effective outcome
|
||||||
marks succeeded 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, the current
|
canonical `narratio.extraction.<key>` output identity. It excludes executable,
|
||||||
direct trimmed-transcript identity, and sorted prepared-reference identities.
|
timeout, working directory, config path, and private Notarius config contents.
|
||||||
The same reference helper and transcript identity are resolved again for
|
|
||||||
artifact evidence, so changing the current transcript bytes or producer
|
`internal/stage/extract_resume.go` then permits a skip only when the existing
|
||||||
identity makes the prior extraction obsolete.
|
stage record still matches the current byte- and provenance-bearing invocation
|
||||||
|
evidence. That evidence covers the current direct trimmed-transcript identity,
|
||||||
|
sorted prepared-reference identities, pipeline identity, and configured output
|
||||||
|
contracts. The same reference helper and transcript identity are resolved again
|
||||||
|
for artifact evidence, so changing current transcript bytes, reference bytes,
|
||||||
|
or producer identity makes the prior extraction obsolete. Operational runner
|
||||||
|
settings do not invalidate otherwise current durable evidence.
|
||||||
|
|
||||||
A valid prepared-reference change makes extraction non-resumable. Missing,
|
A valid prepared-reference change makes extraction non-resumable. Missing,
|
||||||
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
|
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
|
||||||
@@ -75,9 +82,10 @@ 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
|
||||||
|
|
||||||
@@ -100,7 +108,8 @@ available for audit and recovery.
|
|||||||
|
|
||||||
- Stage execution, selection, and resume validation: `internal/stage/extract.go`,
|
- Stage execution, selection, and resume validation: `internal/stage/extract.go`,
|
||||||
`internal/stage/extract_resume.go`,
|
`internal/stage/extract_resume.go`,
|
||||||
`internal/stage/extract_test.go`
|
`internal/stage/extract_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_delivery.go`
|
||||||
- Subprocess boundary: `internal/adapters/notarius/subprocess.go`,
|
- Subprocess boundary: `internal/adapters/notarius/subprocess.go`,
|
||||||
`internal/adapters/notarius/subprocess_test.go`
|
`internal/adapters/notarius/subprocess_test.go`
|
||||||
- Catalog hydration: `internal/artifacts/extraction_catalog.go`,
|
- Catalog hydration: `internal/artifacts/extraction_catalog.go`,
|
||||||
|
|||||||
@@ -29,9 +29,23 @@ Normalize raw transcript inputs and merge into base transcript via Seriatim.
|
|||||||
- base transcript must validate before stage success.
|
- base transcript must validate before stage success.
|
||||||
- report output is config-gated.
|
- report output is config-gated.
|
||||||
|
|
||||||
|
## Resume Evidence
|
||||||
|
|
||||||
|
Merge records a versioned semantic-configuration fingerprint for the Seriatim
|
||||||
|
merge operation, output schema, coalesce gap, and every configured advanced
|
||||||
|
merge transformation. A change reruns merge and stales only its fixed
|
||||||
|
descendants; prepare and transcribe remain reusable. Binary path, timeout,
|
||||||
|
report emission, logs, and diagnostic retention are operational exclusions.
|
||||||
|
|
||||||
|
Configuration or resources loaded privately inside Seriatim are outside
|
||||||
|
Narratio's observable contract and require `--force` when changed. An existing
|
||||||
|
successful merge record without evidence reruns once when selected.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
||||||
- [Configuration](../config.md#pipeline) owns operator-selected Seriatim values.
|
- [Configuration](../config.md#pipeline) owns operator-selected Seriatim values.
|
||||||
- Implementation and tests: `internal/stage/merge.go`,
|
- Implementation and tests: `internal/stage/merge.go`,
|
||||||
`internal/stage/merge_test.go`
|
`internal/stage/merge_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_initial.go`, and
|
||||||
|
`internal/stage/semantic_contracts_initial_test.go`
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -28,10 +28,26 @@ 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 credential environment name are operational and do not invalidate a
|
||||||
|
successful result.
|
||||||
|
|
||||||
|
Narratio can fingerprint a selected model, module, or configuration identifier,
|
||||||
|
but it cannot inspect content that Audita privately resolves behind that stable
|
||||||
|
identifier. Force `polish` after changing such private content without changing
|
||||||
|
its identifier.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Audita](../integrations/audita.md) owns subprocess, validation, and failure
|
- [Audita](../integrations/audita.md) owns subprocess, validation, and failure
|
||||||
semantics.
|
semantics.
|
||||||
- [Configuration](../config.md#pipeline) owns operator-selected Audita values.
|
- [Configuration](../config.md#pipeline) owns operator-selected Audita values.
|
||||||
- Implementation and tests: `internal/stage/polish.go`,
|
- Implementation and tests: `internal/stage/polish.go`,
|
||||||
`internal/stage/polish_test.go`
|
`internal/stage/polish_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_refinement.go`
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
- materializes a configured spell catalog with checksum and provenance, or
|
- materializes a configured spell catalog with checksum and provenance, or
|
||||||
safely removes an obsolete canonical spell catalog and its manifest record
|
safely removes an obsolete canonical spell catalog and its manifest record
|
||||||
when the effective input is omitted.
|
when the effective input is omitted.
|
||||||
|
- in canonical party mode, copies the validated raw party bytes unchanged and
|
||||||
|
deterministically generates the prepared players projection; legacy mode
|
||||||
|
continues to copy its opaque party and explicit players sources.
|
||||||
- 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;
|
||||||
@@ -57,6 +60,26 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches.
|
|||||||
- managed `previous/` state represents only the current requirement set.
|
- managed `previous/` state represents only the current requirement set.
|
||||||
- `manifest.inputs` ordering is deterministic (`kind`, `path`).
|
- `manifest.inputs` ordering is deterministic (`kind`, `path`).
|
||||||
|
|
||||||
|
## Resume Evidence
|
||||||
|
|
||||||
|
Prepare records a versioned semantic-configuration fingerprint for the
|
||||||
|
resolved campaign/session selection, local-versus-S3 audio mode and canonical
|
||||||
|
audio names, stable-input ownership/presence, previous-session identity, and
|
||||||
|
the party mode plus canonical players projection version, and the effective
|
||||||
|
previous-artifact requirement set. A change reruns prepare and
|
||||||
|
stales its fixed descendants. Existing successful records without this
|
||||||
|
evidence rerun once when selected.
|
||||||
|
|
||||||
|
Workspace, spool, and cache placement and absolute source relocation are not
|
||||||
|
semantic when logical selection, canonical names, and bytes are equivalent.
|
||||||
|
The fingerprint deliberately does not read or rehash large audio. Prepared
|
||||||
|
input checksums remain the content provenance. Before reusing success, prepare
|
||||||
|
validates every durable prepared copy and compares current stable-input bytes,
|
||||||
|
canonical party and derived-player bytes, local audio membership/checksums, or
|
||||||
|
S3 key/size/entity-tag identity with that provenance. Source relocation with
|
||||||
|
equivalent names and bytes remains reusable; changed or unavailable evidence
|
||||||
|
causes a normal prepare rerun.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Configuration](../config.md) owns audio selection, stable input fields, and
|
- [Configuration](../config.md) owns audio selection, stable input fields, and
|
||||||
@@ -66,5 +89,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`
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ Exact remote placement and the operator workflow belong in
|
|||||||
|
|
||||||
- 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
|
both manifests record success. Enablement and upload policy are fingerprinted,
|
||||||
until publish is forced.
|
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 validated run
|
||||||
`manifest.json`: declared run-local outputs, logs, generated configs, and the
|
`manifest.json`: declared run-local outputs, logs, generated configs, and the
|
||||||
@@ -38,7 +38,13 @@ Exact remote placement and the operator workflow belong in
|
|||||||
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, legacy aggregate output, stale/failed/unselected record, or
|
||||||
|
mismatched path, size, or checksum remains unavailable. This does not change
|
||||||
|
the explicit compatibility policies owned by built-in, extraction, or
|
||||||
|
previous-session sources.
|
||||||
- publishes extraction lanes only through explicit configured output rules;
|
- publishes extraction lanes only through explicit configured output rules;
|
||||||
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
||||||
- selected artifact filter applies to configured artifact sources only.
|
- selected artifact filter applies to configured artifact sources only.
|
||||||
@@ -83,6 +89,16 @@ Includes counts/lists for:
|
|||||||
- post-commit local cleanup is authorized by the committed publish metadata and
|
- post-commit local cleanup is authorized by the committed publish metadata and
|
||||||
is durably recorded by the application lifecycle before any local deletion.
|
is durably recorded by the application lifecycle before any local deletion.
|
||||||
|
|
||||||
|
## Resume Semantics
|
||||||
|
|
||||||
|
The versioned semantic fingerprint covers enabled behavior, run-upload policy,
|
||||||
|
normalized source/destination/required output rules, static lock policy, and
|
||||||
|
the remote backend, bucket, region, endpoint, and root-prefix identity. Rule
|
||||||
|
and lock ordering is canonicalized. Credential environment names,
|
||||||
|
path-addressing transport mode, local workspace placement, and run identifiers
|
||||||
|
are excluded. Remote locks remain mutable state and are still revalidated at
|
||||||
|
the commit boundary; semantic evidence does not replace that safety check.
|
||||||
|
|
||||||
The commit boundary and cleanup gate are normative architecture invariants; see
|
The commit boundary and cleanup gate are normative architecture invariants; see
|
||||||
[Architecture](../policy/architecture.md#publish-commit-boundary).
|
[Architecture](../policy/architecture.md#publish-commit-boundary).
|
||||||
|
|
||||||
@@ -95,5 +111,7 @@ The commit boundary and cleanup gate are normative architecture invariants; see
|
|||||||
- [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_test.go`,
|
||||||
|
`internal/stage/semantic_contracts_delivery.go`,
|
||||||
|
`internal/app/operator_helpers_test.go`, and
|
||||||
`internal/app/post_publish_cleanup_test.go`
|
`internal/app/post_publish_cleanup_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
|
||||||
|
|
||||||
@@ -22,7 +26,8 @@ Render Markdown transcript artifacts from normalized JSON transcripts via Seriat
|
|||||||
- 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`
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ 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
|
||||||
|
current selection through `narratio config diff <left-profile> <right-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. Select the same campaign explicitly when
|
||||||
|
profiles could resolve different campaign paths; see the [CLI reference](cli.md#config-validate-config-show-config-sources-and-config-diff) for syntax and record format.
|
||||||
|
|
||||||
## Stage Execution and Continuation Behavior
|
## Stage Execution and Continuation Behavior
|
||||||
|
|
||||||
Canonical stage order:
|
Canonical stage order:
|
||||||
@@ -80,22 +93,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`
|
||||||
|
|
||||||
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,
|
||||||
|
and missing legacy evidence causes a safe one-time rerun;
|
||||||
- `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`
|
||||||
@@ -111,14 +147,86 @@ 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
|
||||||
|
`notify`. 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 record 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.
|
||||||
|
|
||||||
|
For the common post-transcript 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 or notify; delivery remains a separate
|
||||||
|
operator action.
|
||||||
|
|
||||||
|
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, legacy, 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).
|
||||||
|
|
||||||
## 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
|
||||||
@@ -146,6 +254,11 @@ prepared inputs. Their canonical locations are `inputs/party.yml`,
|
|||||||
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
|
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
|
||||||
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
|
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
|
||||||
prepared files cannot change the bytes consumed by an in-flight invocation.
|
prepared files cannot change the bytes consumed by an in-flight invocation.
|
||||||
|
For a canonical party, preparation retains the validated authored party bytes
|
||||||
|
at `inputs/party.yml` and generates `inputs/players.yml` from that roster.
|
||||||
|
The manifest records their checksums separately, with the players input marked
|
||||||
|
as derived from the party; refresh preparation after changing the roster rather
|
||||||
|
than editing either prepared file.
|
||||||
Inspect the effective stable-input inventory and
|
Inspect the effective stable-input inventory and
|
||||||
prepared-file readiness with:
|
prepared-file readiness with:
|
||||||
|
|
||||||
@@ -213,6 +326,10 @@ narratio run-stage extract 2026-04-04 --force
|
|||||||
|
|
||||||
Narratio automatically reruns extraction when its recorded invocation contract,
|
Narratio automatically reruns extraction when its recorded invocation contract,
|
||||||
prepared Narratio reference identities, or durable output validation changes.
|
prepared Narratio reference identities, or durable output validation changes.
|
||||||
|
The semantic portion covers Notarius enablement, pipeline identity, declared
|
||||||
|
reference mapping, and output contracts. Executable, timeout, working directory,
|
||||||
|
and private config-file paths are operational and do not invalidate a current
|
||||||
|
result.
|
||||||
It cannot fingerprint configuration files, profiles, prompts, modules, or
|
It cannot fingerprint configuration files, profiles, prompts, modules, or
|
||||||
other references loaded transitively by Notarius itself. Force extraction after
|
other references loaded transitively by Notarius itself. Force extraction after
|
||||||
changing any of those inputs, even when the top-level Narratio and Notarius
|
changing any of those inputs, even when the top-level Narratio and Notarius
|
||||||
@@ -221,6 +338,12 @@ 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, static locks, 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 still
|
||||||
|
revalidates mutable remote locks immediately before commit selection.
|
||||||
|
|
||||||
## Publish Workflow
|
## Publish Workflow
|
||||||
|
|
||||||
Run publish only:
|
Run publish only:
|
||||||
@@ -436,7 +559,11 @@ Rules:
|
|||||||
- `pipeline.workspace.cleanup_after_publish=true`
|
- `pipeline.workspace.cleanup_after_publish=true`
|
||||||
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
|
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
|
||||||
reports incomplete, the remote committed snapshot remains current; rerun
|
reports incomplete, the remote committed snapshot remains current; rerun
|
||||||
Narratio to retry only the outstanding confined local cleanup.
|
publish to retry only the outstanding confined local cleanup.
|
||||||
|
|
||||||
|
Post-publish cleanup is evaluated only when `publish` actually executes in the
|
||||||
|
current invocation. A bounded range that excludes publish does not replay a
|
||||||
|
cleanup obligation as an unrelated side effect.
|
||||||
|
|
||||||
## Operational Caveats
|
## Operational Caveats
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -139,6 +145,14 @@ Configuration is strict, explicit, centralized, and operator-oriented.
|
|||||||
- 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 composition 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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
97
docs/release.md
Normal file
97
docs/release.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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 six 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, macOS, and Windows assets for `amd64` and `arm64`.
|
||||||
|
Cross-builds prove compilation; they are not native macOS or Windows 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.
|
||||||
26
docs/releases/README.md
Normal file
26
docs/releases/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
- [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.
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
# Notarius v0.6 CLI References Implementation Plan
|
|
||||||
|
|
||||||
## Purpose And Status
|
|
||||||
|
|
||||||
This is the executable implementation plan for the accepted target state in
|
|
||||||
[Notarius v0.6 CLI Reference Integration](notarius-v0.6-cli-references.md). It
|
|
||||||
is written for a `gpt-5.6-terra` coding agent that will implement exactly one
|
|
||||||
pending stage per prompt, in order.
|
|
||||||
|
|
||||||
The feature roadmap owns user intent, architectural boundaries, settled policy,
|
|
||||||
and the target end state. This document owns delivery order, concrete changes,
|
|
||||||
test allocation, and implementation status. Do not restate or change a roadmap
|
|
||||||
decision here during implementation; if current Notarius v0.6.0 evidence
|
|
||||||
contradicts the roadmap, stop and record the conflict instead of inventing a
|
|
||||||
different contract.
|
|
||||||
|
|
||||||
| Stage | Outcome | Status |
|
|
||||||
| ---: | --- | --- |
|
|
||||||
| 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed |
|
|
||||||
| 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed |
|
|
||||||
| 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed |
|
|
||||||
| 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Completed |
|
|
||||||
| 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Completed |
|
|
||||||
| 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Completed |
|
|
||||||
| 7 | Update canonical documentation and maintained examples for the completed feature. | Completed |
|
|
||||||
| 8 | Perform compatibility, quality, and repository-wide closure validation. | Completed |
|
|
||||||
|
|
||||||
## Governing Decisions
|
|
||||||
|
|
||||||
The following requirements are settled and are not questions for the
|
|
||||||
implementing agent:
|
|
||||||
|
|
||||||
1. `pipeline.notarius.references` is a map whose key is a Notarius v0.6 CLI
|
|
||||||
reference selector and whose value is a prepared Narratio source ID. It is
|
|
||||||
not a map of paths.
|
|
||||||
2. Every configured binding is required. There is no per-entry `required`
|
|
||||||
field. An optional Notarius reference is omitted by omitting the map entry.
|
|
||||||
An empty or omitted map remains valid for custom pipelines and backward
|
|
||||||
compatibility.
|
|
||||||
The map is limited by the centrally declared configuration constant
|
|
||||||
`MaxNotariusReferenceBindings = 256`, which is far above the four-entry
|
|
||||||
maintained D&D case while bounding argv and manifest growth.
|
|
||||||
3. The supported prepared reference sources are
|
|
||||||
`narratio.input.party`, `narratio.input.players`,
|
|
||||||
`narratio.input.glossary`, and `narratio.input.spell_catalog`.
|
|
||||||
Arbitrary Notarius slot names and qualified selectors may bind those sources;
|
|
||||||
direct paths and later-stage artifacts may not.
|
|
||||||
4. Campaign and session `spell_catalog_file` are optional. A session value
|
|
||||||
overrides the campaign value; an empty session value inherits the campaign
|
|
||||||
value. A `narratio.input.spell_catalog` reference binding requires an
|
|
||||||
effective configured file.
|
|
||||||
5. Prepared sources are authoritative only when the current session manifest
|
|
||||||
records the matching canonical input and checksum. Consumers do not fall
|
|
||||||
back to campaign/session source paths or accept an incidental workspace file.
|
|
||||||
6. Narratio passes absolute prepared-file paths to Notarius. Fingerprints and
|
|
||||||
metadata use the canonical workspace-relative path identity together with
|
|
||||||
selector, source ID, SHA-256 checksum, and size so workspace relocation does
|
|
||||||
not become the only identity signal.
|
|
||||||
7. External CLI references are sorted by normalized selector. They override
|
|
||||||
matching external references in the Notarius configuration. Narratio never
|
|
||||||
emits `--without-reference`, the deprecated `roster` alias, or CLI bindings
|
|
||||||
for generated D&D artifact handoffs.
|
|
||||||
8. Notarius remains authoritative for whether a selected target declares a
|
|
||||||
slot, accepted reference media types and sizes, generated-handoff collisions,
|
|
||||||
pipeline topology, and D&D payload schemas. Narratio validates selector
|
|
||||||
structure and its own source contract only.
|
|
||||||
9. Notarius v0.6.0 is the minimum supported CLI contract when references are
|
|
||||||
configured. Do not add version-string parsing or an automatic per-session
|
|
||||||
`notarius config validate` subprocess.
|
|
||||||
10. The existing receipt-v2, bundle-confinement, diagnostic, ten-lane selection,
|
|
||||||
immutable promotion, and analysis-source behavior must remain intact.
|
|
||||||
11. The default test suite remains offline, deterministic, and independent of
|
|
||||||
a sibling checkout or installed Notarius binary. A real v0.6.0 smoke run is
|
|
||||||
useful supplementary evidence when locally available, not a default-suite
|
|
||||||
dependency.
|
|
||||||
12. Add no external Go dependency for this feature. Use narrow owner-specific
|
|
||||||
types and existing file, path, artifact, manifest, adapter, and stage
|
|
||||||
facilities.
|
|
||||||
|
|
||||||
## Instructions For Every Stage
|
|
||||||
|
|
||||||
For each implementation prompt, the coding agent must:
|
|
||||||
|
|
||||||
1. Read `docs/development.md`, all three files under `docs/policy/`, the feature
|
|
||||||
roadmap, this plan, and the stage-specific documents and source named below.
|
|
||||||
Inspect the current tree because earlier stages may have changed names or
|
|
||||||
ownership boundaries.
|
|
||||||
2. Use the repository knowledge graph first for code discovery and call tracing;
|
|
||||||
use text search for documentation, configuration, examples, string literals,
|
|
||||||
and evidence the graph cannot supply.
|
|
||||||
3. Confirm the worktree state and preserve unrelated changes. Implement only the
|
|
||||||
current stage. Do not begin a later stage merely because an adjacent file is
|
|
||||||
open.
|
|
||||||
4. Keep production code, focused tests, fakes, and fixtures consistent within
|
|
||||||
the stage. Remove superseded helpers when their final caller migrates. Do not
|
|
||||||
retain two competing source maps, path resolvers, fingerprint paths, or
|
|
||||||
subprocess argument builders.
|
|
||||||
5. Follow the testing policy's ownership rule. Parser/config tests own selector
|
|
||||||
and configuration cases; artifact tests own prepared-file identity and
|
|
||||||
integrity; adapter tests own exact arguments; stage tests own orchestration and
|
|
||||||
resume; application tests own lifecycle invalidation. Do not repeat every
|
|
||||||
lower-level case at higher levels.
|
|
||||||
6. Keep errors actionable and content-free. They may identify a selector,
|
|
||||||
Narratio source ID, canonical path, or checksum mismatch, but must not include
|
|
||||||
reference contents. Preserve ordinary group-workspace permissions and
|
|
||||||
restrictive API-key handling.
|
|
||||||
7. Run `gofmt` on changed Go files and focused tests while iterating. Before
|
|
||||||
marking any stage complete, run at minimum:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./...
|
|
||||||
go test -race ./...
|
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
go test ./internal/doccheck
|
|
||||||
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
|
||||||
```
|
|
||||||
|
|
||||||
Default tests must not contact live services or require credentials.
|
|
||||||
8. Compare the final diff against the stage goal and exit criteria. Update only
|
|
||||||
the current stage's status row from `Pending` to `Completed`. Do not mark a
|
|
||||||
stage complete while a required check fails or required behavior is absent.
|
|
||||||
Intermediate commits are implementation-branch state and must not be released
|
|
||||||
before Stage 7 has reconciled current-behavior documentation.
|
|
||||||
|
|
||||||
## Stage 1 — Reference And Configuration Vocabulary
|
|
||||||
|
|
||||||
**Read first:** `docs/config.md`, `docs/integrations/notarius.md`,
|
|
||||||
`internal/config/config.go`, `internal/config/defaults.go`,
|
|
||||||
`internal/config/load.go`, `internal/config/validate.go`,
|
|
||||||
`internal/config/notarius_test.go`, `internal/config/campaign_config_test.go`,
|
|
||||||
and `internal/artifactpolicy/policy.go` and its tests. Read the tagged Notarius
|
|
||||||
v0.6.0 `docs/cli.md` reference-selector section from `../notarius` when that
|
|
||||||
checkout is available; otherwise use the canonical link from the feature
|
|
||||||
roadmap.
|
|
||||||
|
|
||||||
**Depends on:** None.
|
|
||||||
|
|
||||||
**Goal:** Establish one normalized reference-selector grammar and the strict
|
|
||||||
configuration model needed by later stages, without adding path-valued Notarius
|
|
||||||
configuration or making spell catalogs mandatory for every campaign.
|
|
||||||
|
|
||||||
**Work:**
|
|
||||||
|
|
||||||
- Add a small dependency-free `internal/notariusref` package as the contract
|
|
||||||
owner for Notarius reference selector normalization. Its exported normalizer
|
|
||||||
must trim the selector and each component, reject empty components and `=`,
|
|
||||||
and accept only the v0.6 forms `slot`,
|
|
||||||
`chunk.slot`, `lane.slot`, `lane.extract.slot`, `lane.merge.slot`, and
|
|
||||||
`lane.normalize.slot`. A three-component selector accepts only `extract`,
|
|
||||||
`merge`, or `normalize` in its middle component. Do not check the selector
|
|
||||||
against a Notarius module or lane registry.
|
|
||||||
- Add `References map[string]string` with YAML key `references` to
|
|
||||||
`config.NotariusConfig`. During enabled Notarius validation, sort raw keys,
|
|
||||||
normalize each selector through the shared contract, trim each source value,
|
|
||||||
reject empty values and normalized-selector collisions, require each value to
|
|
||||||
be one of the four prepared reference sources, and replace the config map with
|
|
||||||
its normalized form. Enforce the named, centrally discoverable
|
|
||||||
`config.MaxNotariusReferenceBindings` limit of 256 entries with an error that
|
|
||||||
identifies the field and limit. Keep a nil/empty map valid. Rely on strict YAML
|
|
||||||
decoding to reject duplicate identical keys, but explicitly reject distinct
|
|
||||||
raw keys that normalize to one selector.
|
|
||||||
- Add `SpellCatalogFile string` with YAML key `spell_catalog_file` to campaign
|
|
||||||
inputs and session inputs, plus `SpellCatalogFile ResolvedInputFile` to
|
|
||||||
resolved stable inputs. Merge it with the existing session-over-campaign
|
|
||||||
helper. It is not part of the campaign-required input set. Reject a non-empty
|
|
||||||
configured scalar that becomes empty after trimming.
|
|
||||||
- Add `artifactpolicy.SourceInputSpellCatalog` and make the artifact-policy
|
|
||||||
owner describe all four prepared reference sources, including their canonical
|
|
||||||
manifest kind and filename. Use that owner for stable-source recognition
|
|
||||||
instead of adding a second switch in configuration validation. Preserve the
|
|
||||||
existing three source IDs and their behavior.
|
|
||||||
- Extend cross-configuration validation so a normalized reference to
|
|
||||||
`narratio.input.spell_catalog` requires a non-empty effective resolved
|
|
||||||
`spell_catalog_file`. Existing campaign requirements already guarantee party,
|
|
||||||
players, and glossary declarations. Do not check filesystem existence during
|
|
||||||
configuration validation.
|
|
||||||
- If analyze's current private filename switch must change to keep the tree
|
|
||||||
behaviorally coherent, make it delegate to the artifact-policy descriptor and
|
|
||||||
recognize spell catalog; Stage 3 will replace the filesystem-only resolver.
|
|
||||||
|
|
||||||
**Tests and exit criteria:** At the contract/config owners, cover every accepted
|
|
||||||
selector shape; zero, empty, excess, invalid-stage, and `=` forms; whitespace
|
|
||||||
normalization; normalized collisions; unsupported and empty source IDs; nil and
|
|
||||||
empty maps; exactly the configured binding limit and limit plus one; strict
|
|
||||||
unknown fields; campaign inheritance and session override; optional omission;
|
|
||||||
and the cross-config missing-spell-catalog failure. Prefer table-driven parser
|
|
||||||
and validator tests over assertions against private helper structure. Existing
|
|
||||||
configuration and example tests must still pass without adding spell catalogs
|
|
||||||
to every campaign. The codebase has one selector grammar owner and one
|
|
||||||
prepared-source descriptor owner.
|
|
||||||
|
|
||||||
## Stage 2 — Spell Catalog Prepare And Operator Lifecycle
|
|
||||||
|
|
||||||
**Read first:** `docs/internal/stage-prepare.md`, `docs/internal/workspace.md`,
|
|
||||||
`docs/operations.md`, `internal/stage/prepare.go` and its tests,
|
|
||||||
`internal/app/operator_inspection.go`, `internal/app/operator_findings.go` and
|
|
||||||
their focused tests, `internal/manifest/manifest.go`, and the relevant confined
|
|
||||||
file-operation helpers.
|
|
||||||
|
|
||||||
**Depends on:** Stage 1.
|
|
||||||
|
|
||||||
**Goal:** Make the effective optional spell catalog a normal prepared session
|
|
||||||
input with canonical storage, checksum/provenance, safe stale-file handling, and
|
|
||||||
operator visibility.
|
|
||||||
|
|
||||||
**Work:**
|
|
||||||
|
|
||||||
- Resolve `StableInputs.SpellCatalogFile` with the same origin-preserving
|
|
||||||
campaign/session behavior as the five existing stable inputs. When configured,
|
|
||||||
require a regular readable source, copy it atomically to
|
|
||||||
`inputs/spell_catalog.json`, preserve ordinary workspace permissions, and add
|
|
||||||
one manifest input record with kind `spell_catalog`, canonical destination,
|
|
||||||
checksum, and `campaign_config` or `session_config` source provenance.
|
|
||||||
- Treat the input as optional when no effective path is configured. Do not call
|
|
||||||
the required-input path resolver with an empty value and do not create a
|
|
||||||
manifest record. Remove an obsolete canonical `inputs/spell_catalog.json`
|
|
||||||
without following it when a forced prepare transitions from configured to
|
|
||||||
absent; refuse to recursively remove a directory or other ambiguous object at
|
|
||||||
that exact file path.
|
|
||||||
- Include a configured spell catalog in operator inspection and validation
|
|
||||||
findings. Omission is not an error unless Stage 1 cross-configuration policy
|
|
||||||
says the enabled Notarius reference requires it. Reuse the common resolved
|
|
||||||
stable-input enumeration where practical instead of extending parallel
|
|
||||||
hand-written lists in several functions.
|
|
||||||
- Adjust input-slice capacity, deterministic ordering, test fixtures, and any
|
|
||||||
manifest assumptions affected by the optional sixth stable file. Do not parse
|
|
||||||
or schema-validate the JSON payload in Narratio; Notarius owns that contract.
|
|
||||||
|
|
||||||
**Tests and exit criteria:** Through prepare and operator package behavior, cover
|
|
||||||
campaign and session source provenance, canonical destination bytes and
|
|
||||||
checksum, optional omission, missing configured source, replacement after source
|
|
||||||
change, safe removal when configuration is removed, rejection of an ambiguous
|
|
||||||
destination object, deterministic manifest ordering, and operator reporting.
|
|
||||||
Do not duplicate selector-validation cases from Stage 1. Existing sessions with
|
|
||||||
no spell catalog remain valid and produce no stale manifest entry.
|
|
||||||
|
|
||||||
## Stage 3 — Manifest-Authoritative Prepared Input Resolution
|
|
||||||
|
|
||||||
**Read first:** `docs/internal/artifacts.md`, `docs/internal/manifest.md`,
|
|
||||||
`docs/internal/stage-analyze.md`, `internal/artifacts/artifact_resolver.go`,
|
|
||||||
`internal/artifacts/resolve.go`, `internal/artifacts/checksum.go`, their tests,
|
|
||||||
and the prepared stable-input resolution path in `internal/stage/analyze.go` and
|
|
||||||
`internal/stage/analyze_test.go`.
|
|
||||||
|
|
||||||
**Depends on:** Stage 2.
|
|
||||||
|
|
||||||
**Goal:** Give analyze and extract one integrity-checked resolver for prepared
|
|
||||||
stable sources so neither stage trusts incidental files or reconstructs its own
|
|
||||||
source-to-filename table.
|
|
||||||
|
|
||||||
**Work:**
|
|
||||||
|
|
||||||
- Add an artifacts-owned `PreparedInputIdentity` contract containing source ID,
|
|
||||||
manifest kind, absolute canonical path, slash-separated path relative to the
|
|
||||||
session root, SHA-256 checksum, and byte size. Add one resolver that accepts
|
|
||||||
session paths, the current session manifest, and a stable source ID. Provide a
|
|
||||||
typed or sentinel absence classification so callers can distinguish no current
|
|
||||||
manifest record from corrupt or unsafe recorded evidence.
|
|
||||||
- Derive kind and filename exclusively from the artifact-policy descriptor. The
|
|
||||||
resolver must require exactly one current manifest input record with the
|
|
||||||
expected kind and canonical path; resolve/rebase recorded local paths through
|
|
||||||
existing session-local path safety helpers; require the result to equal the
|
|
||||||
canonical file below `inputs/`; reject escapes, symlinks, non-regular files,
|
|
||||||
empty files, missing checksums, duplicate records, and checksum mismatches; and
|
|
||||||
calculate size without loading the complete file into memory. Do not fall back
|
|
||||||
to the configured campaign/session path or accept canonical file presence
|
|
||||||
without manifest evidence. Zero matching manifest records is the typed absent
|
|
||||||
case; once a record exists, a missing or invalid file is an integrity error,
|
|
||||||
not optional absence.
|
|
||||||
- Return owner-neutral errors from `internal/artifacts`. At stage boundaries,
|
|
||||||
wrap unavailable or stale prepared inputs with the source ID and actionable
|
|
||||||
`narratio run-stage prepare <session_id> --force` guidance. Do not include file
|
|
||||||
contents.
|
|
||||||
- Replace analyze's private prepared-source filename switch and filesystem-only
|
|
||||||
resolver with the shared artifact resolver. Preserve required-versus-optional
|
|
||||||
Scriptorium input behavior: a typed absent optional source is omitted, an
|
|
||||||
absent required source fails with prepare guidance, and invalid recorded
|
|
||||||
evidence fails regardless of optionality. Make `narratio.input.spell_catalog`
|
|
||||||
usable wherever another prepared Scriptorium source is accepted.
|
|
||||||
|
|
||||||
**Tests and exit criteria:** Artifact-package tests own valid resolution and the
|
|
||||||
missing-record, duplicate-record, wrong-kind/path, traversal/rebase, symlink,
|
|
||||||
non-regular, empty, missing-checksum, and checksum-mismatch boundaries. Analyze
|
|
||||||
tests need only prove required/optional stage behavior and successful use of the
|
|
||||||
shared source, including spell catalog; do not repeat the artifact resolver's
|
|
||||||
full matrix. Remove the old filename/path resolver after its final caller moves.
|
|
||||||
|
|
||||||
## Stage 4 — Notarius Adapter Reference Arguments
|
|
||||||
|
|
||||||
**Read first:** `docs/internal/adapters.md`, `docs/integrations/notarius.md`,
|
|
||||||
`internal/adapters/notarius/runner.go`, `fake.go`, `subprocess.go`, and focused
|
|
||||||
adapter tests. Re-read the Notarius v0.6.0 subprocess and CLI reference-selector
|
|
||||||
contracts from the tagged sibling checkout when available.
|
|
||||||
|
|
||||||
**Depends on:** Stage 1.
|
|
||||||
|
|
||||||
**Goal:** Extend the transport-neutral Notarius request and production adapter
|
|
||||||
to emit safe, exact, repeatable v0.6 `--reference` arguments without changing
|
|
||||||
receipt or bundle ingestion.
|
|
||||||
|
|
||||||
**Work:**
|
|
||||||
|
|
||||||
- Add a transport-neutral reference binding containing normalized selector and
|
|
||||||
absolute path, and add an ordered slice of those bindings to `RunRequest`.
|
|
||||||
Keep source IDs and manifest identities out of the adapter contract; those are
|
|
||||||
stage policy.
|
|
||||||
- Validate each adapter binding before process launch: normalize/validate the
|
|
||||||
selector through the shared Stage 1 contract, require a non-empty absolute
|
|
||||||
path, reject duplicate normalized selectors, and avoid mutating the caller's
|
|
||||||
slice. Do not open or parse the reference file in the adapter.
|
|
||||||
- Build arguments as repeated pairs `--reference`,
|
|
||||||
`<normalized-selector>=<absolute-path>` after `--output-dir` and before
|
|
||||||
`--json`. Preserve one argument for the combined selector/path value so spaces,
|
|
||||||
additional `=` characters within the path portion, and platform separators do
|
|
||||||
not involve shell interpretation. The request order is authoritative; Stage 5
|
|
||||||
will supply sorted bindings.
|
|
||||||
- Preserve current executable, environment, timeout, cancellation, diagnostic,
|
|
||||||
receipt-v2, bounded-read, confinement, and bundle-discovery behavior. Do not
|
|
||||||
add `--without-reference`, generated reference arguments, version probing, or
|
|
||||||
configuration preflight.
|
|
||||||
- Update the fake only as required to retain and expose the extended request.
|
|
||||||
|
|
||||||
**Tests and exit criteria:** Adapter tests own exact argv with zero and multiple
|
|
||||||
references, position before `--json`, spaces and `=` in paths, selector
|
|
||||||
normalization, duplicate/invalid selector rejection, relative/empty path
|
|
||||||
rejection, and no subprocess start after request-validation failure. Existing
|
|
||||||
receipt-v2 and bundle fixture tests must remain unchanged in meaning and pass.
|
|
||||||
Do not assert stage-level source sorting here beyond preserving the request
|
|
||||||
order.
|
|
||||||
|
|
||||||
## Stage 5 — Extract Reference Identity, Invocation, And Resume
|
|
||||||
|
|
||||||
**Read first:** `docs/internal/stage-extract.md`,
|
|
||||||
`docs/integrations/notarius.md`, `docs/internal/manifest.md`,
|
|
||||||
`internal/stage/extract.go`, `internal/stage/extract_resume.go`, their focused
|
|
||||||
tests, the Stage 3 prepared-input identity contract, and the Stage 4 Notarius
|
|
||||||
request contract.
|
|
||||||
|
|
||||||
**Depends on:** Stages 3 and 4.
|
|
||||||
|
|
||||||
**Goal:** Make configured references part of the actual extraction invocation
|
|
||||||
and durable reuse contract, using one resolution path for initial execution and
|
|
||||||
resume validation.
|
|
||||||
|
|
||||||
**Work:**
|
|
||||||
|
|
||||||
- Add one extract-owned reference-resolution helper used by both `Run` and
|
|
||||||
`ValidateResume`. Iterate normalized config bindings in lexical selector
|
|
||||||
order, resolve each source through the Stage 3 manifest-authoritative resolver,
|
|
||||||
and produce both adapter bindings and immutable reference identities. Resolve
|
|
||||||
every reference before creating run-local receipt, log, output, or promotion
|
|
||||||
directories and before invoking the adapter.
|
|
||||||
- Define the fingerprint/metadata identity as normalized selector, source ID,
|
|
||||||
canonical session-relative slash path, SHA-256 checksum, and byte size. Do not
|
|
||||||
include contents or original campaign/session absolute paths. Pass only
|
|
||||||
selector and absolute prepared path to the adapter.
|
|
||||||
- Extend the extraction fingerprint document with the sorted reference
|
|
||||||
identities. Keep all existing binary, config path, pipeline, timeout, working
|
|
||||||
directory, trimmed-transcript identity, and required-output identities. The
|
|
||||||
result must be independent of YAML map iteration order and must change for a
|
|
||||||
selector, source, relative path, checksum, or size change.
|
|
||||||
- Persist `reference_count` and a bounded deterministic `references` metadata
|
|
||||||
list on successful extraction. Each entry contains exactly `selector`,
|
|
||||||
`source_id`, `path`, `checksum`, and `size_bytes`. Empty bindings produce count
|
|
||||||
zero and an empty list. Do not duplicate Notarius reference payloads or
|
|
||||||
downstream error messages.
|
|
||||||
- Make resume recompute current reference identities through the same helper
|
|
||||||
before comparing the configuration fingerprint. A valid changed prepared
|
|
||||||
input yields a fingerprint mismatch and a non-resumable result so extraction
|
|
||||||
reruns. Missing, unsafe, or checksum-inconsistent current input is an error
|
|
||||||
with prepare-force guidance because immediately rerunning extract cannot
|
|
||||||
succeed. Do not silently reuse the old bundle.
|
|
||||||
- Preserve explicit disabled-stage skip without resolving references. Preserve
|
|
||||||
required lane selection, immutable promotion, receipt identity, and bundle
|
|
||||||
evidence behavior.
|
|
||||||
|
|
||||||
**Tests and exit criteria:** Stage tests own sorted request construction for all
|
|
||||||
four D&D bindings, zero bindings, failure before adapter invocation for an
|
|
||||||
unavailable source, content-free metadata, and fingerprint changes for each
|
|
||||||
identity field while remaining stable across map order. Resume tests must prove
|
|
||||||
reuse with unchanged references, non-reuse after a valid prepared-reference
|
|
||||||
change, hard failure for missing or checksum-invalid current evidence, and no
|
|
||||||
reference resolution when disabled. Use the fake adapter; do not duplicate exact
|
|
||||||
subprocess argv cases from Stage 4.
|
|
||||||
|
|
||||||
## Stage 6 — Assembled Lifecycle And Invalidation Coverage
|
|
||||||
|
|
||||||
**Read first:** `docs/internal/overview.md`, `docs/internal/manifest.md`,
|
|
||||||
`docs/internal/stage-extract.md`, `docs/internal/stage-prepare.md`,
|
|
||||||
`internal/app/runner.go`,
|
|
||||||
`internal/app/extract_lifecycle_test.go`, and representative full pipeline and
|
|
||||||
stage fixtures. Inspect existing downstream invalidation tests before adding
|
|
||||||
new cases.
|
|
||||||
|
|
||||||
**Depends on:** Stage 5.
|
|
||||||
|
|
||||||
**Goal:** Prove at the application boundary that prepared campaign context
|
|
||||||
reaches Notarius and that reference changes cannot leave extraction or later
|
|
||||||
analysis falsely current.
|
|
||||||
|
|
||||||
**Work:**
|
|
||||||
|
|
||||||
- Extend the smallest existing assembled runner fixture to execute prepare and
|
|
||||||
extract with party, players, glossary, and spell catalog bindings. Assert that
|
|
||||||
the fake Notarius request receives the four canonical prepared absolute paths,
|
|
||||||
not the original campaign/session source paths, and that the successful
|
|
||||||
manifest records bounded reference identity.
|
|
||||||
- Add one lifecycle regression covering a valid reference-content change:
|
|
||||||
rerun/force prepare so the manifest and prepared checksum change, then verify
|
|
||||||
extract resume is rejected, Notarius runs again, and succeeded canonical
|
|
||||||
downstream stages are invalidated according to the existing stage-order
|
|
||||||
policy. Assert outcomes, not private runner call choreography.
|
|
||||||
- Add one representative session override case to prove the overridden prepared
|
|
||||||
bytes/checksum reach extraction. Do not repeat all four configuration merge
|
|
||||||
cases or artifact-integrity failures already owned by earlier stages.
|
|
||||||
- Confirm an empty reference map preserves the pre-v0.6 invocation behavior and
|
|
||||||
that all ten configured D&D lanes remain registered as the same
|
|
||||||
`narratio.extraction.<key>` sources available to analyze.
|
|
||||||
- Fix production integration defects exposed by these assembled tests without
|
|
||||||
broadening the feature or adding a DAG, generic reference workflow, or direct
|
|
||||||
Notarius payload parsing.
|
|
||||||
|
|
||||||
**Tests and exit criteria:** The application-level tests must be deterministic,
|
|
||||||
offline, and fake only the external Notarius boundary. They must credibly fail
|
|
||||||
if Narratio passes original paths, omits one configured reference, reuses stale
|
|
||||||
extraction, or loses a configured lane, while remaining insensitive to private
|
|
||||||
helper structure and exact non-contractual diagnostics. Earlier focused suites
|
|
||||||
and the repository baseline remain green.
|
|
||||||
|
|
||||||
## Stage 7 — Canonical Documentation And Maintained Examples
|
|
||||||
|
|
||||||
**Read first:** `docs/policy/documentation.md`, `docs/config.md`,
|
|
||||||
`docs/operations.md`, `docs/troubleshooting.md`,
|
|
||||||
`docs/integrations/notarius.md`, `docs/internal/overview.md`,
|
|
||||||
`docs/internal/adapters.md`, `docs/internal/artifacts.md`,
|
|
||||||
`docs/internal/stage-prepare.md`, `docs/internal/stage-extract.md`,
|
|
||||||
`docs/internal/stage-analyze.md`, `examples/README.md`, and all maintained
|
|
||||||
pipeline, campaign, and session examples affected by the new fields.
|
|
||||||
|
|
||||||
**Depends on:** Stage 6.
|
|
||||||
|
|
||||||
**Goal:** Move the completed behavior from roadmap-only future state into its
|
|
||||||
canonical current-behavior owners and provide valid copyable D&D examples
|
|
||||||
without duplicating volatile Notarius contracts.
|
|
||||||
|
|
||||||
**Work:**
|
|
||||||
|
|
||||||
- Update `docs/config.md` with `pipeline.notarius.references`, its selector-to-
|
|
||||||
source shape, normalization/validation rules, required-by-presence behavior,
|
|
||||||
supported stable source IDs, and campaign/session `spell_catalog_file`
|
|
||||||
precedence and optionality. Keep complete copyable YAML in `examples/`.
|
|
||||||
- Update `docs/integrations/notarius.md` to the v0.6.0 baseline and exact
|
|
||||||
repeatable-reference invocation boundary. Explain absolute CLI paths,
|
|
||||||
precedence over configured external paths, the four maintained external D&D
|
|
||||||
slots, the generated-handoff exclusion, and unchanged receipt-v2/ten-lane
|
|
||||||
output compatibility. Link to Notarius's canonical v0.6 CLI and D&D consumer
|
|
||||||
docs instead of copying its target/module matrix.
|
|
||||||
- Update operations and troubleshooting with prepared input location,
|
|
||||||
fingerprint/rerun consequences, operator inspection, missing-reference
|
|
||||||
diagnosis, and Notarius undeclared-slot/generated-collision failures. Update
|
|
||||||
internal component documents only with implemented ownership and flow; do not
|
|
||||||
duplicate configuration field definitions there.
|
|
||||||
- Add a valid, secret-free sample spell catalog following Notarius v0.6's
|
|
||||||
published overlay schema, add `spell_catalog_file` to the sample campaign, and
|
|
||||||
configure all four external reference bindings in the complete annotated D&D
|
|
||||||
pipeline. Add the same bindings to other Notarius-enabled maintained examples
|
|
||||||
only when their selected pipeline declares them; do not add a Notarius section
|
|
||||||
to examples that intentionally omit extraction.
|
|
||||||
- Ensure the maintained command snippets place repeated `--reference` arguments
|
|
||||||
before `--json`, use `party` rather than `roster`, and never show generated
|
|
||||||
handoffs on the CLI. Remove stale v0.5 compatibility wording where it refers to
|
|
||||||
the supported invocation baseline.
|
|
||||||
|
|
||||||
**Tests and exit criteria:** Run documentation-link checks and the example
|
|
||||||
loader explicitly. Verify every changed example is accepted by strict config
|
|
||||||
validation, contains no credentials or private infrastructure values, and has
|
|
||||||
one canonical owner for each volatile fact. Search current-behavior docs and
|
|
||||||
examples for stale v0.5 invocation wording, deprecated `roster` emission, and
|
|
||||||
generated D&D CLI handoff examples. Do not mark the roadmap itself implemented;
|
|
||||||
its status remains target-state context until the implementation sprint is
|
|
||||||
reviewed and closed.
|
|
||||||
|
|
||||||
## Stage 8 — Compatibility And Quality Closure
|
|
||||||
|
|
||||||
**Read first:** The feature roadmap, every completed stage diff, the final
|
|
||||||
current-behavior docs, `.woodpecker/verify.yml`, `.woodpecker/release.yml`, and
|
|
||||||
`.woodpecker/shuffle.yml`. Re-read the tagged Notarius v0.6.0
|
|
||||||
`docs/consumers/dnd-pipeline.md`, `docs/cli.md`, and linked spell-catalog overlay
|
|
||||||
contract when the sibling checkout is available.
|
|
||||||
|
|
||||||
**Depends on:** Stage 7.
|
|
||||||
|
|
||||||
**Goal:** Verify the delivered code matches the accepted boundary, remains
|
|
||||||
compatible with all ten default D&D artifacts, and is ready for review without
|
|
||||||
dead compatibility paths or duplicated policy.
|
|
||||||
|
|
||||||
**Work:**
|
|
||||||
|
|
||||||
- Audit the final diff against every target-state and out-of-scope statement in
|
|
||||||
the feature roadmap. Confirm only four external prepared sources are exposed,
|
|
||||||
custom selectors remain possible, every configured binding is required, and
|
|
||||||
no Notarius pipeline topology or generated-handoff logic moved into Narratio.
|
|
||||||
- Trace initial extract and resume paths to confirm both use the same prepared
|
|
||||||
identity and reference resolution, the adapter is the sole argv builder, and
|
|
||||||
artifact policy is the sole source-to-kind/filename vocabulary. Remove dead
|
|
||||||
helpers, redundant switches, stale fixtures, and low-value duplicate tests
|
|
||||||
found during this review.
|
|
||||||
- Confirm the complete D&D example still declares and validates the exact ten
|
|
||||||
output lanes and that analyze can consume those sources after reference-
|
|
||||||
enabled extraction. Confirm empty-reference custom pipelines remain supported.
|
|
||||||
- If a local Notarius v0.6.0 binary and its required offline/test configuration
|
|
||||||
are already available, perform a non-credentialed smoke invocation with all
|
|
||||||
four reference flags and record the result in the implementation handoff. Do
|
|
||||||
not download tools, contact paid providers, add a default test dependency, or
|
|
||||||
block completion solely because this supplementary environment is absent.
|
|
||||||
- Run the repository baseline plus the scheduled shuffled suite and release
|
|
||||||
cross-build commands:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./...
|
|
||||||
go test -race ./...
|
|
||||||
go test -race -shuffle=on -count=3 ./...
|
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
go test ./internal/doccheck
|
|
||||||
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
|
||||||
narratio_cross_dir="$(mktemp -d)"
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-linux-amd64" ./cmd/narratio
|
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-darwin-amd64" ./cmd/narratio
|
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-windows-amd64.exe" ./cmd/narratio
|
|
||||||
```
|
|
||||||
|
|
||||||
Cross-builds are compilation evidence only; do not claim native macOS or
|
|
||||||
Windows runtime validation.
|
|
||||||
|
|
||||||
**Tests and exit criteria:** Every required command passes, `git diff --check`
|
|
||||||
is clean, the worktree contains no unintended generated test artifacts, and the
|
|
||||||
implementation is traceably complete against the roadmap. Summarize any
|
|
||||||
unavailable supplementary smoke evidence without treating it as a product
|
|
||||||
question or silently weakening the default suite.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None. The feature roadmap and governing decisions above are sufficient to
|
|
||||||
implement the plan without additional product or architecture choices.
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
# Notarius v0.6 CLI Reference Integration
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted target state. Delivery sequencing and implementation status are owned
|
|
||||||
by [implementation.md](implementation.md).
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Upgrade Narratio's extraction boundary to the Notarius v0.6.0 subprocess
|
|
||||||
contract and supply session reference documents explicitly with repeatable
|
|
||||||
`--reference selector=path` arguments.
|
|
||||||
|
|
||||||
The maintained D&D integration must make the prepared party roster, player
|
|
||||||
context, glossary, and optional spell catalog available to every compatible
|
|
||||||
Notarius target. Notarius must continue to own pipeline topology, reference-slot
|
|
||||||
compatibility, generated artifact handoffs, prompts, and D&D schemas. Narratio
|
|
||||||
owns selection and preparation of its external reference files, exact CLI
|
|
||||||
invocation, provenance, and extraction reuse correctness.
|
|
||||||
|
|
||||||
## Current State And Gap
|
|
||||||
|
|
||||||
Narratio currently invokes Notarius as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
notarius run <pipeline_id> --config <config_path> --input <transcript> --output-dir <staging_dir> --json
|
|
||||||
```
|
|
||||||
|
|
||||||
The `prepare` stage already materializes campaign/session party, players, and
|
|
||||||
glossary files under the session `inputs/` directory, but `extract` does not
|
|
||||||
pass them to Notarius. Narratio also has no stable spell-catalog input. As a
|
|
||||||
result, a Notarius deployment must duplicate these paths in its own
|
|
||||||
configuration, cannot reliably receive session overrides, and may extract
|
|
||||||
without the same campaign context supplied to Narratio's analysis stage.
|
|
||||||
|
|
||||||
Notarius v0.6.0 makes an unqualified CLI selector pipeline-scoped. For example,
|
|
||||||
`--reference party=/absolute/path/party.yml` supplies the file to every
|
|
||||||
selected target that declares `party`. Scoped selectors remain available for
|
|
||||||
exceptional overrides. CLI paths are resolved from the Notarius process working
|
|
||||||
directory, so subprocess callers are expected to provide absolute paths.
|
|
||||||
|
|
||||||
The v0.6.0 receipt, index, warning, diagnostic, and ten-lane D&D artifact
|
|
||||||
contracts remain compatible with Narratio's current v0.5 integration. This
|
|
||||||
feature changes the invocation and input-provenance contract rather than the
|
|
||||||
accepted output inventory.
|
|
||||||
|
|
||||||
## User Outcome
|
|
||||||
|
|
||||||
With the maintained complete D&D configuration, an operator can declare the
|
|
||||||
campaign reference sources once in Narratio. For each extraction Narratio will:
|
|
||||||
|
|
||||||
1. materialize the effective campaign/session files during `prepare`;
|
|
||||||
2. resolve those prepared files by stable Narratio source ID;
|
|
||||||
3. pass absolute paths for `party`, `players`, `glossary`, and, when configured,
|
|
||||||
`spell_catalog` to Notarius through repeatable CLI arguments;
|
|
||||||
4. fail before launching Notarius when a configured reference is unavailable;
|
|
||||||
5. rerun extraction when a selector, source binding, or reference file changes;
|
|
||||||
and
|
|
||||||
6. retain bounded reference identities and checksums for diagnosis and
|
|
||||||
provenance without copying reference contents into manifest metadata.
|
|
||||||
|
|
||||||
Session-level stable-input overrides must flow through the same mechanism. A
|
|
||||||
custom Notarius pipeline may bind different external slots without requiring a
|
|
||||||
Narratio code change.
|
|
||||||
|
|
||||||
## Chosen Architecture
|
|
||||||
|
|
||||||
### Explicit Reference Bindings
|
|
||||||
|
|
||||||
Extend `pipeline.notarius` with an explicit map from a Notarius CLI selector to
|
|
||||||
a prepared Narratio input source:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
notarius:
|
|
||||||
enabled: true
|
|
||||||
binary: notarius
|
|
||||||
config_path: /usr/local/etc/notarius/config.yml
|
|
||||||
pipeline_id: dnd-session
|
|
||||||
working_directory: /usr/local/etc/notarius
|
|
||||||
references:
|
|
||||||
party: narratio.input.party
|
|
||||||
players: narratio.input.players
|
|
||||||
glossary: narratio.input.glossary
|
|
||||||
spell_catalog: narratio.input.spell_catalog
|
|
||||||
outputs:
|
|
||||||
# Existing required lane contracts remain unchanged.
|
|
||||||
```
|
|
||||||
|
|
||||||
Each configured binding is required. An operator who does not maintain an
|
|
||||||
optional Notarius reference, such as a spell catalog, omits that binding. This
|
|
||||||
keeps missing-input behavior explicit and avoids a second required/optional
|
|
||||||
policy inside each entry.
|
|
||||||
|
|
||||||
The maintained complete D&D example will show all four external reference
|
|
||||||
slots. The three existing campaign context bindings use the canonical `party`,
|
|
||||||
`players`, and `glossary` spellings. Narratio will not emit the deprecated
|
|
||||||
`roster` alias.
|
|
||||||
|
|
||||||
The binding is deliberately source-based rather than path-based. Pipeline
|
|
||||||
configuration should not reconstruct session workspace paths or bypass
|
|
||||||
`prepare`; it names the stable input whose effective campaign/session value is
|
|
||||||
already owned by Narratio. The map also avoids hard-coded behavior keyed to the
|
|
||||||
literal `dnd-session` pipeline ID, preserving custom-pipeline support.
|
|
||||||
|
|
||||||
Narratio accepts the selector forms published by Notarius v0.6.0:
|
|
||||||
|
|
||||||
- `slot`;
|
|
||||||
- `chunk.slot`;
|
|
||||||
- `lane.slot`; and
|
|
||||||
- `lane.extract.slot`, `lane.merge.slot`, or `lane.normalize.slot`.
|
|
||||||
|
|
||||||
Configuration validation will reject empty or structurally invalid selectors,
|
|
||||||
selectors containing `=`, unsupported source IDs, and duplicate YAML keys.
|
|
||||||
Notarius remains authoritative for whether a selected target actually declares
|
|
||||||
the slot and whether a file satisfies that slot's media type and size contract.
|
|
||||||
Narratio will not duplicate the Notarius module registry.
|
|
||||||
|
|
||||||
### Stable Reference Inputs
|
|
||||||
|
|
||||||
Continue to use the existing prepared sources and canonical files:
|
|
||||||
|
|
||||||
| Narratio source | Prepared file | Notarius slot |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `narratio.input.party` | `inputs/party.yml` | `party` |
|
|
||||||
| `narratio.input.players` | `inputs/players.yml` | `players` |
|
|
||||||
| `narratio.input.glossary` | `inputs/glossary.yml` | `glossary` |
|
|
||||||
| `narratio.input.spell_catalog` | `inputs/spell_catalog.json` | `spell_catalog` |
|
|
||||||
|
|
||||||
Add optional `spell_catalog_file` fields to campaign and session inputs, with
|
|
||||||
the existing campaign-default/session-override resolution behavior. When
|
|
||||||
provided, `prepare` copies it into the session input area and records its
|
|
||||||
origin and checksum consistently with the other stable inputs. The prepared
|
|
||||||
filename remains JSON so Notarius can apply its published spell-catalog media
|
|
||||||
contract.
|
|
||||||
|
|
||||||
The new source must be added everywhere stable inputs are enumerated: strict
|
|
||||||
configuration decoding and merging, validation, prepare materialization,
|
|
||||||
artifact policy/source descriptions, operator inspection, manifest input
|
|
||||||
records, examples, and canonical documentation. It remains optional at the
|
|
||||||
campaign level; a configured Notarius binding makes it mandatory for that
|
|
||||||
extraction.
|
|
||||||
|
|
||||||
Extract and analyze should use one shared prepared-input source resolver rather
|
|
||||||
than maintain separate source-to-filename tables. The resolver must return an
|
|
||||||
absolute, regular, non-empty file beneath the current session workspace and
|
|
||||||
produce actionable `prepare --force` guidance when a configured source is
|
|
||||||
missing. It must not fall back to the original campaign path after preparation.
|
|
||||||
|
|
||||||
### Adapter Request And CLI Construction
|
|
||||||
|
|
||||||
Extend the transport-neutral Notarius run request with an ordered collection of
|
|
||||||
resolved reference bindings. Each binding contains only its selector and
|
|
||||||
absolute prepared-file path. The extraction stage resolves source IDs and file
|
|
||||||
identity; the subprocess adapter validates and serializes the request.
|
|
||||||
|
|
||||||
The production command becomes:
|
|
||||||
|
|
||||||
```text
|
|
||||||
notarius run <pipeline_id>
|
|
||||||
--config <config_path>
|
|
||||||
--input <trimmed_json>
|
|
||||||
--output-dir <staging_dir>
|
|
||||||
--reference party=<absolute_prepared_party_path>
|
|
||||||
--reference players=<absolute_prepared_players_path>
|
|
||||||
--reference glossary=<absolute_prepared_glossary_path>
|
|
||||||
--reference spell_catalog=<absolute_prepared_spell_catalog_path>
|
|
||||||
--json
|
|
||||||
```
|
|
||||||
|
|
||||||
Only configured bindings are emitted. Selectors are sorted before request
|
|
||||||
construction so argument order, tests, logs, and fingerprints are deterministic.
|
|
||||||
Arguments are passed directly to the subprocess without shell interpretation;
|
|
||||||
paths containing spaces or platform-specific separators remain one argument.
|
|
||||||
|
|
||||||
CLI bindings intentionally override matching external paths in the deployed
|
|
||||||
Notarius configuration. Narratio must not pass `--without-reference` and must
|
|
||||||
not synthesize CLI bindings for `location_registry`, `item_registry`,
|
|
||||||
`npc_registry`, `scene_descriptions`, `combat_turns`, or `npc_occurrences`.
|
|
||||||
Those are generated same-run artifact handoffs in the complete D&D pipeline and
|
|
||||||
remain entirely under Notarius configuration and execution control. A custom
|
|
||||||
configuration that collides an external CLI binding with a generated handoff is
|
|
||||||
expected to fail with Notarius's normal resolution error.
|
|
||||||
|
|
||||||
### Fingerprints, Resume, And Provenance
|
|
||||||
|
|
||||||
Reference identity is part of the extraction input contract. The extraction
|
|
||||||
fingerprint and resume validator must include, in deterministic selector order:
|
|
||||||
|
|
||||||
- the selector;
|
|
||||||
- the configured Narratio source ID;
|
|
||||||
- the resolved prepared path identity; and
|
|
||||||
- the prepared file's content checksum and size.
|
|
||||||
|
|
||||||
This is required even though Notarius generates a prompt session ID from the
|
|
||||||
input module and transcript bytes: Notarius intentionally does not include
|
|
||||||
references in that identifier. Narratio must therefore prevent an old
|
|
||||||
extraction from being reused after a roster, player list, glossary, spell
|
|
||||||
catalog, selector, or source mapping changes.
|
|
||||||
|
|
||||||
A changed reference makes the prior `extract` result non-reusable and follows
|
|
||||||
Narratio's normal downstream invalidation rules. A failed reference-resolution
|
|
||||||
or checksum check also prevents reuse; it must not silently accept the prior
|
|
||||||
bundle.
|
|
||||||
|
|
||||||
Successful extract metadata should record a bounded, deterministic list of
|
|
||||||
selector, source ID, workspace-relative path, checksum, and size. It must not
|
|
||||||
record reference contents, original absolute operator paths, or values from the
|
|
||||||
files. Existing receipt and bundle provenance behavior remains unchanged.
|
|
||||||
|
|
||||||
### Error And Compatibility Behavior
|
|
||||||
|
|
||||||
Narratio's documented minimum supported Notarius version becomes v0.6.0 for an
|
|
||||||
enabled reference binding. Compatibility remains contract-based rather than
|
|
||||||
dependent on parsing `notarius --version`: an older or incompatible executable
|
|
||||||
will fail at the CLI boundary with captured diagnostics.
|
|
||||||
|
|
||||||
Errors must identify the responsible selector and Narratio source without
|
|
||||||
including file contents. Configuration errors are reported before pipeline
|
|
||||||
execution. Missing, empty, non-regular, unsafe, or unreadable prepared files
|
|
||||||
fail extraction before the Notarius subprocess starts. Notarius continues to
|
|
||||||
report undeclared slots, media incompatibility, size limits, required-slot
|
|
||||||
failures, and generated-handoff collisions.
|
|
||||||
|
|
||||||
When Notarius is disabled, extraction retains its current explicit skip
|
|
||||||
behavior and does not resolve reference inputs. Receipt v2 ingestion, bundle
|
|
||||||
confinement, ten-lane selection, and downstream artifact source IDs are not
|
|
||||||
otherwise changed by this feature.
|
|
||||||
|
|
||||||
## Target End State
|
|
||||||
|
|
||||||
Narratio and Notarius have a clear orchestration boundary:
|
|
||||||
|
|
||||||
- `prepare` owns the effective, immutable session copies of external campaign
|
|
||||||
context;
|
|
||||||
- `extract` maps configured stable source IDs to Notarius v0.6 CLI selectors,
|
|
||||||
supplies absolute file paths, and owns reuse/provenance policy;
|
|
||||||
- the Notarius adapter owns exact subprocess serialization and supported result
|
|
||||||
decoding;
|
|
||||||
- Notarius owns slot compatibility, reference precedence within its pipeline,
|
|
||||||
generated artifact handoffs, and output schemas; and
|
|
||||||
- `analyze` consumes the resulting ten structured lane artifacts exactly as it
|
|
||||||
does today.
|
|
||||||
|
|
||||||
The maintained complete D&D workflow passes party, players, glossary, and spell
|
|
||||||
catalog context from the same prepared session inputs used elsewhere in
|
|
||||||
Narratio. Updating any of those documents deterministically causes fresh
|
|
||||||
extraction, and operators can diagnose the effective bindings without exposing
|
|
||||||
file contents.
|
|
||||||
|
|
||||||
## Out Of Scope
|
|
||||||
|
|
||||||
- Reproducing Notarius pipeline, lane, binding, or media-type validation in
|
|
||||||
Narratio.
|
|
||||||
- Passing or overriding Notarius generated artifact handoffs.
|
|
||||||
- Adding `--without-reference`, Notarius resume/recompute controls, lane
|
|
||||||
selection, model selection, profile selection, or session-ID overrides.
|
|
||||||
- Changing the ten accepted D&D lane contracts or the Scriptorium analysis
|
|
||||||
design.
|
|
||||||
- Reading reference payloads into Narratio manifests or logs.
|
|
||||||
- Automatically running `notarius config validate` for every session.
|
|
||||||
|
|
||||||
## Settled Policy Choices
|
|
||||||
|
|
||||||
The implementation must preserve these choices unless implementation evidence
|
|
||||||
shows a contract conflict:
|
|
||||||
|
|
||||||
- explicit selector-to-source mappings are preferred over pipeline-ID-specific
|
|
||||||
defaults;
|
|
||||||
- every configured mapping is required;
|
|
||||||
- `spell_catalog_file` is optional until a mapping requests its prepared
|
|
||||||
source;
|
|
||||||
- the complete D&D example demonstrates all four external references; and
|
|
||||||
- Notarius v0.6.0 is the minimum supported CLI contract for reference-enabled
|
|
||||||
extraction.
|
|
||||||
@@ -71,6 +71,36 @@ Safe fix:
|
|||||||
|
|
||||||
Relevant reference: [Configuration](./config.md).
|
Relevant reference: [Configuration](./config.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 +147,40 @@ 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 record 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 executable missing
|
## Notarius executable missing
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
@@ -321,6 +385,103 @@ 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, malformed, or
|
||||||
|
from the legacy aggregate-only manifest contract;
|
||||||
|
- 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).
|
||||||
|
|
||||||
|
## Legacy aggregate analysis requires regeneration
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- a manifest from an older Narratio version reports aggregate analyze success
|
||||||
|
and the old files are present, but configured artifact sources remain
|
||||||
|
unavailable.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- the manifest has no supported per-artifact analyze state. Aggregate output
|
||||||
|
lists do not establish current configured-artifact authority.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- regenerate the required artifacts. A partial selection makes only its
|
||||||
|
targets and prerequisites eligible for current state; unselected legacy
|
||||||
|
files intentionally remain unavailable. Run full analysis later when every
|
||||||
|
enabled configured artifact must become current.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio analyze 2026-04-04 --artifacts session_recap
|
||||||
|
narratio analyze 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
|
After current records exist, inspect them and publish explicitly. Do not delete
|
||||||
|
the legacy files 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:
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ 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/testing split bundle](production-testing/pipeline.yml): explicit
|
||||||
|
`conf.d` imports, a production default, and selectable production/testing
|
||||||
|
overlays. It also demonstrates canonical-party artifact families and a
|
||||||
|
testing-only disabled artifact. Validate it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
narratio config validate --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
|
||||||
|
narratio config diff production testing --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
`config show` and `config sources` accept the same selectors and remain
|
||||||
|
read-only.
|
||||||
- [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,11 +49,11 @@ with the sample campaign and a compatible local- or S3-audio session.
|
|||||||
- The sample campaign references its local
|
- The sample campaign references its local
|
||||||
[speakers](campaigns/sample-campaign/speakers.yml),
|
[speakers](campaigns/sample-campaign/speakers.yml),
|
||||||
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
||||||
[glossary](campaigns/sample-campaign/glossary.yml),
|
[glossary](campaigns/sample-campaign/glossary.yml), and canonical
|
||||||
[players](campaigns/sample-campaign/players.yml), and
|
[party](campaigns/sample-campaign/party.yml) fixture, plus an optional
|
||||||
[party](campaigns/sample-campaign/party.yml) fixtures, plus an optional
|
|
||||||
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
|
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
|
||||||
follows the Notarius v0.6 contract.
|
follows the Notarius v0.6 contract. Narratio derives the players projection
|
||||||
|
from this party source; the campaign deliberately has no `players_file`.
|
||||||
- [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.
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
spell_catalog_file: ./spell_catalog.json
|
spell_catalog_file: ./spell_catalog.json
|
||||||
|
|||||||
@@ -1,2 +1,26 @@
|
|||||||
- name: Example Hero
|
schema_version: narratio.party.v1
|
||||||
type: pc
|
|
||||||
|
characters:
|
||||||
|
arannis:
|
||||||
|
player:
|
||||||
|
name: Rowan Hale
|
||||||
|
character:
|
||||||
|
name: Arannis
|
||||||
|
alias:
|
||||||
|
- Ari
|
||||||
|
- The Grey Owl
|
||||||
|
classes:
|
||||||
|
- name: wizard
|
||||||
|
level: 8
|
||||||
|
brenna:
|
||||||
|
player:
|
||||||
|
name: Rowan Hale
|
||||||
|
character:
|
||||||
|
name: Brenna
|
||||||
|
alias:
|
||||||
|
- Shield of Dawn
|
||||||
|
classes:
|
||||||
|
- name: paladin
|
||||||
|
level: 6
|
||||||
|
- name: warlock
|
||||||
|
level: 2
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
- name: Example Player
|
|
||||||
role: player
|
|
||||||
32
examples/production-testing/conf.d/artifacts.yml
Normal file
32
examples/production-testing/conf.d/artifacts.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
config_path: ./scriptorium/config.yml
|
||||||
|
artifact_families:
|
||||||
|
character_meta:
|
||||||
|
enabled: true
|
||||||
|
for_each: party.characters
|
||||||
|
prompt_id: dnd.character_meta
|
||||||
|
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
|
||||||
|
character_items:
|
||||||
|
enabled: true
|
||||||
|
for_each: party.characters
|
||||||
|
prompt_id: dnd.character_items
|
||||||
|
output_path_pattern: artifacts/characters/{character_id}/items.md
|
||||||
|
member_dependencies: [character_meta]
|
||||||
|
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
|
||||||
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
|
||||||
16
examples/production-testing/conf.d/transcript.yml
Normal file
16
examples/production-testing/conf.d/transcript.yml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
normalize:
|
||||||
|
output_path: transcripts/final.json
|
||||||
|
output_schema: seriatim-intermediate
|
||||||
15
examples/production-testing/pipeline.yml
Normal file
15
examples/production-testing/pipeline.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Copyable production/testing pipeline entry point. Every fragment is named
|
||||||
|
# explicitly; Narratio never scans conf.d automatically.
|
||||||
|
composition:
|
||||||
|
imports:
|
||||||
|
- conf.d/platform.yml
|
||||||
|
- conf.d/storage.yml
|
||||||
|
- conf.d/transcript.yml
|
||||||
|
- conf.d/artifacts.yml
|
||||||
|
- conf.d/publish.yml
|
||||||
|
default_profile: production
|
||||||
|
profiles:
|
||||||
|
production:
|
||||||
|
overlay: profiles/production.yml
|
||||||
|
testing:
|
||||||
|
overlay: profiles/testing.yml
|
||||||
10
examples/production-testing/profiles/production.yml
Normal file
10
examples/production-testing/profiles/production.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
audita:
|
||||||
|
model: narratio-production-model-placeholder
|
||||||
|
validation_model: narratio-production-validator-placeholder
|
||||||
|
|
||||||
|
scriptorium:
|
||||||
|
artifact_families:
|
||||||
|
character_meta:
|
||||||
|
profile_id: production-placeholder
|
||||||
|
character_items:
|
||||||
|
profile_id: production-placeholder
|
||||||
14
examples/production-testing/profiles/testing.yml
Normal file
14
examples/production-testing/profiles/testing.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
audita:
|
||||||
|
model: narratio-testing-model-placeholder
|
||||||
|
validation_model: narratio-testing-validator-placeholder
|
||||||
|
|
||||||
|
scriptorium:
|
||||||
|
artifacts:
|
||||||
|
testing_notes:
|
||||||
|
enabled: false
|
||||||
|
output_path: artifacts/testing-notes.md
|
||||||
|
artifact_families:
|
||||||
|
character_meta:
|
||||||
|
profile_id: testing-placeholder
|
||||||
|
character_items:
|
||||||
|
profile_id: testing-placeholder
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
// Package subprocess provides reusable process execution and generated-config helpers.
|
// Package subprocess provides reusable process execution and generated-config helpers.
|
||||||
package subprocess
|
package subprocess
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -43,8 +42,8 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
@@ -115,8 +114,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 +125,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 +154,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=4 skipped=8") {
|
||||||
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,8 +169,8 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
@@ -200,7 +210,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, _ []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, _ BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
||||||
}
|
}
|
||||||
@@ -293,8 +303,8 @@ func TestExecutePublishForceRunsPublish(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
internal/app/analyze_evidence_test_helpers_test.go
Normal file
37
internal/app/analyze_evidence_test_helpers_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setAppAnalyzeEvidence(m *manifest.Manifest, key, relativePath string, body []byte) {
|
||||||
|
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC)
|
||||||
|
record := m.Stages["analyze"]
|
||||||
|
if record == nil {
|
||||||
|
record = &manifest.StageRecord{Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now}
|
||||||
|
m.Stages["analyze"] = record
|
||||||
|
}
|
||||||
|
if record.AnalyzeArtifacts == nil {
|
||||||
|
record.AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{}
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
record.AnalyzeArtifacts[key] = manifest.AnalyzeArtifactRecord{
|
||||||
|
Key: key, Status: manifest.AnalyzeArtifactCurrent,
|
||||||
|
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
|
||||||
|
Fingerprint: strings.Repeat("1", 64),
|
||||||
|
Output: &manifest.ArtifactRecord{
|
||||||
|
Kind: "scriptorium_artifact", SourceID: artifacts.ConfiguredArtifactSourceID(key), LocalPath: relativePath,
|
||||||
|
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
|
||||||
|
ProducerRunID: "run-1", Checksum: hex.EncodeToString(digest[:]),
|
||||||
|
},
|
||||||
|
OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/app/analyze_projection.go
Normal file
134
internal/app/analyze_projection.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type validatedAnalyzeProjection struct {
|
||||||
|
session map[string]manifest.AnalyzeArtifactRecord
|
||||||
|
invocation map[string]manifest.AnalyzeArtifactRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
type analyzeStateSnapshot struct {
|
||||||
|
version int
|
||||||
|
records map[string]manifest.AnalyzeArtifactRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureAnalyzeState(manifestValue *manifest.Manifest, stageName string) analyzeStateSnapshot {
|
||||||
|
if manifestValue == nil || stageName != "analyze" || manifestValue.Stages["analyze"] == nil {
|
||||||
|
return analyzeStateSnapshot{}
|
||||||
|
}
|
||||||
|
record := manifestValue.Stages["analyze"]
|
||||||
|
return analyzeStateSnapshot{
|
||||||
|
version: record.AnalyzeStateVersion,
|
||||||
|
records: manifest.CloneAnalyzeArtifactCollection(record.AnalyzeArtifacts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreAnalyzeState(manifestValue *manifest.Manifest, snapshot analyzeStateSnapshot) {
|
||||||
|
if manifestValue == nil || manifestValue.Stages["analyze"] == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record := manifestValue.Stages["analyze"]
|
||||||
|
record.AnalyzeStateVersion = snapshot.version
|
||||||
|
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(snapshot.records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSuccessfulAnalyzeProjection(stageName string, result *stage.StageResult) (*validatedAnalyzeProjection, error) {
|
||||||
|
if result == nil || result.AnalyzeState == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if stageName != "analyze" {
|
||||||
|
return nil, fmt.Errorf("stage %q returned analyze-owned state projection", stageName)
|
||||||
|
}
|
||||||
|
if result.Disposition == stage.StageDispositionSkipped {
|
||||||
|
return nil, fmt.Errorf("skipped analyze result cannot contain analyze-owned state projection")
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 0 {
|
||||||
|
return nil, fmt.Errorf("analyze result with state projection cannot contain ordinary outputs")
|
||||||
|
}
|
||||||
|
return validateAndCloneAnalyzeProjection(result.AnalyzeState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFailedAnalyzeProjection(stageName string, result *stage.StageResult) (*validatedAnalyzeProjection, error) {
|
||||||
|
if result == nil || result.AnalyzeState == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if stageName != "analyze" {
|
||||||
|
return nil, fmt.Errorf("stage %q returned analyze-owned state projection with an error", stageName)
|
||||||
|
}
|
||||||
|
if result.Disposition != stage.StageDispositionSucceeded || result.SkipReason != "" || len(result.Outputs) != 0 || len(result.Logs) != 0 || len(result.GeneratedConfigs) != 0 || len(result.Metadata) != 0 {
|
||||||
|
return nil, fmt.Errorf("analyze result with an error may contain only analyze-owned state projection")
|
||||||
|
}
|
||||||
|
return validateAndCloneAnalyzeProjection(result.AnalyzeState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAndCloneAnalyzeProjection(projection *stage.AnalyzeStateProjection) (*validatedAnalyzeProjection, error) {
|
||||||
|
session := manifest.CloneAnalyzeArtifactCollection(projection.Session)
|
||||||
|
invocation := manifest.CloneAnalyzeArtifactCollection(projection.Invocation)
|
||||||
|
if err := manifest.ValidateAnalyzeArtifactCollection(manifest.AnalyzeStateContractVersion, session); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate reconciled session analyze state: %w", err)
|
||||||
|
}
|
||||||
|
if err := manifest.ValidateAnalyzeArtifactCollection(manifest.AnalyzeStateContractVersion, invocation); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate invocation analyze state: %w", err)
|
||||||
|
}
|
||||||
|
for key, invocationRecord := range invocation {
|
||||||
|
sessionRecord, ok := session[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invocation analyze artifact %q is absent from reconciled session state", key)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(invocationRecord, sessionRecord) {
|
||||||
|
return nil, fmt.Errorf("invocation analyze artifact %q contradicts reconciled session state", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &validatedAnalyzeProjection{session: session, invocation: invocation}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAnalyzeProjection(
|
||||||
|
sessionManifest *manifest.Manifest,
|
||||||
|
runManifest *manifest.RunManifest,
|
||||||
|
projection *validatedAnalyzeProjection,
|
||||||
|
) {
|
||||||
|
if projection == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sessionManifest != nil && sessionManifest.Stages["analyze"] != nil {
|
||||||
|
record := sessionManifest.Stages["analyze"]
|
||||||
|
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.session)
|
||||||
|
}
|
||||||
|
if runManifest != nil && runManifest.Stages["analyze"] != nil {
|
||||||
|
record := runManifest.Stages["analyze"]
|
||||||
|
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.invocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeProjectionOutputs(records map[string]manifest.AnalyzeArtifactRecord, producerRunID string) []manifest.ArtifactRecord {
|
||||||
|
keys := make([]string, 0, len(records))
|
||||||
|
for key, record := range records {
|
||||||
|
if record.Status != manifest.AnalyzeArtifactCurrent || record.Output == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if producerRunID != "" && record.ProducerRunID != producerRunID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
outputs := make([]manifest.ArtifactRecord, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
record := manifest.CloneAnalyzeArtifactCollection(map[string]manifest.AnalyzeArtifactRecord{key: records[key]})[key]
|
||||||
|
output := *record.Output
|
||||||
|
if output.ProducerRunID == "" {
|
||||||
|
output.ProducerRunID = record.ProducerRunID
|
||||||
|
}
|
||||||
|
outputs = append(outputs, output)
|
||||||
|
}
|
||||||
|
return outputs
|
||||||
|
}
|
||||||
346
internal/app/analyze_projection_test.go
Normal file
346
internal/app/analyze_projection_test.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectionStage struct {
|
||||||
|
name string
|
||||||
|
run func(*stage.Env, *manifest.Manifest) (*stage.StageResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s projectionStage) Name() string { return s.name }
|
||||||
|
func (s projectionStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
return s.run(env, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesProjectsSeparateSessionAndInvocationAnalyzeState(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
oldAt := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
oldRecord := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", oldAt)
|
||||||
|
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
newRecord := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
staleRecord := appAnalyzeRecord("quest_log", manifest.AnalyzeArtifactStale, m.RunID, time.Now().UTC())
|
||||||
|
session := map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": oldRecord,
|
||||||
|
"quest_log": staleRecord,
|
||||||
|
"session_recap": newRecord,
|
||||||
|
}
|
||||||
|
return &stage.StageResult{
|
||||||
|
Logs: []string{"aggregate-analyze.log"},
|
||||||
|
AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: session,
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": oldRecord,
|
||||||
|
"session_recap": newRecord,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
summary, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
sessionManifest, err := store.Load(context.Background(), summary.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(session) error = %v", err)
|
||||||
|
}
|
||||||
|
analyze := sessionManifest.Stages["analyze"]
|
||||||
|
if analyze.AnalyzeStateVersion != manifest.AnalyzeStateContractVersion || len(analyze.AnalyzeArtifacts) != 3 {
|
||||||
|
t.Fatalf("session analyze state = %#v", analyze)
|
||||||
|
}
|
||||||
|
if got := analyzeArtifactOutputKeys(analyze.Outputs); !reflect.DeepEqual(got, []string{"player_handout", "session_recap"}) {
|
||||||
|
t.Fatalf("session aggregate outputs = %#v, want current records only", got)
|
||||||
|
}
|
||||||
|
if len(analyze.Logs) != 1 || analyze.Logs[0] != "aggregate-analyze.log" {
|
||||||
|
t.Fatalf("session aggregate logs = %#v", analyze.Logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
runManifest, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadRun() error = %v", err)
|
||||||
|
}
|
||||||
|
runAnalyze := runManifest.Stages["analyze"]
|
||||||
|
if len(runAnalyze.AnalyzeArtifacts) != 2 || runAnalyze.AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
t.Fatalf("invocation analyze state = %#v", runAnalyze.AnalyzeArtifacts)
|
||||||
|
}
|
||||||
|
if got := analyzeArtifactOutputKeys(runAnalyze.Outputs); !reflect.DeepEqual(got, []string{"session_recap"}) {
|
||||||
|
t.Fatalf("invocation outputs = %#v, want produced artifact only", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesPersistsRestrictedAnalyzeStateOnPartialError(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, now)
|
||||||
|
seed.Campaign = cfg.Session.Campaign
|
||||||
|
seed.MarkStageSucceeded("analyze", now, nil)
|
||||||
|
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now),
|
||||||
|
}
|
||||||
|
seed.MarkStageSucceeded("publish", now, nil)
|
||||||
|
saveBoundedManifest(t, cfg, seed)
|
||||||
|
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
unrelated := seed.Stages["analyze"].AnalyzeArtifacts["player_handout"]
|
||||||
|
completed := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
failed := appAnalyzeRecord("quest_log", manifest.AnalyzeArtifactFailed, m.RunID, time.Now().UTC())
|
||||||
|
session := map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": unrelated,
|
||||||
|
"quest_log": failed,
|
||||||
|
"session_recap": completed,
|
||||||
|
}
|
||||||
|
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: session,
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"quest_log": failed,
|
||||||
|
"session_recap": completed,
|
||||||
|
},
|
||||||
|
}}, errors.New("quest log failed")
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: true})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "quest log failed") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(session) error = %v", err)
|
||||||
|
}
|
||||||
|
analyze := loaded.Stages["analyze"]
|
||||||
|
if analyze.Status != manifest.StatusFailed || len(analyze.Outputs) != 0 {
|
||||||
|
t.Fatalf("aggregate analyze state = %#v, want failed without outputs", analyze)
|
||||||
|
}
|
||||||
|
if analyze.AnalyzeArtifacts["player_handout"].Status != manifest.AnalyzeArtifactCurrent ||
|
||||||
|
analyze.AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent ||
|
||||||
|
analyze.AnalyzeArtifacts["quest_log"].Status != manifest.AnalyzeArtifactFailed {
|
||||||
|
t.Fatalf("partial session projection = %#v", analyze.AnalyzeArtifacts)
|
||||||
|
}
|
||||||
|
if loaded.Stages["publish"].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("publish status = %q, want stale", loaded.Stages["publish"].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
runsDir := artifacts.SessionRunsDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
entries, err := os.ReadDir(runsDir)
|
||||||
|
if err != nil || len(entries) != 1 {
|
||||||
|
t.Fatalf("run directory entries = %#v, error = %v", entries, err)
|
||||||
|
}
|
||||||
|
runManifest, err := store.LoadRun(context.Background(), filepath.Join(runsDir, entries[0].Name(), "manifest.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadRun() error = %v", err)
|
||||||
|
}
|
||||||
|
runAnalyze := runManifest.Stages["analyze"]
|
||||||
|
if runAnalyze.Status != manifest.StatusFailed || len(runAnalyze.AnalyzeArtifacts) != 2 || len(runAnalyze.Outputs) != 0 {
|
||||||
|
t.Fatalf("partial invocation projection = %#v", runAnalyze)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRejectsInvalidAnalyzeProjectionWithoutReplacingPriorState(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
prior := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now)
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, now)
|
||||||
|
seed.Campaign = cfg.Session.Campaign
|
||||||
|
seed.MarkStageSucceeded("analyze", now, nil)
|
||||||
|
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{"player_handout": prior}
|
||||||
|
saveBoundedManifest(t, cfg, seed)
|
||||||
|
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
invalid := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
invalid.Output.Checksum = "invalid"
|
||||||
|
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": invalid},
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": invalid},
|
||||||
|
}}, errors.New("analysis failed")
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: true})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "checksum") {
|
||||||
|
t.Fatalf("executeStages() error = %v, want projection validation failure", err)
|
||||||
|
}
|
||||||
|
loaded, loadErr := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("Load() error = %v", loadErr)
|
||||||
|
}
|
||||||
|
if len(loaded.Stages["analyze"].AnalyzeArtifacts) != 1 || !reflect.DeepEqual(loaded.Stages["analyze"].AnalyzeArtifacts["player_handout"], prior) {
|
||||||
|
t.Fatalf("prior state replaced by invalid projection: %#v", loaded.Stages["analyze"].AnalyzeArtifacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRollsBackAnalyzeAuthorityWhenProjectionSaveFails(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
prior := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now)
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, now)
|
||||||
|
seed.Campaign = cfg.Session.Campaign
|
||||||
|
seed.MarkStageSucceeded("analyze", now, nil)
|
||||||
|
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{"player_handout": prior}
|
||||||
|
saveBoundedManifest(t, cfg, seed)
|
||||||
|
|
||||||
|
stageReturned := false
|
||||||
|
store := &analyzeProjectionFailingStore{delegate: &manifest.LocalStore{}, shouldFail: func(m *manifest.Manifest) bool {
|
||||||
|
return stageReturned && m.Stages["analyze"] != nil && m.Stages["analyze"].Status == manifest.StatusSucceeded && m.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status == manifest.AnalyzeArtifactCurrent
|
||||||
|
}}
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
stageReturned = true
|
||||||
|
newRecord := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": prior,
|
||||||
|
"session_recap": newRecord,
|
||||||
|
},
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": newRecord},
|
||||||
|
}}, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{
|
||||||
|
Force: true,
|
||||||
|
Env: &Env{ManifestStore: store},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "injected analyze projection save failure") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if !store.failed {
|
||||||
|
t.Fatal("projection persistence failure was not injected")
|
||||||
|
}
|
||||||
|
loaded, loadErr := store.delegate.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("Load() error = %v", loadErr)
|
||||||
|
}
|
||||||
|
analyze := loaded.Stages["analyze"]
|
||||||
|
if analyze.Status != manifest.StatusFailed || len(analyze.AnalyzeArtifacts) != 1 || !reflect.DeepEqual(analyze.AnalyzeArtifacts["player_handout"], prior) {
|
||||||
|
t.Fatalf("durable analyze state after rollback = %#v", analyze)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRejectsAnalyzeProjectionFromOtherStage(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
stageToRun := projectionStage{name: "prepare", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
record := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
|
||||||
|
}}, nil
|
||||||
|
}}
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "returned analyze-owned state projection") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRejectsContradictoryAnalyzeResultWithError(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
record := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
return &stage.StageResult{
|
||||||
|
Outputs: []artifacts.Ref{{Kind: "session_recap", RelativePath: "artifacts/session-recap.md"}},
|
||||||
|
AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
|
||||||
|
},
|
||||||
|
}, errors.New("analysis failed")
|
||||||
|
}}
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "may contain only analyze-owned state projection") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesExposesSelectedForceDecisionToStage(t *testing.T) {
|
||||||
|
for _, force := range []bool{false, true} {
|
||||||
|
t.Run(strings.ToLower(strings.TrimSpace(map[bool]string{false: "ordinary", true: "forced"}[force])), func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
captured := !force
|
||||||
|
stageToRun := projectionStage{name: "prepare", run: func(env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
captured = env.Force
|
||||||
|
return &stage.StageResult{}, nil
|
||||||
|
}}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: force}); err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if captured != force {
|
||||||
|
t.Fatalf("stage env force = %v, want %v", captured, force)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appAnalyzeRecord(key string, status manifest.AnalyzeArtifactStatus, producerRunID string, at time.Time) manifest.AnalyzeArtifactRecord {
|
||||||
|
record := manifest.AnalyzeArtifactRecord{
|
||||||
|
Key: key,
|
||||||
|
Status: status,
|
||||||
|
ProducerRunID: producerRunID,
|
||||||
|
UpdatedAt: at,
|
||||||
|
}
|
||||||
|
if status == manifest.AnalyzeArtifactFailed {
|
||||||
|
record.Error = "scriptorium failed"
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
if status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
|
||||||
|
record.Fingerprint = strings.Repeat("b", 64)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
|
||||||
|
record.Fingerprint = strings.Repeat("a", 64)
|
||||||
|
record.OutputSize = 42
|
||||||
|
record.Output = &manifest.ArtifactRecord{
|
||||||
|
Kind: key,
|
||||||
|
SourceID: artifacts.ConfiguredArtifactSourceID(key),
|
||||||
|
LocalPath: "artifacts/" + strings.ReplaceAll(key, "_", "-") + ".md",
|
||||||
|
ProducerRunID: producerRunID,
|
||||||
|
Checksum: strings.Repeat("c", 64),
|
||||||
|
Contract: &artifactmodel.ContractMetadata{
|
||||||
|
MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeArtifactOutputKeys(outputs []manifest.ArtifactRecord) []string {
|
||||||
|
keys := make([]string, 0, len(outputs))
|
||||||
|
for _, output := range outputs {
|
||||||
|
keys = append(keys, strings.TrimPrefix(output.SourceID, "narratio.artifact."))
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
type analyzeProjectionFailingStore struct {
|
||||||
|
delegate *manifest.LocalStore
|
||||||
|
shouldFail func(*manifest.Manifest) bool
|
||||||
|
failed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *analyzeProjectionFailingStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
|
||||||
|
return s.delegate.Create(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *analyzeProjectionFailingStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
|
||||||
|
return s.delegate.Load(ctx, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *analyzeProjectionFailingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
||||||
|
if !s.failed && s.shouldFail != nil && s.shouldFail(m) {
|
||||||
|
s.failed = true
|
||||||
|
return errors.New("injected analyze projection save failure")
|
||||||
|
}
|
||||||
|
return s.delegate.Save(ctx, path, m)
|
||||||
|
}
|
||||||
480
internal/app/assembled_workflow_test.go
Normal file
480
internal/app/assembled_workflow_test.go
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAssembledFullRunUsesCanonicalOrderAndBoundedRunManifests(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
canonical := []string{
|
||||||
|
"prepare", "transcribe", "merge", "polish", "normalize", "trim",
|
||||||
|
"render", "extract", "analyze", "publish", "notify",
|
||||||
|
}
|
||||||
|
var order []string
|
||||||
|
stages := make([]stage.Stage, 0, len(canonical))
|
||||||
|
for _, name := range canonical {
|
||||||
|
stages = append(stages, resultStage{name: name, result: &stage.StageResult{}, order: &order})
|
||||||
|
}
|
||||||
|
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(order, canonical) || !reflect.DeepEqual(summary.Executed, canonical) {
|
||||||
|
t.Fatalf("execution order=%#v summary=%#v, want %#v", order, summary.Executed, canonical)
|
||||||
|
}
|
||||||
|
runManifest, err := (&manifest.LocalStore{}).LoadRun(context.Background(), summary.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(runManifest.RequestedStages, canonical) {
|
||||||
|
t.Fatalf("requested stages = %#v, want canonical order", runManifest.RequestedStages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
type capturedRequest struct {
|
||||||
|
stages []string
|
||||||
|
artifacts []string
|
||||||
|
force bool
|
||||||
|
}
|
||||||
|
var captured []capturedRequest
|
||||||
|
original := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = original })
|
||||||
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||||
|
captured = append(captured, capturedRequest{
|
||||||
|
stages: plan.Names(), artifacts: append([]string(nil), options.SelectedArtifacts...), force: options.Force,
|
||||||
|
})
|
||||||
|
return &RunSummary{SessionID: "2026-05-03", ManifestPath: manifestPathForConfig(workspaceRoot)}, nil
|
||||||
|
}
|
||||||
|
base := []string{
|
||||||
|
"2026-05-03", "--force", "--from", "extract", "--through", "analyze",
|
||||||
|
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
|
}
|
||||||
|
if err := Run(context.Background(), base, &bytes.Buffer{}); err != nil {
|
||||||
|
t.Fatalf("canonical unselected Run() error = %v", err)
|
||||||
|
}
|
||||||
|
selected := append(append([]string(nil), base...), "--artifacts", "session_recap")
|
||||||
|
if err := Run(context.Background(), selected, &bytes.Buffer{}); err != nil {
|
||||||
|
t.Fatalf("canonical selected Run() error = %v", err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
alias := []string{
|
||||||
|
"regenerate-artifacts", "2026-05-03", "--artifacts", "session_recap",
|
||||||
|
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
|
}
|
||||||
|
if code := Execute(alias, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("alias exit=%d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if len(captured) != 3 {
|
||||||
|
t.Fatalf("captured requests = %#v", captured)
|
||||||
|
}
|
||||||
|
wantStages := []string{"extract", "analyze"}
|
||||||
|
if !reflect.DeepEqual(captured[0].stages, wantStages) || len(captured[0].artifacts) != 0 || !captured[0].force {
|
||||||
|
t.Fatalf("unselected request = %#v", captured[0])
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured[1], captured[2]) || !reflect.DeepEqual(captured[1].stages, wantStages) ||
|
||||||
|
!reflect.DeepEqual(captured[1].artifacts, []string{"session_recap"}) || !captured[1].force {
|
||||||
|
t.Fatalf("canonical=%#v alias=%#v, want identical bounded request", captured[1], captured[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembledForcedSiblingIndependenceAndFailureBoundary(t *testing.T) {
|
||||||
|
for _, selected := range []string{"render", "extract"} {
|
||||||
|
t.Run(selected, func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
seedAllStagesSucceeded(t, cfg)
|
||||||
|
plan := mustBoundedPlan(t, selected, selected)
|
||||||
|
runs := 0
|
||||||
|
plan.stages = []stage.Stage{countingStage{name: selected, runs: &runs}}
|
||||||
|
summary, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded := loadAssembledManifest(t, cfg)
|
||||||
|
sibling := "render"
|
||||||
|
if selected == "render" {
|
||||||
|
sibling = "extract"
|
||||||
|
}
|
||||||
|
if loaded.Stages[sibling].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("%s sibling = %#v, want succeeded", sibling, loaded.Stages[sibling])
|
||||||
|
}
|
||||||
|
for _, dependent := range []string{"analyze", "publish", "notify"} {
|
||||||
|
if loaded.Stages[dependent].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("%s status = %q, want stale", dependent, loaded.Stages[dependent].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runManifest, err := (&manifest.LocalStore{}).LoadRun(context.Background(), summary.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(runManifest.RequestedStages, []string{selected}) || len(runManifest.Stages) != 1 {
|
||||||
|
t.Fatalf("bounded run manifest = %#v", runManifest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("stop on failure", func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
seedAllStagesSucceeded(t, cfg)
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
plan.stages = []stage.Stage{failingStage{name: "render", err: context.Canceled}}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("forced render failure returned nil")
|
||||||
|
}
|
||||||
|
loaded := loadAssembledManifest(t, cfg)
|
||||||
|
if loaded.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("extract sibling = %#v", loaded.Stages["extract"])
|
||||||
|
}
|
||||||
|
for _, outside := range []string{"analyze", "publish", "notify"} {
|
||||||
|
if loaded.Stages[outside].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("outside stage %s = %#v, want stale and unexecuted", outside, loaded.Stages[outside])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembledLegacyAnalyzeTransitionPublishesOnlyCurrentRecords(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
||||||
|
"player_handout": {Enabled: true, PromptID: "dnd.player_handout", OutputPath: "artifacts/player_handout.md"},
|
||||||
|
"session_recap": {Enabled: true, PromptID: "dnd.session_recap", OutputPath: "artifacts/session_recap.md"},
|
||||||
|
}}
|
||||||
|
cfg.Pipeline.Storage.Backend = config.StorageBackendS3
|
||||||
|
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "archive", RootPrefix: "dnd"}
|
||||||
|
cfg.Pipeline.Publish = &config.PublishConfig{
|
||||||
|
Enabled: boolPtr(true), UploadRun: boolPtr(true),
|
||||||
|
Outputs: []config.PublishOutputRule{
|
||||||
|
{Source: artifacts.ConfiguredArtifactSourceID("player_handout"), Dest: "artifacts/player_handout.md", Required: boolPtr(true)},
|
||||||
|
{Source: artifacts.ConfiguredArtifactSourceID("session_recap"), Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legacyHandout := []byte("legacy handout\n")
|
||||||
|
legacyRecap := []byte("legacy recap\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(paths.ArtifactsDir, "player_handout.md"), string(legacyHandout))
|
||||||
|
mustWriteTestFile(t, filepath.Join(paths.ArtifactsDir, "session_recap.md"), string(legacyRecap))
|
||||||
|
|
||||||
|
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
m := manifest.New(cfg.Session.SessionID, now)
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
for index, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
||||||
|
m.MarkStageSucceeded(name, now.Add(time.Duration(index)*time.Minute), nil)
|
||||||
|
}
|
||||||
|
// Historical manifests can show extract completing before render. Status,
|
||||||
|
// not the old relative timestamps, is the compatibility authority.
|
||||||
|
m.MarkStageSucceeded("extract", now.Add(10*time.Minute), nil)
|
||||||
|
m.MarkStageSucceeded("render", now.Add(11*time.Minute), nil)
|
||||||
|
m.MarkStageSucceeded("analyze", now.Add(12*time.Minute), []manifest.ArtifactRecord{
|
||||||
|
{Kind: "player_handout", LocalPath: "artifacts/player_handout.md"},
|
||||||
|
{Kind: "session_recap", LocalPath: "artifacts/session_recap.md"},
|
||||||
|
})
|
||||||
|
if err := (&manifest.LocalStore{}).Save(context.Background(), paths.ManifestPath, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
analyze, err := stage.Select("analyze")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fake := &scriptorium.FakeRunner{}
|
||||||
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
|
||||||
|
SelectedArtifacts: []string{"session_recap"}, Env: &Env{Scriptorium: fake},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("partial legacy regeneration: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 1 || fake.RunRequests[0].PromptID != "dnd.session_recap" {
|
||||||
|
t.Fatalf("partial requests = %#v", fake.RunRequests)
|
||||||
|
}
|
||||||
|
afterPartial := loadAssembledManifest(t, cfg)
|
||||||
|
if len(afterPartial.Stages["analyze"].AnalyzeArtifacts) != 1 ||
|
||||||
|
afterPartial.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
t.Fatalf("partial state = %#v", afterPartial.Stages["analyze"].AnalyzeArtifacts)
|
||||||
|
}
|
||||||
|
for _, transcriptStage := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"} {
|
||||||
|
if afterPartial.Stages[transcriptStage].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("legacy transition invalidated %s: %#v", transcriptStage, afterPartial.Stages[transcriptStage])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts)
|
||||||
|
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := artifacts.BootstrapRuntimeCatalog(configured, effective, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, afterPartial, configured)
|
||||||
|
if entry, ok := catalog.Lookup(artifacts.ConfiguredArtifactSourceID("player_handout")); !ok || entry.Available {
|
||||||
|
t.Fatalf("legacy unselected handout catalog entry = %#v, present=%v", entry, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
full, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{Env: &Env{Scriptorium: fake}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("full regeneration: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 2 || fake.RunRequests[1].PromptID != "dnd.player_handout" {
|
||||||
|
t.Fatalf("full requests = %#v, want only missing handout added", fake.RunRequests)
|
||||||
|
}
|
||||||
|
fullRun, err := (&manifest.LocalStore{}).LoadRun(context.Background(), full.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := analyzeArtifactOutputKeys(fullRun.Stages["analyze"].Outputs); !reflect.DeepEqual(got, []string{"player_handout"}) {
|
||||||
|
t.Fatalf("full invocation outputs = %#v, want newly generated handout only", got)
|
||||||
|
}
|
||||||
|
afterFull := loadAssembledManifest(t, cfg)
|
||||||
|
for _, key := range []string{"player_handout", "session_recap"} {
|
||||||
|
if afterFull.Stages["analyze"].AnalyzeArtifacts[key].Status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
t.Fatalf("%s state = %#v", key, afterFull.Stages["analyze"].AnalyzeArtifacts[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
publish, err := stage.Select("publish")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
remote := &storage.FakeBackend{}
|
||||||
|
published, err := executeStages(context.Background(), cfg, []stage.Stage{publish}, RunOptions{Env: &Env{ObjectStore: remote}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("publish current records: %v", err)
|
||||||
|
}
|
||||||
|
afterPublish := loadAssembledManifest(t, cfg)
|
||||||
|
if got := afterPublish.Stages["publish"].Metadata["published_files_uploaded"]; got != float64(2) {
|
||||||
|
t.Fatalf("published files = %#v, want 2", got)
|
||||||
|
}
|
||||||
|
publishRun, err := (&manifest.LocalStore{}).LoadRun(context.Background(), published.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(publishRun.RequestedStages, []string{"publish"}) || publishRun.Stages["analyze"] != nil {
|
||||||
|
t.Fatalf("publish run manifest = %#v", publishRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths()
|
||||||
|
workspacePath := filepath.Join(filepath.Dir(pipelinePath), "workspace")
|
||||||
|
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("example workspace stat = %v, want absent", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validated bytes.Buffer
|
||||||
|
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validated); err != nil {
|
||||||
|
t.Fatalf("validate production default: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(validated.String(), "profile=production") {
|
||||||
|
t.Fatalf("validate output = %q, want production profile", validated.String())
|
||||||
|
}
|
||||||
|
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigShow, ConfigSources} {
|
||||||
|
if err := command(context.Background(), []string{
|
||||||
|
"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing",
|
||||||
|
}, io.Discard); err != nil {
|
||||||
|
t.Fatalf("testing inspection command %T: %v", command, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var diff bytes.Buffer
|
||||||
|
if err := ConfigDiff(context.Background(), []string{
|
||||||
|
"production", "testing", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||||
|
}, &diff); err != nil {
|
||||||
|
t.Fatalf("compare profiles: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diff.String(), "audita.model") || !strings.Contains(diff.String(), "character_items_arannis.profile_id") {
|
||||||
|
t.Fatalf("profile diff = %q, want model and expanded family changes", diff.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
common := []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath, "--profile", "testing",
|
||||||
|
}
|
||||||
|
var planned bytes.Buffer
|
||||||
|
if err := Plan(context.Background(), common, &planned); err != nil {
|
||||||
|
t.Fatalf("plan testing bundle: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(planned.String(), "profile=testing") {
|
||||||
|
t.Fatalf("plan output = %q, want testing provenance", planned.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
original := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = original })
|
||||||
|
var capturedCfg *config.Config
|
||||||
|
var capturedPlan BoundedPlan
|
||||||
|
var capturedOptions RunOptions
|
||||||
|
executeStagesFn = func(_ context.Context, cfg *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||||
|
capturedCfg = cfg
|
||||||
|
capturedPlan = plan
|
||||||
|
capturedOptions = options
|
||||||
|
return &RunSummary{SessionID: cfg.Session.SessionID, ManifestPath: manifestPathForConfig(cfg.Pipeline.Workspace.Root)}, nil
|
||||||
|
}
|
||||||
|
runArgs := append(append([]string(nil), common...), "--from", "analyze", "--through", "analyze", "--artifacts", "character_items_arannis")
|
||||||
|
var runOut bytes.Buffer
|
||||||
|
if err := Run(context.Background(), runArgs, &runOut); err != nil {
|
||||||
|
t.Fatalf("bounded family run: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(capturedPlan.Names(), []string{"analyze"}) {
|
||||||
|
t.Fatalf("bounded plan = %#v, want analyze only", capturedPlan.Names())
|
||||||
|
}
|
||||||
|
if profile, ok := config.SelectedPipelineProfile(capturedCfg.Pipeline); !ok || profile.Name != "testing" {
|
||||||
|
t.Fatalf("captured profile = %#v, selected=%t", profile, ok)
|
||||||
|
}
|
||||||
|
if digest := config.EffectivePipelineDigest(capturedCfg.Pipeline); digest == "" || !strings.Contains(runOut.String(), "digest="+digest) {
|
||||||
|
t.Fatalf("run output = %q, want effective digest %q", runOut.String(), digest)
|
||||||
|
}
|
||||||
|
if got := capturedOptions.EffectiveArtifacts.Keys(); !reflect.DeepEqual(got, []string{"character_items_arannis"}) {
|
||||||
|
t.Fatalf("effective artifact keys = %#v", got)
|
||||||
|
}
|
||||||
|
if origin, ok := capturedOptions.EffectiveArtifacts.Origin("character_items_arannis"); !ok || origin.Family != "character_items" || origin.CharacterID != "arannis" {
|
||||||
|
t.Fatalf("family origin = %#v, present=%t", origin, ok)
|
||||||
|
}
|
||||||
|
fullFamily, err := resolveEffectiveArtifacts(capturedCfg, []string{"character_items"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve complete family: %v", err)
|
||||||
|
}
|
||||||
|
if got := fullFamily.Keys(); !reflect.DeepEqual(got, []string{"character_items_arannis", "character_items_brenna"}) {
|
||||||
|
t.Fatalf("full family keys = %#v", got)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("inspection or bounded run created example workspace: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembledSplitBundleProfileSwitchRecordsProvenanceAndLimitsReuse(t *testing.T) {
|
||||||
|
production := loadAssembledSplitBundleConfig(t, "")
|
||||||
|
testingCfg := loadAssembledSplitBundleConfig(t, "testing")
|
||||||
|
workspace := t.TempDir()
|
||||||
|
production.Pipeline.Workspace.Root = workspace
|
||||||
|
testingCfg.Pipeline.Workspace.Root = workspace
|
||||||
|
|
||||||
|
names := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"}
|
||||||
|
providers := make([]stage.SemanticConfigFingerprinter, len(names))
|
||||||
|
seed := manifest.New(production.Session.SessionID, time.Now().UTC())
|
||||||
|
seed.Campaign = production.Session.Campaign
|
||||||
|
for _, candidate := range stage.All() {
|
||||||
|
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
for index, name := range names {
|
||||||
|
providers[index] = canonicalSemanticProvider(t, name)
|
||||||
|
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: production})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("production %s fingerprint: %v", name, err)
|
||||||
|
}
|
||||||
|
seed.Stages[name].SemanticConfig = &fingerprint
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
if err := store.Save(context.Background(), manifestPathForConfig(workspace), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
productionRuns := make([]int, len(names))
|
||||||
|
productionStages := assembledSemanticStages(names, providers, productionRuns)
|
||||||
|
productionSummary, err := executeStages(context.Background(), production, productionStages, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("record production provenance: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(productionRuns, make([]int, len(names))) {
|
||||||
|
t.Fatalf("production runs = %v, want complete reuse", productionRuns)
|
||||||
|
}
|
||||||
|
assertAssembledProvenance(t, store, productionSummary, "production", config.EffectivePipelineDigest(production.Pipeline))
|
||||||
|
|
||||||
|
testingRuns := make([]int, len(names))
|
||||||
|
testingStages := assembledSemanticStages(names, providers, testingRuns)
|
||||||
|
testingSummary, err := executeStages(context.Background(), testingCfg, testingStages, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch to testing profile: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := testingRuns, []int{0, 0, 0, 1, 1, 1, 1}; !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("testing profile runs = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
assertAssembledProvenance(t, store, testingSummary, "testing", config.EffectivePipelineDigest(testingCfg.Pipeline))
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembledSplitBundlePaths() (pipelinePath, campaignPath, sessionPath string) {
|
||||||
|
examplesDir := filepath.Join("..", "..", "examples")
|
||||||
|
return filepath.Join(examplesDir, "production-testing", "pipeline.yml"),
|
||||||
|
filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml"),
|
||||||
|
filepath.Join(examplesDir, "session.local-audio.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAssembledSplitBundleConfig(t *testing.T, profile string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths()
|
||||||
|
options := config.SessionLoadOptions{}
|
||||||
|
if profile != "" {
|
||||||
|
options.Profile = &profile
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load split bundle profile %q: %v", profile, err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func assembledSemanticStages(names []string, providers []stage.SemanticConfigFingerprinter, runs []int) []stage.Stage {
|
||||||
|
stages := make([]stage.Stage, 0, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
stages = append(stages, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
|
||||||
|
}
|
||||||
|
return stages
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertAssembledProvenance(t *testing.T, store *manifest.LocalStore, summary *RunSummary, profile, digest string) {
|
||||||
|
t.Helper()
|
||||||
|
session, err := store.Load(context.Background(), summary.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, value := range []*manifest.EffectiveConfigProvenance{session.EffectiveConfig, run.EffectiveConfig} {
|
||||||
|
if value == nil || value.SelectedProfile == nil || value.SelectedProfile.Name != profile || value.EffectiveConfigDigest != digest {
|
||||||
|
t.Fatalf("effective configuration provenance = %#v, want profile=%q digest=%q", value, profile, digest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAllStagesSucceeded(t *testing.T, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
for _, name := range canonicalStageNames() {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAssembledManifest(t *testing.T, cfg *config.Config) *manifest.Manifest {
|
||||||
|
t.Helper()
|
||||||
|
m, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestPathForConfig(workspaceRoot string) string {
|
||||||
|
return artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
||||||
|
}
|
||||||
62
internal/app/bounded_prerequisites.go
Normal file
62
internal/app/bounded_prerequisites.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateBoundedPrerequisites(plan BoundedPlan, m *manifest.Manifest) error {
|
||||||
|
if !plan.HasExplicitBounds() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, name := range plan.PrefixNames() {
|
||||||
|
status := "absent"
|
||||||
|
if m != nil && m.Stages != nil && m.Stages[name] != nil {
|
||||||
|
stageStatus := m.Stages[name].Status
|
||||||
|
if stageStatus == manifest.StatusSucceeded || stageStatus == manifest.StatusSkipped {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stageStatus != "" {
|
||||||
|
status = string(stageStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"prerequisite stage %q has unusable status %q before selected start %q; widen the range with --from %s or recover %s explicitly",
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
plan.From(),
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectBoundedPrerequisites(ctx context.Context, cfg *config.Config, plan BoundedPlan, store manifest.Store) error {
|
||||||
|
if !plan.HasExplicitBounds() || len(plan.PrefixNames()) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
||||||
|
return fmt.Errorf("bounded prerequisite inspection requires resolved pipeline and session configuration")
|
||||||
|
}
|
||||||
|
if store == nil {
|
||||||
|
store = &manifest.LocalStore{}
|
||||||
|
}
|
||||||
|
path := artifacts.SessionManifestPathForCampaign(
|
||||||
|
cfg.Pipeline.Workspace.Root,
|
||||||
|
cfg.Session.Campaign,
|
||||||
|
cfg.Session.SessionID,
|
||||||
|
)
|
||||||
|
m, present, err := loadManifestAtPathIfPresent(ctx, store, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
m = nil
|
||||||
|
}
|
||||||
|
return validateBoundedPrerequisites(plan, m)
|
||||||
|
}
|
||||||
345
internal/app/bounded_prerequisites_test.go
Normal file
345
internal/app/bounded_prerequisites_test.go
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateBoundedPrerequisitesRejectsFirstUnusablePrefixStatus(t *testing.T) {
|
||||||
|
plan := mustBoundedPlan(t, "render", "extract")
|
||||||
|
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
status manifest.StageStatus
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "absent", want: "absent"},
|
||||||
|
{name: "pending", status: manifest.StatusPending, want: "pending"},
|
||||||
|
{name: "running", status: manifest.StatusRunning, want: "running"},
|
||||||
|
{name: "failed", status: manifest.StatusFailed, want: "failed"},
|
||||||
|
{name: "stale", status: manifest.StatusStale, want: "stale"},
|
||||||
|
{name: "interrupted", status: manifest.StatusInterrupted, want: "interrupted"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
m := manifest.New("session", now)
|
||||||
|
if test.status != "" {
|
||||||
|
m.Stages["prepare"] = &manifest.StageRecord{Name: "prepare", Status: test.status}
|
||||||
|
}
|
||||||
|
// A later terminal prefix must not hide the first unusable one.
|
||||||
|
m.MarkStageSucceeded("transcribe", now, nil)
|
||||||
|
err := validateBoundedPrerequisites(plan, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("validateBoundedPrerequisites() error = nil")
|
||||||
|
}
|
||||||
|
for _, detail := range []string{`stage "prepare"`, `status "` + test.want + `"`, `selected start "render"`, "--from prepare", "recover prepare"} {
|
||||||
|
if !strings.Contains(err.Error(), detail) {
|
||||||
|
t.Fatalf("error = %q, want detail %q", err, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBoundedPrerequisitesAcceptsSucceededAndSkippedPrefix(t *testing.T) {
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New("session", time.Now().UTC())
|
||||||
|
for index, name := range plan.PrefixNames() {
|
||||||
|
if index%2 == 0 {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
} else {
|
||||||
|
m.MarkStageSkipped(name, time.Now().UTC(), "not applicable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateBoundedPrerequisites(plan, m); err != nil {
|
||||||
|
t.Fatalf("validateBoundedPrerequisites() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBoundedPrerequisitesHasNoPrefixAtPrepareAndIgnoresSuffix(t *testing.T) {
|
||||||
|
preparePlan := mustBoundedPlan(t, "prepare", "prepare")
|
||||||
|
if err := validateBoundedPrerequisites(preparePlan, nil); err != nil {
|
||||||
|
t.Fatalf("prepare prerequisite validation error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPlan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New("session", time.Now().UTC())
|
||||||
|
markPrefixSucceeded(m, renderPlan)
|
||||||
|
m.MarkStageFailed("analyze", time.Now().UTC(), "later failure")
|
||||||
|
if err := validateBoundedPrerequisites(renderPlan, m); err != nil {
|
||||||
|
t.Fatalf("suffix status affected prerequisite validation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRejectsBoundedPrerequisitesBeforePersistentMutation(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
m.MarkStageRunning("prepare", time.Now().UTC())
|
||||||
|
manifestPath := saveBoundedManifest(t, cfg, m)
|
||||||
|
before, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read seeded manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &prerequisiteMutationSpy{local: &manifest.LocalStore{}}
|
||||||
|
runs := 0
|
||||||
|
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
|
||||||
|
_, err = executePlan(context.Background(), cfg, plan, RunOptions{
|
||||||
|
Env: &Env{ManifestStore: store},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `stage "prepare" has unusable status "running"`) {
|
||||||
|
t.Fatalf("executeStages() error = %v, want running prerequisite", err)
|
||||||
|
}
|
||||||
|
if runs != 0 || store.creates != 0 || store.saves != 0 {
|
||||||
|
t.Fatalf("runs=%d manifest creates=%d saves=%d, want no mutation", runs, store.creates, store.saves)
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read manifest after rejection: %v", err)
|
||||||
|
}
|
||||||
|
if string(after) != string(before) {
|
||||||
|
t.Fatal("manifest changed after prerequisite rejection")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(artifacts.SessionRunsDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("runs directory stat error = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRechecksBoundedPrerequisitesUnderSessionLock(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
markPrefixSucceeded(m, plan)
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
|
||||||
|
store := &prerequisiteChangingStore{local: &manifest.LocalStore{}}
|
||||||
|
runs := 0
|
||||||
|
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{
|
||||||
|
Env: &Env{ManifestStore: store},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `stage "prepare" has unusable status "running"`) {
|
||||||
|
t.Fatalf("executeStages() error = %v, want changed prerequisite rejection", err)
|
||||||
|
}
|
||||||
|
if store.loads != 2 {
|
||||||
|
t.Fatalf("manifest loads = %d, want preflight and locked reload", store.loads)
|
||||||
|
}
|
||||||
|
if runs != 0 || store.creates != 0 || store.saves != 0 {
|
||||||
|
t.Fatalf("runs=%d manifest creates=%d saves=%d, want no run or manifest mutation", runs, store.creates, store.saves)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesBoundedCompositionUsesOnlySelectedCollaborators(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
stageName string
|
||||||
|
configure func(*config.Config)
|
||||||
|
assertProbe func(*testing.T, *stage.Env)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "render",
|
||||||
|
stageName: "render",
|
||||||
|
assertProbe: func(t *testing.T, env *stage.Env) {
|
||||||
|
if env.Seriatim == nil || env.Notarius != nil || env.Scriptorium != nil {
|
||||||
|
t.Fatalf("render collaborators: seriatim=%v notarius=%v scriptorium=%v", env.Seriatim, env.Notarius, env.Scriptorium)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract",
|
||||||
|
stageName: "extract",
|
||||||
|
configure: func(cfg *config.Config) {
|
||||||
|
cfg.Pipeline.Notarius = &config.NotariusConfig{Enabled: true}
|
||||||
|
},
|
||||||
|
assertProbe: func(t *testing.T, env *stage.Env) {
|
||||||
|
if env.Notarius == nil || env.Scriptorium != nil || env.Seriatim != nil {
|
||||||
|
t.Fatalf("extract collaborators: notarius=%v scriptorium=%v seriatim=%v", env.Notarius, env.Scriptorium, env.Seriatim)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "analyze",
|
||||||
|
stageName: "analyze",
|
||||||
|
assertProbe: func(t *testing.T, env *stage.Env) {
|
||||||
|
if env.Scriptorium == nil || env.Notarius != nil || env.Seriatim != nil || env.WhisperX != nil || env.Audita != nil {
|
||||||
|
t.Fatalf("analyze collaborators: scriptorium=%v notarius=%v seriatim=%v whisperx=%v audita=%v", env.Scriptorium, env.Notarius, env.Seriatim, env.WhisperX, env.Audita)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
if test.configure != nil {
|
||||||
|
test.configure(cfg)
|
||||||
|
}
|
||||||
|
plan := mustBoundedPlan(t, test.stageName, test.stageName)
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
markPrefixSucceeded(m, plan)
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
|
||||||
|
var captured *stage.Env
|
||||||
|
plan.stages = []stage.Stage{collaboratorProbeStage{name: test.stageName, captured: &captured}}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if captured == nil {
|
||||||
|
t.Fatal("selected stage did not run")
|
||||||
|
}
|
||||||
|
test.assertProbe(t, captured)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesBoundedForceStalesButDoesNotRunDependentsOutsideRange(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
runs := 0
|
||||||
|
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if runs != 1 {
|
||||||
|
t.Fatalf("selected render runs = %d, want 1", runs)
|
||||||
|
}
|
||||||
|
loaded, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load manifest: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"analyze", "publish", "notify"} {
|
||||||
|
if loaded.Stages[name].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("stage %q status = %q, want stale", name, loaded.Stages[name].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loaded.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("extract status = %q, want succeeded", loaded.Stages["extract"].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesBoundedFailureStopsWithinSelectedRange(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
plan := mustBoundedPlan(t, "render", "extract")
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
markPrefixSucceeded(m, plan)
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
extractRuns := 0
|
||||||
|
plan.stages = []stage.Stage{
|
||||||
|
failingStage{name: "render", err: errors.New("render failed")},
|
||||||
|
countingStage{name: "extract", runs: &extractRuns},
|
||||||
|
}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "render failed") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if extractRuns != 0 {
|
||||||
|
t.Fatalf("extract runs = %d, want 0", extractRuns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type collaboratorProbeStage struct {
|
||||||
|
name string
|
||||||
|
captured **stage.Env
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s collaboratorProbeStage) Name() string { return s.name }
|
||||||
|
func (s collaboratorProbeStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
*s.captured = env
|
||||||
|
return &stage.StageResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type prerequisiteMutationSpy struct {
|
||||||
|
local *manifest.LocalStore
|
||||||
|
creates int
|
||||||
|
saves int
|
||||||
|
}
|
||||||
|
|
||||||
|
type prerequisiteChangingStore struct {
|
||||||
|
local *manifest.LocalStore
|
||||||
|
loads int
|
||||||
|
creates int
|
||||||
|
saves int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteChangingStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
|
||||||
|
s.creates++
|
||||||
|
return s.local.Create(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteChangingStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
|
||||||
|
s.loads++
|
||||||
|
m, err := s.local.Load(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.loads == 2 {
|
||||||
|
m.MarkStageRunning("prepare", time.Now().UTC())
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteChangingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
||||||
|
s.saves++
|
||||||
|
return s.local.Save(ctx, path, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteMutationSpy) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
|
||||||
|
s.creates++
|
||||||
|
return s.local.Create(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteMutationSpy) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
|
||||||
|
return s.local.Load(ctx, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteMutationSpy) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
||||||
|
s.saves++
|
||||||
|
return s.local.Save(ctx, path, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustBoundedPlan(t *testing.T, from, through string) BoundedPlan {
|
||||||
|
t.Helper()
|
||||||
|
plan, err := BuildBoundedPlan(from, through)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", from, through, err)
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
func markPrefixSucceeded(m *manifest.Manifest, plan BoundedPlan) {
|
||||||
|
for _, name := range plan.PrefixNames() {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBoundedManifest(t *testing.T, cfg *config.Config, m *manifest.Manifest) string {
|
||||||
|
t.Helper()
|
||||||
|
path := manifestPathFor(cfg)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("create manifest directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := (&manifest.LocalStore{}).Save(context.Background(), path, m); err != nil {
|
||||||
|
t.Fatalf("save manifest: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
118
internal/app/bounded_run.go
Normal file
118
internal/app/bounded_run.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type boundedRunRequest struct {
|
||||||
|
Config commonConfigFlags
|
||||||
|
Plan BoundedPlan
|
||||||
|
Force bool
|
||||||
|
SelectedArtifacts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type singletonStringFlag struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
set bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *singletonStringFlag) String() string { return f.value }
|
||||||
|
|
||||||
|
func (f *singletonStringFlag) Set(value string) error {
|
||||||
|
if f.set {
|
||||||
|
return fmt.Errorf("--%s may be specified only once", f.name)
|
||||||
|
}
|
||||||
|
f.value = value
|
||||||
|
f.set = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f singletonStringFlag) pointer() *string {
|
||||||
|
if !f.set {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := f.value
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
type singletonBoolFlag struct {
|
||||||
|
name string
|
||||||
|
value bool
|
||||||
|
set bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *singletonBoolFlag) String() string { return strconv.FormatBool(f.value) }
|
||||||
|
func (f *singletonBoolFlag) IsBoolFlag() bool { return true }
|
||||||
|
|
||||||
|
func (f *singletonBoolFlag) Set(raw string) error {
|
||||||
|
if f.set {
|
||||||
|
return fmt.Errorf("--%s may be specified only once", f.name)
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("--%s requires a boolean value: %w", f.name, err)
|
||||||
|
}
|
||||||
|
f.value = value
|
||||||
|
f.set = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBoundedRunRequest(command string, args []string, help io.Writer) (boundedRunRequest, error) {
|
||||||
|
fs := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||||
|
fs.SetOutput(help)
|
||||||
|
|
||||||
|
var configFlags commonConfigFlags
|
||||||
|
var from singletonStringFlag
|
||||||
|
var through singletonStringFlag
|
||||||
|
var force singletonBoolFlag
|
||||||
|
var selectedArtifacts artifactSelectionFlag
|
||||||
|
from.name = "from"
|
||||||
|
through.name = "through"
|
||||||
|
force.name = "force"
|
||||||
|
|
||||||
|
addCommonConfigFlags(fs, &configFlags)
|
||||||
|
fs.Var(&from, "from", "first canonical stage to select (inclusive)")
|
||||||
|
fs.Var(&through, "through", "last canonical stage to select (inclusive)")
|
||||||
|
fs.Var(&force, "force", "rerun selected stages even when already succeeded")
|
||||||
|
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
|
||||||
|
fs.Usage = func() {
|
||||||
|
invocation := "narratio run"
|
||||||
|
if command == "plan" {
|
||||||
|
invocation = "narratio session plan"
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(help, "Usage: %s <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [common config flags]\n\n", invocation)
|
||||||
|
_, _ = fmt.Fprintln(help, "Bounds are inclusive; omitted --from or --through selects the beginning or end of the canonical pipeline.")
|
||||||
|
_, _ = fmt.Fprintln(help)
|
||||||
|
_, _ = fmt.Fprintln(help, "Flags:")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := parseSessionAwareFlags(command, fs, args, &configFlags.sessionID); err != nil {
|
||||||
|
return boundedRunRequest{}, err
|
||||||
|
}
|
||||||
|
if configFlags.sessionID == "" {
|
||||||
|
return boundedRunRequest{}, fmt.Errorf("%s: session_id is required", command)
|
||||||
|
}
|
||||||
|
plan, err := BuildBoundedPlan(from.value, through.value)
|
||||||
|
if err != nil {
|
||||||
|
return boundedRunRequest{}, fmt.Errorf("%s: %w", command, err)
|
||||||
|
}
|
||||||
|
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
||||||
|
if err != nil {
|
||||||
|
return boundedRunRequest{}, fmt.Errorf("%s: invalid --artifacts: %w", command, err)
|
||||||
|
}
|
||||||
|
if len(normalizedArtifacts) > 0 && !plan.Contains("analyze") && !plan.Contains("publish") {
|
||||||
|
return boundedRunRequest{}, fmt.Errorf("%s: --artifacts requires a selected range containing analyze or publish", command)
|
||||||
|
}
|
||||||
|
|
||||||
|
return boundedRunRequest{
|
||||||
|
Config: configFlags,
|
||||||
|
Plan: plan,
|
||||||
|
Force: force.value,
|
||||||
|
SelectedArtifacts: normalizedArtifacts,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
224
internal/app/bounded_run_test.go
Normal file
224
internal/app/bounded_run_test.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBoundedRunParsingIsSharedByRunAndPlan(t *testing.T) {
|
||||||
|
args := []string{
|
||||||
|
"2026-05-03",
|
||||||
|
"--from", "extract",
|
||||||
|
"--through=publish",
|
||||||
|
"--force",
|
||||||
|
"--artifacts", "session_recap,player_handout",
|
||||||
|
"--artifacts=session_recap",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
}
|
||||||
|
runRequest, err := parseBoundedRunRequest("run", args, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse run request: %v", err)
|
||||||
|
}
|
||||||
|
planRequest, err := parseBoundedRunRequest("plan", args, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse plan request: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(runRequest.Plan.Names(), planRequest.Plan.Names()) ||
|
||||||
|
runRequest.Plan.From() != planRequest.Plan.From() ||
|
||||||
|
runRequest.Plan.Through() != planRequest.Plan.Through() ||
|
||||||
|
runRequest.Force != planRequest.Force ||
|
||||||
|
!reflect.DeepEqual(runRequest.SelectedArtifacts, planRequest.SelectedArtifacts) ||
|
||||||
|
runRequest.Config != planRequest.Config {
|
||||||
|
t.Fatalf("run request = %#v, plan request = %#v", runRequest, planRequest)
|
||||||
|
}
|
||||||
|
wantArtifacts := []string{"player_handout", "session_recap"}
|
||||||
|
if !reflect.DeepEqual(runRequest.SelectedArtifacts, wantArtifacts) {
|
||||||
|
t.Fatalf("artifacts = %#v, want %#v", runRequest.SelectedArtifacts, wantArtifacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "from separate", args: []string{"session", "--from", "render", "--from", "extract"}, want: "--from may be specified only once"},
|
||||||
|
{name: "from equals", args: []string{"session", "--from=render", "--from=extract"}, want: "--from may be specified only once"},
|
||||||
|
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
|
||||||
|
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
|
||||||
|
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
|
||||||
|
{name: "profile", args: []string{"session", "--profile", "production", "--profile=testing"}, want: "--profile may be specified only once"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := parseBoundedRunRequest("run", test.args, io.Discard)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunParsingRetainsExplicitProfilePresence(t *testing.T) {
|
||||||
|
withoutProfile, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := withoutProfile.Config.sessionOptions().Profile; got != nil {
|
||||||
|
t.Fatalf("omitted profile = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
withProfile, err := parseBoundedRunRequest("run", []string{"session", "--profile", "testing"}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := withProfile.Config.sessionOptions().Profile; got == nil || *got != "testing" {
|
||||||
|
t.Fatalf("explicit profile = %#v, want testing", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--profile", ""}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := explicitEmpty.Config.sessionOptions().Profile; got == nil || *got != "" {
|
||||||
|
t.Fatalf("explicit empty profile = %#v, want non-nil empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
|
||||||
|
args := []string{"session", "--from", "publish", "--through", "render"}
|
||||||
|
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)
|
||||||
|
planRequest, planErr := parseBoundedRunRequest("plan", args, io.Discard)
|
||||||
|
if runErr == nil || planErr == nil {
|
||||||
|
t.Fatalf("run request=%#v error=%v; plan request=%#v error=%v", runRequest, runErr, planRequest, planErr)
|
||||||
|
}
|
||||||
|
runDetail := strings.TrimPrefix(runErr.Error(), "run: ")
|
||||||
|
planDetail := strings.TrimPrefix(planErr.Error(), "plan: ")
|
||||||
|
if runDetail != planDetail || !strings.Contains(runDetail, `from stage "publish" occurs after through stage "render"`) {
|
||||||
|
t.Fatalf("run error = %q, plan error = %q", runErr, planErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunParsingGatesArtifactSelectionByRange(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
through string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "render only", through: "render", wantErr: true},
|
||||||
|
{name: "analyze only", through: "analyze"},
|
||||||
|
{name: "publish only", through: "publish"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := parseBoundedRunRequest("run", []string{
|
||||||
|
"session", "--from", test.through, "--through", test.through, "--artifacts", "session_recap",
|
||||||
|
}, io.Discard)
|
||||||
|
if test.wantErr && (err == nil || !strings.Contains(err.Error(), "range containing analyze or publish")) {
|
||||||
|
t.Fatalf("error = %v, want artifact/range error", err)
|
||||||
|
}
|
||||||
|
if !test.wantErr && err != nil {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPassesBoundedPlanToRunner(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
var capturedStages []string
|
||||||
|
var capturedPlan BoundedPlan
|
||||||
|
var capturedOptions RunOptions
|
||||||
|
original := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = original })
|
||||||
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||||
|
capturedStages = plan.Names()
|
||||||
|
capturedPlan = plan
|
||||||
|
capturedOptions = options
|
||||||
|
return &RunSummary{SessionID: "2026-05-03", ManifestPath: filepath.Join(workspaceRoot, "manifest.json")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := Run(context.Background(), []string{
|
||||||
|
"2026-05-03",
|
||||||
|
"--from", "render",
|
||||||
|
"--through", "extract",
|
||||||
|
"--force",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath,
|
||||||
|
}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"render", "extract"}
|
||||||
|
if !reflect.DeepEqual(capturedStages, want) || !reflect.DeepEqual(capturedPlan.Names(), want) || !capturedOptions.Force {
|
||||||
|
t.Fatalf("stages = %#v options = %#v", capturedStages, capturedOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPrintsOnlyBoundedRange(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
|
m.Campaign = "sample-campaign"
|
||||||
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
||||||
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
|
}
|
||||||
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
|
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, m); err != nil {
|
||||||
|
t.Fatalf("save prerequisite manifest: %v", err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := Plan(context.Background(), []string{
|
||||||
|
"2026-05-03",
|
||||||
|
"--from", "render",
|
||||||
|
"--through", "extract",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath,
|
||||||
|
}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Plan() error = %v", err)
|
||||||
|
}
|
||||||
|
got := out.String()
|
||||||
|
if !strings.Contains(got, "render: run\nextract: run\ntotals: run=2 skip=0") {
|
||||||
|
t.Fatalf("output = %q, want bounded decisions", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "trim: ") || strings.Contains(got, "analyze: ") {
|
||||||
|
t.Fatalf("output = %q, contains excluded stages", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunCommandHelp(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "run", args: []string{"run", "--help"}, want: "Usage: narratio run <session_id> [--from <stage>] [--through <stage>]"},
|
||||||
|
{name: "plan", args: []string{"session", "plan", "--help"}, want: "Usage: narratio session plan <session_id> [--from <stage>] [--through <stage>]"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute(test.args, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), test.want) || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q stderr = %q, want %q", stdout.String(), stderr.String(), test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,11 +88,7 @@ func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writ
|
|||||||
strings.TrimSpace(flags.previousSessionID) != "" {
|
strings.TrimSpace(flags.previousSessionID) != "" {
|
||||||
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
||||||
}
|
}
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath)
|
_, pipelineCfg, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()})
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("clean: %w", err)
|
|
||||||
}
|
|
||||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("clean: %w", err)
|
return fmt.Errorf("clean: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var supportedCommands = []string{"run", "run-stage", "analyze", "publish", "clean", "session"}
|
var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session", "config"}
|
||||||
|
|
||||||
|
var runCommandFn = Run
|
||||||
|
|
||||||
// Execute dispatches CLI commands and returns a process exit code.
|
// Execute dispatches CLI commands and returns a process exit code.
|
||||||
func Execute(args []string, stdout, stderr io.Writer) int {
|
func Execute(args []string, stdout, stderr io.Writer) int {
|
||||||
@@ -22,8 +24,12 @@ func Execute(args []string, stdout, stderr io.Writer) int {
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
switch cmd {
|
switch cmd {
|
||||||
|
case "version":
|
||||||
|
err = Version(cmdArgs, stdout)
|
||||||
case "run":
|
case "run":
|
||||||
err = Run(ctx, cmdArgs, stdout)
|
err = runCommandFn(ctx, cmdArgs, stdout)
|
||||||
|
case "regenerate-artifacts":
|
||||||
|
err = RegenerateArtifacts(ctx, cmdArgs, stdout)
|
||||||
case "run-stage":
|
case "run-stage":
|
||||||
err = RunStage(ctx, cmdArgs, stdout)
|
err = RunStage(ctx, cmdArgs, stdout)
|
||||||
case "analyze":
|
case "analyze":
|
||||||
@@ -34,6 +40,8 @@ func Execute(args []string, stdout, stderr io.Writer) int {
|
|||||||
err = Session(ctx, cmdArgs, stdout)
|
err = Session(ctx, cmdArgs, stdout)
|
||||||
case "clean":
|
case "clean":
|
||||||
err = Clean(ctx, cmdArgs, stdout)
|
err = Clean(ctx, cmdArgs, stdout)
|
||||||
|
case "config":
|
||||||
|
err = Config(ctx, cmdArgs, stdout)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd)
|
fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd)
|
||||||
printUsage(stderr)
|
printUsage(stderr)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestExecuteValidCommands(t *testing.T) {
|
|||||||
wantOut string
|
wantOut string
|
||||||
}{
|
}{
|
||||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=11 skipped=1; manifest="},
|
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=11 skipped=1; manifest="},
|
||||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nextract: run\nrender: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "analyze: skip\n targets: none\n prerequisites: none\n execute: none\n reuse: none\npublish: skip\nnotify: skip"},
|
||||||
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||||
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||||
}
|
}
|
||||||
@@ -549,6 +549,17 @@ inputs:
|
|||||||
return pipelinePath, campaignPath, sessionPath
|
return pipelinePath, campaignPath, sessionPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func materializePrepareResumeFixture(t *testing.T, pipelinePath, campaignPath, sessionPath string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := RunStage(
|
||||||
|
context.Background(),
|
||||||
|
[]string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
|
&bytes.Buffer{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("materialize prepare resume fixture: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func writeAppTestCampaignConfig(t *testing.T, dir string) string {
|
func writeAppTestCampaignConfig(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
campaignPath := filepath.Join(dir, "campaign.yml")
|
campaignPath := filepath.Join(dir, "campaign.yml")
|
||||||
@@ -563,6 +574,7 @@ inputs:
|
|||||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write campaign.yml: %v", err)
|
t.Fatalf("write campaign.yml: %v", err)
|
||||||
}
|
}
|
||||||
|
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
|
||||||
return campaignPath
|
return campaignPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
298
internal/app/config_commands.go
Normal file
298
internal/app/config_commands.go
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inspectionFlags struct {
|
||||||
|
pipelinePath string
|
||||||
|
campaignPath string
|
||||||
|
campaignFilePath string
|
||||||
|
profile singletonStringFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
type inspectionConfig struct {
|
||||||
|
PipelinePath string
|
||||||
|
Pipeline *config.PipelineConfig
|
||||||
|
CampaignPath string
|
||||||
|
Campaign *config.CampaignConfig
|
||||||
|
Party config.ResolvedParty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config dispatches read-only pipeline configuration commands.
|
||||||
|
func Config(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("config: expected subcommand: validate|show|sources|diff")
|
||||||
|
}
|
||||||
|
if args[0] == "--help" || args[0] == "-h" {
|
||||||
|
writeConfigCommandUsage(out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "validate":
|
||||||
|
return ConfigValidate(ctx, args[1:], out)
|
||||||
|
case "show":
|
||||||
|
return ConfigShow(ctx, args[1:], out)
|
||||||
|
case "sources":
|
||||||
|
return ConfigSources(ctx, args[1:], out)
|
||||||
|
case "diff":
|
||||||
|
return ConfigDiff(ctx, args[1:], out)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("config: unknown subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigValidate validates one fully resolved effective pipeline without
|
||||||
|
// loading session state or constructing runtime collaborators.
|
||||||
|
func ConfigValidate(_ context.Context, args []string, out io.Writer) error {
|
||||||
|
flags, err := parseInspectionFlags("config validate", args, out)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolved, err := resolveInspectionConfig(flags)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config validate: %w", err)
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(out, "Configuration valid: root=%s; profile=%s; digest=%s\n",
|
||||||
|
inspectionRootPath(resolved), inspectionProfile(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigShow validates and renders one fully resolved effective pipeline
|
||||||
|
// without loading session state or constructing runtime collaborators.
|
||||||
|
func ConfigShow(_ context.Context, args []string, out io.Writer) error {
|
||||||
|
flags, err := parseInspectionFlags("config show", args, out)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolved, err := resolveInspectionConfig(flags)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config show: %w", err)
|
||||||
|
}
|
||||||
|
data, err := config.MarshalEffectivePipeline(resolved.Pipeline)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config show: %w", err)
|
||||||
|
}
|
||||||
|
_, err = out.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigSources validates and reports deterministic effective configuration
|
||||||
|
// ownership without loading session state or runtime collaborators.
|
||||||
|
func ConfigSources(_ context.Context, args []string, out io.Writer) error {
|
||||||
|
flags, err := parseInspectionFlags("config sources", args, out)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolved, err := resolveInspectionConfig(flags)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config sources: %w", err)
|
||||||
|
}
|
||||||
|
records, err := config.EffectivePipelineSources(resolved.Pipeline, resolved.Party)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config sources: %w", err)
|
||||||
|
}
|
||||||
|
records = append(records, config.EffectiveCampaignSources(resolved.CampaignPath, resolved.Campaign, resolved.Party)...)
|
||||||
|
sortInspectionSourceRecords(records)
|
||||||
|
if err := writeInspectionSourceHeader(out, resolved); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
if _, err := fmt.Fprintf(out, "%s\t%s\t%s\n", record.Path, record.Role, record.Source); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) {
|
||||||
|
fs := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||||
|
fs.SetOutput(out)
|
||||||
|
var flags inspectionFlags
|
||||||
|
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
|
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
|
||||||
|
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
|
flags.profile.name = "profile"
|
||||||
|
fs.Var(&flags.profile, "profile", "named pipeline profile")
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]\n\n", command)
|
||||||
|
fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.")
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
fmt.Fprintln(out, "Flags:")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return inspectionFlags{}, fmt.Errorf("%s: invalid flags: %w", command, err)
|
||||||
|
}
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return inspectionFlags{}, fmt.Errorf("%s: unexpected positional arguments", command)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(flags.campaignPath) != "" && strings.TrimSpace(flags.campaignFilePath) != "" {
|
||||||
|
return inspectionFlags{}, fmt.Errorf("%s: --campaign and --campaign-file are mutually exclusive", command)
|
||||||
|
}
|
||||||
|
return flags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveInspectionConfig(flags inspectionFlags) (*inspectionConfig, error) {
|
||||||
|
pipelinePath, pipeline, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
needsCampaign := pipelineHasArtifactFamilies(pipeline)
|
||||||
|
hasCampaignSelection := strings.TrimSpace(flags.campaignPath) != "" || strings.TrimSpace(flags.campaignFilePath) != ""
|
||||||
|
if !needsCampaign && !hasCampaignSelection {
|
||||||
|
if err := validateInspectionPipeline(pipelinePath, pipeline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &inspectionConfig{PipelinePath: pipelinePath, Pipeline: pipeline}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
campaignPath, err := resolveCampaignConfigPath(pipeline, flags.campaignPath, flags.campaignFilePath)
|
||||||
|
if err != nil {
|
||||||
|
if needsCampaign {
|
||||||
|
return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires a campaign: %w", err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
campaign, err := config.LoadCampaign(campaignPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if selectedID := strings.TrimSpace(flags.campaignPath); selectedID != "" && strings.TrimSpace(flags.campaignFilePath) == "" {
|
||||||
|
if got := config.CampaignID(campaign); got != selectedID {
|
||||||
|
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, selectedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded, err := config.LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateInspectionPipeline(loaded.PipelinePath, loaded.Pipeline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateInspectionCampaign(loaded.CampaignPath, loaded.Campaign); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &inspectionConfig{
|
||||||
|
PipelinePath: loaded.PipelinePath,
|
||||||
|
Pipeline: loaded.Pipeline,
|
||||||
|
CampaignPath: loaded.CampaignPath,
|
||||||
|
Campaign: loaded.Campaign,
|
||||||
|
Party: loaded.Party,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipelineHasArtifactFamilies(pipeline *config.PipelineConfig) bool {
|
||||||
|
return pipeline != nil && pipeline.Scriptorium != nil && len(pipeline.Scriptorium.ArtifactFamilies) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInspectionPipeline(path string, pipeline *config.PipelineConfig) error {
|
||||||
|
if err := config.ValidatePipelineConfig(pipeline); err != nil {
|
||||||
|
return fmt.Errorf("pipeline config %q invalid: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInspectionCampaign(path string, campaign *config.CampaignConfig) error {
|
||||||
|
if err := config.ValidateCampaignConfig(campaign); err != nil {
|
||||||
|
return fmt.Errorf("campaign config %q invalid: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectionRootPath(resolved *inspectionConfig) string {
|
||||||
|
if resolved == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if root := config.EffectivePipelineRootPath(resolved.Pipeline); root != "" {
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
return resolved.PipelinePath
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectionProfile(pipeline *config.PipelineConfig) string {
|
||||||
|
if profile, ok := config.SelectedPipelineProfile(pipeline); ok {
|
||||||
|
return profile.Name
|
||||||
|
}
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfigCommandUsage(out io.Writer) {
|
||||||
|
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|diff>")
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, or config diff to compare two profiles.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
|
||||||
|
if _, err := fmt.Fprintf(out, "root: %s\n", inspectionRootPath(resolved)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
imports := config.EffectivePipelineImports(resolved.Pipeline)
|
||||||
|
if len(imports) == 0 {
|
||||||
|
if _, err := fmt.Fprintln(out, "imports: none"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, imported := range imports {
|
||||||
|
if _, err := fmt.Fprintf(out, "import: %s\n", config.NormalizedConfigurationPath(imported)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile, ok := config.SelectedPipelineProfile(resolved.Pipeline); ok {
|
||||||
|
if _, err := fmt.Fprintf(out, "profile: name=%s selection=%s overlay=%s\n", profile.Name, profile.Source, config.NormalizedConfigurationPath(profile.OverlayPath)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if _, err := fmt.Fprintln(out, "profile: none"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resolved.Campaign == nil {
|
||||||
|
if _, err := fmt.Fprintln(out, "campaign: none"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(out, "party: none"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := fmt.Fprintf(out, "campaign: id=%s source=%s\n", config.CampaignID(resolved.Campaign), config.NormalizedConfigurationPath(resolved.CampaignPath)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, "party: mode=%s source=%s\n", resolved.Party.Mode, config.NormalizedConfigurationPath(resolved.Party.Source.Path)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, "digest: %s\n", config.EffectivePipelineDigest(resolved.Pipeline)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(out, "path\trole\tsource")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortInspectionSourceRecords(records []config.EffectivePipelineSourceRecord) {
|
||||||
|
sort.Slice(records, func(left, right int) bool {
|
||||||
|
if records[left].Path != records[right].Path {
|
||||||
|
return records[left].Path < records[right].Path
|
||||||
|
}
|
||||||
|
if records[left].Role != records[right].Role {
|
||||||
|
return records[left].Role < records[right].Role
|
||||||
|
}
|
||||||
|
return records[left].Source < records[right].Source
|
||||||
|
})
|
||||||
|
}
|
||||||
557
internal/app/config_commands_test.go
Normal file
557
internal/app/config_commands_test.go
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigCommandHelpAndDispatch(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
code int
|
||||||
|
}{
|
||||||
|
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show|sources|diff>"},
|
||||||
|
{name: "validate help", args: []string{"config", "validate", "--help"}, want: "Usage: narratio config validate"},
|
||||||
|
{name: "show help", args: []string{"config", "show", "--help"}, want: "Usage: narratio config show"},
|
||||||
|
{name: "sources help", args: []string{"config", "sources", "--help"}, want: "Usage: narratio config sources"},
|
||||||
|
{name: "diff help", args: []string{"config", "diff", "--help"}, want: "Usage: narratio config diff"},
|
||||||
|
{name: "unknown", args: []string{"config", "unknown"}, want: `config: unknown subcommand "unknown"`, code: 1},
|
||||||
|
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources|diff", code: 1},
|
||||||
|
{name: "session flag", args: []string{"config", "validate", "--session", "session.yml"}, want: "flag provided but not defined", code: 1},
|
||||||
|
{name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1},
|
||||||
|
{name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1},
|
||||||
|
{name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code := Execute(test.args, &stdout, &stderr)
|
||||||
|
if code != test.code {
|
||||||
|
t.Fatalf("exit code = %d, want %d; stdout=%q stderr=%q", code, test.code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
combined := stdout.String() + stderr.String()
|
||||||
|
if !strings.Contains(combined, test.want) {
|
||||||
|
t.Fatalf("output = %q, want %q", combined, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigValidateAndShowPipelineOnlyAreSideEffectFree(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
workspaceRoot := filepath.Join(dir, "workspace-does-not-exist")
|
||||||
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
|
secretsDir := filepath.Join(dir, "secrets")
|
||||||
|
const secret = "inspection-secret-must-not-escape"
|
||||||
|
mustWriteTestFile(t, filepath.Join(secretsDir, "INSPECTION_SECRET"), secret+"\n")
|
||||||
|
calls := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ }))
|
||||||
|
defer server.Close()
|
||||||
|
mustWriteTestFile(t, pipelinePath, `workspace:
|
||||||
|
root: `+workspaceRoot+`
|
||||||
|
secrets:
|
||||||
|
env_dir: `+secretsDir+`
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: `+server.URL+`
|
||||||
|
`)
|
||||||
|
t.Setenv("INSPECTION_SECRET", secret)
|
||||||
|
|
||||||
|
var validateOut bytes.Buffer
|
||||||
|
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil {
|
||||||
|
t.Fatalf("ConfigValidate() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; profile=none; digest=") {
|
||||||
|
t.Fatalf("validate output = %q", validateOut.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var showOut bytes.Buffer
|
||||||
|
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &showOut); err != nil {
|
||||||
|
t.Fatalf("ConfigShow() error = %v", err)
|
||||||
|
}
|
||||||
|
show := showOut.String()
|
||||||
|
if !strings.HasSuffix(show, "\n") || !strings.Contains(show, "workspace:\n") || !strings.Contains(show, "root: "+workspaceRoot) {
|
||||||
|
t.Fatalf("show output = %q", show)
|
||||||
|
}
|
||||||
|
if strings.Contains(show, "artifact_families") || strings.Contains(show, "resolution") {
|
||||||
|
t.Fatalf("show output leaked resolution-only fields: %q", show)
|
||||||
|
}
|
||||||
|
if strings.Contains(show, secret) {
|
||||||
|
t.Fatalf("show output leaked a raw secret: %q", show)
|
||||||
|
}
|
||||||
|
var sourcesOut bytes.Buffer
|
||||||
|
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath}, &sourcesOut); err != nil {
|
||||||
|
t.Fatalf("ConfigSources() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(sourcesOut.String(), secret) {
|
||||||
|
t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String())
|
||||||
|
}
|
||||||
|
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
|
||||||
|
var diffOut bytes.Buffer
|
||||||
|
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diffOut); err != nil {
|
||||||
|
t.Fatalf("ConfigDiff() error = %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(diffOut.String(), secret) {
|
||||||
|
t.Fatalf("diff output leaked a raw secret: %q", diffOut.String())
|
||||||
|
}
|
||||||
|
if calls != 0 {
|
||||||
|
t.Fatalf("inspection invoked configured external endpoint %d time(s)", calls)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(workspaceRoot); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("workspace root stat error = %v, want absent", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDiffReportsSemanticProfileChanges(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
writeInspectionProfiles(t, pipelinePath, `workspace:
|
||||||
|
cleanup_after_publish: true
|
||||||
|
whisperx:
|
||||||
|
language: en
|
||||||
|
audita:
|
||||||
|
modules: [glossary, grammar]
|
||||||
|
scriptorium:
|
||||||
|
artifacts:
|
||||||
|
production_only:
|
||||||
|
enabled: false
|
||||||
|
output_path: artifacts/production.md
|
||||||
|
`, `workspace:
|
||||||
|
cleanup_after_publish: false
|
||||||
|
whisperx:
|
||||||
|
language: fr
|
||||||
|
audita:
|
||||||
|
modules: []
|
||||||
|
scriptorium:
|
||||||
|
artifacts:
|
||||||
|
testing_only:
|
||||||
|
enabled: false
|
||||||
|
output_path: artifacts/testing.md
|
||||||
|
`)
|
||||||
|
|
||||||
|
args := []string{"production", "testing", "--config", pipelinePath}
|
||||||
|
var first, second bytes.Buffer
|
||||||
|
if err := ConfigDiff(context.Background(), args, &first); err != nil {
|
||||||
|
t.Fatalf("ConfigDiff() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := ConfigDiff(context.Background(), args, &second); err != nil {
|
||||||
|
t.Fatalf("second ConfigDiff() error = %v", err)
|
||||||
|
}
|
||||||
|
if first.String() != second.String() {
|
||||||
|
t.Fatalf("diff output is not deterministic:\nfirst=%q\nsecond=%q", first.String(), second.String())
|
||||||
|
}
|
||||||
|
output := first.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"changed\taudita.modules\t[\"glossary\",\"grammar\"]\t[]\n",
|
||||||
|
"changed\tworkspace.cleanup_after_publish\ttrue\tfalse\n",
|
||||||
|
"changed\twhisperx.language\t\"en\"\t\"fr\"\n",
|
||||||
|
"removed\tscriptorium.artifacts.production_only.enabled\tfalse\n",
|
||||||
|
"added\tscriptorium.artifacts.testing_only.enabled\tfalse\n",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Fatalf("diff output missing %q:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Index(output, "audita.modules") > strings.Index(output, "workspace.cleanup_after_publish") {
|
||||||
|
t.Fatalf("diff records are not sorted by path:\n%s", output)
|
||||||
|
}
|
||||||
|
var reversed bytes.Buffer
|
||||||
|
if err := ConfigDiff(context.Background(), []string{"testing", "production", "--config", pipelinePath}, &reversed); err != nil {
|
||||||
|
t.Fatalf("reversed ConfigDiff() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reversed.String(), "changed\twhisperx.language\t\"fr\"\t\"en\"\n") {
|
||||||
|
t.Fatalf("reversed diff did not independently resolve profiles:\n%s", reversed.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||||
|
writeInspectionProfiles(t, pipelinePath, `scriptorium:
|
||||||
|
artifact_families:
|
||||||
|
character_note:
|
||||||
|
enabled: false
|
||||||
|
member_vars:
|
||||||
|
character_name: character.name
|
||||||
|
publish:
|
||||||
|
enabled: true
|
||||||
|
required: false
|
||||||
|
`, `scriptorium:
|
||||||
|
artifact_families:
|
||||||
|
character_note:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.character_note
|
||||||
|
member_vars:
|
||||||
|
character_name: character.class_summary
|
||||||
|
publish:
|
||||||
|
enabled: true
|
||||||
|
required: true
|
||||||
|
`)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigDiff(context.Background(), []string{
|
||||||
|
"production", "testing", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||||
|
}, &out); err != nil {
|
||||||
|
t.Fatalf("ConfigDiff() error = %v", err)
|
||||||
|
}
|
||||||
|
output := out.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"changed\tscriptorium.artifacts.character_note_arannis.enabled\tfalse\ttrue\n",
|
||||||
|
"changed\tscriptorium.artifacts.character_note_arannis.vars.character_name\t\"Arannis\"\t\"wizard\"\n",
|
||||||
|
"changed\tpublish.outputs\t",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Fatalf("family diff output missing %q:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: en\n")
|
||||||
|
|
||||||
|
var equal bytes.Buffer
|
||||||
|
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &equal); err != nil {
|
||||||
|
t.Fatalf("ConfigDiff() equal profiles error = %v", err)
|
||||||
|
}
|
||||||
|
if got := equal.String(); got != "no differences\n" {
|
||||||
|
t.Fatalf("equal diff output = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"production", "testing", "extra", "--config", pipelinePath},
|
||||||
|
{"production", "--config", pipelinePath},
|
||||||
|
{"production", "production", "--config", pipelinePath},
|
||||||
|
{"unknown", "testing", "--config", pipelinePath},
|
||||||
|
{"production", "testing", "--config", pipelinePath, "--profile", "production"},
|
||||||
|
{"production", "testing", "--config", pipelinePath, "--config", pipelinePath},
|
||||||
|
{"production", "testing", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath},
|
||||||
|
} {
|
||||||
|
if err := ConfigDiff(context.Background(), args, io.Discard); err == nil {
|
||||||
|
t.Fatalf("ConfigDiff(%q) succeeded, want error", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testing.T) {
|
||||||
|
examplesDir := filepath.Join("..", "..", "examples")
|
||||||
|
pipelinePath := filepath.Join(examplesDir, "production-testing", "pipeline.yml")
|
||||||
|
campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml")
|
||||||
|
workspacePath := filepath.Join(examplesDir, "production-testing", "workspace")
|
||||||
|
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("example workspace stat = %v, want absent", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigSources} {
|
||||||
|
if err := command(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "production"}, io.Discard); err != nil {
|
||||||
|
t.Fatalf("inspection command %T error = %v", command, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var diff bytes.Buffer
|
||||||
|
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath, "--campaign-file", campaignPath}, &diff); err != nil {
|
||||||
|
t.Fatalf("ConfigDiff() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(diff.String(), "audita.model") {
|
||||||
|
t.Fatalf("split bundle profile diff = %q, want model change", diff.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("inspection created example workspace: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInspectionProfiles(t *testing.T, pipelinePath, production, testing string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(pipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
composition := "composition:\n default_profile: production\n profiles:\n production:\n overlay: production.yml\n testing:\n overlay: testing.yml\n"
|
||||||
|
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(pipelinePath)
|
||||||
|
mustWriteTestFile(t, filepath.Join(dir, "production.yml"), production)
|
||||||
|
mustWriteTestFile(t, filepath.Join(dir, "testing.yml"), testing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
enableCommandTestProfiles(t, pipelinePath)
|
||||||
|
|
||||||
|
var defaultOut bytes.Buffer
|
||||||
|
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil {
|
||||||
|
t.Fatalf("default ConfigValidate() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(defaultOut.String(), "profile=production") {
|
||||||
|
t.Fatalf("default output = %q", defaultOut.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var explicitOut bytes.Buffer
|
||||||
|
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &explicitOut); err != nil {
|
||||||
|
t.Fatalf("explicit ConfigShow() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(explicitOut.String(), "language: fr") {
|
||||||
|
t.Fatalf("explicit show output = %q", explicitOut.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"--config", pipelinePath, "--campaign", "sample-campaign"},
|
||||||
|
{"--config", pipelinePath, "--campaign-file", campaignPath},
|
||||||
|
} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigValidate(context.Background(), args, &out); err != nil {
|
||||||
|
t.Fatalf("ConfigValidate(%q) error = %v", args, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigCommandsExpandFamiliesAndRequireCampaign(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||||
|
|
||||||
|
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := command(context.Background(), []string{"--config", pipelinePath}, &out)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "artifact_families requires a campaign") {
|
||||||
|
t.Fatalf("family command without campaign error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var show bytes.Buffer
|
||||||
|
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &show); err != nil {
|
||||||
|
t.Fatalf("ConfigShow() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(show.String(), "character_note_arannis:") || strings.Contains(show.String(), "artifact_families") {
|
||||||
|
t.Fatalf("expanded show output = %q", show.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedPipeline, err := config.LoadPipeline(pipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loadedCampaign, err := config.LoadCampaign(campaignPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := config.LoadPipelineCampaign(pipelinePath, loadedPipeline, campaignPath, loadedCampaign)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var validateOut bytes.Buffer
|
||||||
|
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validateOut); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(validateOut.String(), "digest="+config.EffectivePipelineDigest(loaded.Pipeline)) {
|
||||||
|
t.Fatalf("inspection digest = %q, want %q", validateOut.String(), config.EffectivePipelineDigest(loaded.Pipeline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigValidateReportsCanonicalPartyAndImportErrors(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||||
|
mustWriteTestFile(t, filepath.Join(filepath.Dir(campaignPath), "party.yml"), "schema_version: narratio.party.v2\ncharacters: {}\n")
|
||||||
|
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, io.Discard); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||||
|
t.Fatalf("canonical party error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
rootPath := filepath.Join(dir, "pipeline.yml")
|
||||||
|
mustWriteTestFile(t, rootPath, `composition:
|
||||||
|
imports: [conf.yml]
|
||||||
|
workspace:
|
||||||
|
root: /one
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.example.com/transcribe
|
||||||
|
`)
|
||||||
|
mustWriteTestFile(t, filepath.Join(dir, "conf.yml"), "workspace:\n root: /two\n")
|
||||||
|
err := ConfigValidate(context.Background(), []string{"--config", rootPath}, io.Discard)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "workspace.root") || !strings.Contains(err.Error(), "pipeline.yml") || !strings.Contains(err.Error(), "conf.yml") {
|
||||||
|
t.Fatalf("import diagnostic = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSourcesReportsStableOwnership(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
enableCommandTestProfiles(t, pipelinePath)
|
||||||
|
|
||||||
|
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"}
|
||||||
|
var first, second bytes.Buffer
|
||||||
|
if err := ConfigSources(context.Background(), args, &first); err != nil {
|
||||||
|
t.Fatalf("first ConfigSources() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := ConfigSources(context.Background(), args, &second); err != nil {
|
||||||
|
t.Fatalf("second ConfigSources() error = %v", err)
|
||||||
|
}
|
||||||
|
if first.String() != second.String() {
|
||||||
|
t.Fatalf("sources output is not deterministic:\nfirst=%q\nsecond=%q", first.String(), second.String())
|
||||||
|
}
|
||||||
|
output := first.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"root: ",
|
||||||
|
"imports: none",
|
||||||
|
"profile: name=testing selection=cli overlay=",
|
||||||
|
"campaign: id=sample-campaign source=",
|
||||||
|
"party: mode=legacy source=",
|
||||||
|
"digest: ",
|
||||||
|
"path\trole\tsource\n",
|
||||||
|
"workspace.root\troot\t",
|
||||||
|
"whisperx.language\tprofile\t",
|
||||||
|
"campaign.inputs.players_file\tlegacy_player\t",
|
||||||
|
"trim.enabled\tdefault\tdefault",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Fatalf("sources output missing %q:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
|
||||||
|
t.Fatalf("ConfigSources() error = %v", err)
|
||||||
|
}
|
||||||
|
output := out.String()
|
||||||
|
partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml")
|
||||||
|
for _, want := range []string{
|
||||||
|
"party: mode=canonical source=" + partyPath,
|
||||||
|
"derived.players\tparty\t" + partyPath,
|
||||||
|
"scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + pipelinePath,
|
||||||
|
"scriptorium.artifacts.character_note_arannis.enabled\tparty\t" + partyPath,
|
||||||
|
"scriptorium.artifacts.character_note_arannis.depends_on\tfamily\t" + pipelinePath,
|
||||||
|
"scriptorium.artifacts.character_note_arannis.inputs.prior.source\tparty\t" + partyPath,
|
||||||
|
"publish.outputs[",
|
||||||
|
"\tfamily\t" + pipelinePath,
|
||||||
|
"\tparty\t" + partyPath,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Fatalf("sources output missing %q:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||||
|
data, err := os.ReadFile(pipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
profilePath := filepath.Join(filepath.Dir(pipelinePath), "testing.yml")
|
||||||
|
composition := "composition:\n default_profile: testing\n profiles:\n testing:\n overlay: testing.yml\n"
|
||||||
|
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mustWriteTestFile(t, profilePath, `scriptorium:
|
||||||
|
artifact_families:
|
||||||
|
character_note:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.character_note
|
||||||
|
`)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
|
||||||
|
t.Fatalf("ConfigSources() error = %v", err)
|
||||||
|
}
|
||||||
|
want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + profilePath
|
||||||
|
if !strings.Contains(out.String(), want) {
|
||||||
|
t.Fatalf("sources output missing profile-generated ownership %q:\n%s", want, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSourcesReportsRootImportProfileAndDefaultOwnership(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
rootPath := filepath.Join(dir, "pipeline.yml")
|
||||||
|
importPath := filepath.Join(dir, "base.yml")
|
||||||
|
profilePath := filepath.Join(dir, "testing.yml")
|
||||||
|
mustWriteTestFile(t, rootPath, `composition:
|
||||||
|
imports: [base.yml]
|
||||||
|
default_profile: testing
|
||||||
|
profiles:
|
||||||
|
testing:
|
||||||
|
overlay: testing.yml
|
||||||
|
workspace:
|
||||||
|
root: /srv/narratio
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.example.com/transcribe
|
||||||
|
`)
|
||||||
|
mustWriteTestFile(t, importPath, "storage:\n backend: local\n")
|
||||||
|
mustWriteTestFile(t, profilePath, "whisperx:\n language: fr\n")
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := ConfigSources(context.Background(), []string{"--config", rootPath}, &out); err != nil {
|
||||||
|
t.Fatalf("ConfigSources() error = %v", err)
|
||||||
|
}
|
||||||
|
output := out.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"import: " + importPath,
|
||||||
|
"profile: name=testing selection=default overlay=" + profilePath,
|
||||||
|
"workspace.root\troot\t" + rootPath,
|
||||||
|
"storage.backend\timport\t" + importPath,
|
||||||
|
"whisperx.language\tprofile\t" + profilePath,
|
||||||
|
"trim.enabled\tdefault\tdefault",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(output, want) {
|
||||||
|
t.Fatalf("sources output missing %q:\n%s", want, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeInspectionFamilyConfig(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
campaignRoot := filepath.Join(dir, "campaigns")
|
||||||
|
campaignDir := filepath.Join(campaignRoot, "sample-campaign")
|
||||||
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
|
campaignPath := filepath.Join(campaignDir, "campaign.yml")
|
||||||
|
mustWriteTestFile(t, pipelinePath, `workspace:
|
||||||
|
root: `+filepath.Join(dir, "workspace")+`
|
||||||
|
campaigns:
|
||||||
|
root: `+campaignRoot+`
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.example.com/transcribe
|
||||||
|
scriptorium:
|
||||||
|
artifact_families:
|
||||||
|
character_meta:
|
||||||
|
enabled: false
|
||||||
|
for_each: party.characters
|
||||||
|
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||||
|
character_note:
|
||||||
|
enabled: false
|
||||||
|
for_each: party.characters
|
||||||
|
output_path_pattern: artifacts/characters/{character_id}/note.md
|
||||||
|
member_dependencies: [character_meta]
|
||||||
|
inputs:
|
||||||
|
prior: {source: narratio.member_artifact.character_meta, required: true}
|
||||||
|
member_vars:
|
||||||
|
character_name: character.name
|
||||||
|
publish:
|
||||||
|
enabled: true
|
||||||
|
required: true
|
||||||
|
publish:
|
||||||
|
enabled: true
|
||||||
|
upload_run: false
|
||||||
|
`)
|
||||||
|
mustWriteTestFile(t, campaignPath, `campaign_id: sample-campaign
|
||||||
|
inputs:
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
party_file: ./party.yml
|
||||||
|
`)
|
||||||
|
mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), `schema_version: narratio.party.v1
|
||||||
|
characters:
|
||||||
|
arannis:
|
||||||
|
player: {name: Eric}
|
||||||
|
character: {name: Arannis, classes: [{name: wizard}]}
|
||||||
|
`)
|
||||||
|
return pipelinePath, campaignPath
|
||||||
|
}
|
||||||
302
internal/app/config_diff.go
Normal file
302
internal/app/config_diff.go
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type configDiffFlags struct {
|
||||||
|
pipelinePath singletonStringFlag
|
||||||
|
campaignPath singletonStringFlag
|
||||||
|
campaignFilePath singletonStringFlag
|
||||||
|
}
|
||||||
|
|
||||||
|
type configDiffRequest struct {
|
||||||
|
leftProfile string
|
||||||
|
rightProfile string
|
||||||
|
flags configDiffFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
type configDiffResolved struct {
|
||||||
|
left *inspectionConfig
|
||||||
|
right *inspectionConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type configDiffRecord struct {
|
||||||
|
Kind string
|
||||||
|
Path string
|
||||||
|
Left string
|
||||||
|
Right string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigDiff compares two explicitly selected profiles through the same
|
||||||
|
// read-only configuration resolution boundary used by config validate, show,
|
||||||
|
// and sources. Differences describe normalized effective values rather than
|
||||||
|
// the layout of root, import, or overlay files.
|
||||||
|
func ConfigDiff(_ context.Context, args []string, out io.Writer) error {
|
||||||
|
request, err := parseConfigDiffRequest(args, out)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolved, err := resolveConfigDiff(request)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config diff: %w", err)
|
||||||
|
}
|
||||||
|
leftValues, err := config.EffectivePipelineValues(resolved.left.Pipeline)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config diff: project left effective configuration: %w", err)
|
||||||
|
}
|
||||||
|
rightValues, err := config.EffectivePipelineValues(resolved.right.Pipeline)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config diff: project right effective configuration: %w", err)
|
||||||
|
}
|
||||||
|
records, err := diffEffectivePipelineValues(leftValues, rightValues)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("config diff: %w", err)
|
||||||
|
}
|
||||||
|
leftDigest := config.EffectivePipelineDigest(resolved.left.Pipeline)
|
||||||
|
rightDigest := config.EffectivePipelineDigest(resolved.right.Pipeline)
|
||||||
|
if len(records) == 0 {
|
||||||
|
if leftDigest != rightDigest {
|
||||||
|
return fmt.Errorf("internal error: effective pipeline digests differ without a semantic difference")
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(out, "no differences")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
switch record.Kind {
|
||||||
|
case "added":
|
||||||
|
if _, err := fmt.Fprintf(out, "added\t%s\t%s\n", record.Path, record.Right); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "removed":
|
||||||
|
if _, err := fmt.Fprintf(out, "removed\t%s\t%s\n", record.Path, record.Left); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "changed":
|
||||||
|
if _, err := fmt.Fprintf(out, "changed\t%s\t%s\t%s\n", record.Path, record.Left, record.Right); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("internal error: unsupported difference kind %q", record.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, error) {
|
||||||
|
profiles, flagArgs := splitConfigDiffArguments(args)
|
||||||
|
fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(out)
|
||||||
|
var request configDiffRequest
|
||||||
|
request.flags.pipelinePath.name = "config"
|
||||||
|
request.flags.campaignPath.name = "campaign"
|
||||||
|
request.flags.campaignFilePath.name = "campaign-file"
|
||||||
|
fs.Var(&request.flags.pipelinePath, "config", "path to pipeline.yml (optional; defaults searched)")
|
||||||
|
fs.Var(&request.flags.campaignPath, "campaign", "campaign ID")
|
||||||
|
fs.Var(&request.flags.campaignFilePath, "campaign-file", "path to campaign.yml")
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprintln(out, "Usage: narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>]")
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
fmt.Fprintln(out, "Compares fully resolved profiles without a session, workspace, manifest, adapters, or external services.")
|
||||||
|
fmt.Fprintln(out)
|
||||||
|
fmt.Fprintln(out, "Flags:")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
if err := fs.Parse(flagArgs); err != nil {
|
||||||
|
return configDiffRequest{}, fmt.Errorf("config diff: invalid flags: %w", err)
|
||||||
|
}
|
||||||
|
profiles = append(profiles, fs.Args()...)
|
||||||
|
if len(profiles) != 2 || strings.TrimSpace(profiles[0]) == "" || strings.TrimSpace(profiles[1]) == "" {
|
||||||
|
return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty profile names are required")
|
||||||
|
}
|
||||||
|
if request.flags.campaignPath.set && request.flags.campaignFilePath.set {
|
||||||
|
return configDiffRequest{}, fmt.Errorf("config diff: --campaign and --campaign-file are mutually exclusive")
|
||||||
|
}
|
||||||
|
request.leftProfile = profiles[0]
|
||||||
|
request.rightProfile = profiles[1]
|
||||||
|
return request, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitConfigDiffArguments accepts the documented positional-first syntax and
|
||||||
|
// also permits flags before or between profile names. Values belonging to the
|
||||||
|
// supported string flags stay with their flag so they are not mistaken for
|
||||||
|
// profile names.
|
||||||
|
func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) {
|
||||||
|
for index := 0; index < len(args); index++ {
|
||||||
|
argument := args[index]
|
||||||
|
if !strings.HasPrefix(argument, "-") || argument == "-" {
|
||||||
|
profiles = append(profiles, argument)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flagArgs = append(flagArgs, argument)
|
||||||
|
name, hasValue := strings.CutPrefix(argument, "--")
|
||||||
|
if !hasValue {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, _, hasInlineValue := strings.Cut(name, "=")
|
||||||
|
if hasInlineValue || !configDiffStringFlag(name) || index+1 >= len(args) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
index++
|
||||||
|
flagArgs = append(flagArgs, args[index])
|
||||||
|
}
|
||||||
|
return profiles, flagArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
func configDiffStringFlag(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "config", "campaign", "campaign-file", "profile":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConfigDiff(request configDiffRequest) (*configDiffResolved, error) {
|
||||||
|
pipelinePath, err := resolvePipelineConfigPath(request.flags.pipelinePath.value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
leftPipeline, rightPipeline, err := config.LoadPipelineProfilePair(pipelinePath, request.leftProfile, request.rightProfile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
needsCampaign := pipelineHasArtifactFamilies(leftPipeline) || pipelineHasArtifactFamilies(rightPipeline)
|
||||||
|
hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set
|
||||||
|
if !needsCampaign && !hasCampaignSelection {
|
||||||
|
if err := validateInspectionPipeline(pipelinePath, leftPipeline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateInspectionPipeline(pipelinePath, rightPipeline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &configDiffResolved{
|
||||||
|
left: &inspectionConfig{PipelinePath: pipelinePath, Pipeline: leftPipeline},
|
||||||
|
right: &inspectionConfig{PipelinePath: pipelinePath, Pipeline: rightPipeline},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
campaignPath, err := resolveSharedDiffCampaignPath(leftPipeline, rightPipeline, request.flags)
|
||||||
|
if err != nil {
|
||||||
|
if needsCampaign {
|
||||||
|
return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires one shared campaign: %w", err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
campaign, err := config.LoadCampaign(campaignPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if request.flags.campaignPath.set && !request.flags.campaignFilePath.set {
|
||||||
|
if got := config.CampaignID(campaign); got != request.flags.campaignPath.value {
|
||||||
|
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, request.flags.campaignPath.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
leftLoaded, err := config.LoadPipelineCampaign(pipelinePath, leftPipeline, campaignPath, campaign)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rightLoaded, err := config.LoadPipelineCampaignWithParty(pipelinePath, rightPipeline, campaignPath, campaign, leftLoaded.Party)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateInspectionPipeline(leftLoaded.PipelinePath, leftLoaded.Pipeline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateInspectionPipeline(rightLoaded.PipelinePath, rightLoaded.Pipeline); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateInspectionCampaign(campaignPath, campaign); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &configDiffResolved{
|
||||||
|
left: &inspectionConfig{
|
||||||
|
PipelinePath: leftLoaded.PipelinePath,
|
||||||
|
Pipeline: leftLoaded.Pipeline,
|
||||||
|
CampaignPath: leftLoaded.CampaignPath,
|
||||||
|
Campaign: campaign,
|
||||||
|
Party: leftLoaded.Party,
|
||||||
|
},
|
||||||
|
right: &inspectionConfig{
|
||||||
|
PipelinePath: rightLoaded.PipelinePath,
|
||||||
|
Pipeline: rightLoaded.Pipeline,
|
||||||
|
CampaignPath: rightLoaded.CampaignPath,
|
||||||
|
Campaign: campaign,
|
||||||
|
Party: rightLoaded.Party,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSharedDiffCampaignPath(left, right *config.PipelineConfig, flags configDiffFlags) (string, error) {
|
||||||
|
leftPath, leftErr := resolveCampaignConfigPath(left, flags.campaignPath.value, flags.campaignFilePath.value)
|
||||||
|
if leftErr != nil {
|
||||||
|
return "", leftErr
|
||||||
|
}
|
||||||
|
rightPath, rightErr := resolveCampaignConfigPath(right, flags.campaignPath.value, flags.campaignFilePath.value)
|
||||||
|
if rightErr != nil {
|
||||||
|
return "", rightErr
|
||||||
|
}
|
||||||
|
if leftPath != rightPath {
|
||||||
|
return "", fmt.Errorf("profile selections resolve different campaign files; specify --campaign-file to compare one campaign")
|
||||||
|
}
|
||||||
|
return leftPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffEffectivePipelineValues(left, right []config.EffectivePipelineValueRecord) ([]configDiffRecord, error) {
|
||||||
|
leftByPath, err := effectivePipelineValueMap("left", left)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rightByPath, err := effectivePipelineValueMap("right", right)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paths := make([]string, 0, len(leftByPath)+len(rightByPath))
|
||||||
|
seen := make(map[string]struct{}, len(leftByPath)+len(rightByPath))
|
||||||
|
for path := range leftByPath {
|
||||||
|
seen[path] = struct{}{}
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
for path := range rightByPath {
|
||||||
|
if _, exists := seen[path]; !exists {
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
records := make([]configDiffRecord, 0)
|
||||||
|
for _, path := range paths {
|
||||||
|
leftValue, leftExists := leftByPath[path]
|
||||||
|
rightValue, rightExists := rightByPath[path]
|
||||||
|
switch {
|
||||||
|
case !leftExists:
|
||||||
|
records = append(records, configDiffRecord{Kind: "added", Path: path, Right: rightValue})
|
||||||
|
case !rightExists:
|
||||||
|
records = append(records, configDiffRecord{Kind: "removed", Path: path, Left: leftValue})
|
||||||
|
case leftValue != rightValue:
|
||||||
|
records = append(records, configDiffRecord{Kind: "changed", Path: path, Left: leftValue, Right: rightValue})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectivePipelineValueMap(side string, records []config.EffectivePipelineValueRecord) (map[string]string, error) {
|
||||||
|
values := make(map[string]string, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
if _, exists := values[record.Path]; exists {
|
||||||
|
return nil, fmt.Errorf("internal error: %s effective configuration has duplicate path %q", side, record.Path)
|
||||||
|
}
|
||||||
|
values[record.Path] = record.Value
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
@@ -14,14 +14,10 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
)
|
)
|
||||||
|
|
||||||
type pipelineCampaignConfig struct {
|
type pipelineCampaignConfig = config.LoadedPipelineCampaign
|
||||||
PipelinePath string
|
|
||||||
CampaignPath string
|
|
||||||
Pipeline *config.PipelineConfig
|
|
||||||
Campaign *config.CampaignConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
var downloadObjectToTempFn = storage.DownloadObjectToTemp
|
var downloadObjectToTempFn = storage.DownloadObjectToTemp
|
||||||
|
var loadPipelineConfigFn = config.LoadPipelineWithOptions
|
||||||
|
|
||||||
type commandConfig struct {
|
type commandConfig struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
@@ -52,13 +48,13 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag)
|
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag, config.PipelineLoadOptions{Profile: sessionOpts.Profile})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
|
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
|
||||||
cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, explicitSession, sessionOpts)
|
cfg, err := config.LoadSessionWithPipelineCampaignOptions(*base, explicitSession, sessionOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -70,7 +66,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if discoveredSession.Path != "" {
|
if discoveredSession.Path != "" {
|
||||||
cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, discoveredSession.Path, sessionOpts)
|
cfg, err := config.LoadSessionWithPipelineCampaignOptions(*base, discoveredSession.Path, sessionOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,11 +84,9 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
}
|
}
|
||||||
sessionPrefix := artifacts.S3SessionPrefix(rootPrefix, config.CampaignID(base.Campaign), sessionID)
|
sessionPrefix := artifacts.S3SessionPrefix(rootPrefix, config.CampaignID(base.Campaign), sessionID)
|
||||||
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
||||||
partialCfg := &config.Config{
|
partialCfg, err := config.ResolveLoadedPipelineCampaign(*base, "", nil, config.SessionSource{})
|
||||||
Pipeline: base.Pipeline,
|
if err != nil {
|
||||||
Campaign: base.Campaign,
|
return nil, err
|
||||||
PipelinePath: base.PipelinePath,
|
|
||||||
CampaignPath: base.CampaignPath,
|
|
||||||
}
|
}
|
||||||
store, err := newCommandObjectStore(ctx, partialCfg, nil)
|
store, err := newCommandObjectStore(ctx, partialCfg, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,11 +116,8 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Resolve(
|
cfg, err := config.ResolveLoadedPipelineCampaign(
|
||||||
base.PipelinePath,
|
*base,
|
||||||
base.Pipeline,
|
|
||||||
base.CampaignPath,
|
|
||||||
base.Campaign,
|
|
||||||
sessionTempPath,
|
sessionTempPath,
|
||||||
sessionCfg,
|
sessionCfg,
|
||||||
config.SessionSource{
|
config.SessionSource{
|
||||||
@@ -146,12 +137,8 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
return loaded, nil
|
return loaded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) {
|
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string, pipelineOpts config.PipelineLoadOptions) (*pipelineCampaignConfig, error) {
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
loadedPipelinePath, pipelineCfg, err := loadPipelineConfig(pipelineFlag, pipelineOpts)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -168,12 +155,23 @@ func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag str
|
|||||||
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
|
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &pipelineCampaignConfig{
|
loaded, err := config.LoadPipelineCampaign(loadedPipelinePath, pipelineCfg, resolvedCampaignPath, campaignCfg)
|
||||||
PipelinePath: resolvedPipelinePath,
|
if err != nil {
|
||||||
CampaignPath: resolvedCampaignPath,
|
return nil, err
|
||||||
Pipeline: pipelineCfg,
|
}
|
||||||
Campaign: campaignCfg,
|
return &loaded, nil
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) {
|
||||||
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
pipelineCfg, err := loadPipelineConfigFn(resolvedPipelinePath, opts)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return resolvedPipelinePath, pipelineCfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) {
|
func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) {
|
||||||
|
|||||||
50
internal/app/config_loader_test.go
Normal file
50
internal/app/config_loader_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadCommandConfigRetainsInitiallyLoadedPipeline(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
originalLoader := loadPipelineConfigFn
|
||||||
|
loadCalls := 0
|
||||||
|
loadPipelineConfigFn = func(path string, opts config.PipelineLoadOptions) (*config.PipelineConfig, error) {
|
||||||
|
loaded, err := originalLoader(path, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
loadCalls++
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
changedRoot := workspaceRoot + "-changed"
|
||||||
|
updated := strings.ReplaceAll(string(data), workspaceRoot, changedRoot)
|
||||||
|
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return loaded, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { loadPipelineConfigFn = originalLoader })
|
||||||
|
|
||||||
|
loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, sessionPath, config.SessionLoadOptions{SessionID: "2026-05-03"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadCommandConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = loaded.Close() }()
|
||||||
|
|
||||||
|
if loadCalls != 1 {
|
||||||
|
t.Fatalf("pipeline load calls = %d, want 1", loadCalls)
|
||||||
|
}
|
||||||
|
if got := loaded.Config.Pipeline.Workspace.Root; got != workspaceRoot {
|
||||||
|
t.Fatalf("resolved workspace root = %q, want originally loaded %q", got, workspaceRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
internal/app/config_provenance.go
Normal file
70
internal/app/config_provenance.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func effectiveConfigProvenance(cfg *config.Config) *manifest.EffectiveConfigProvenance {
|
||||||
|
if cfg == nil || cfg.Pipeline == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
provenance := &manifest.EffectiveConfigProvenance{
|
||||||
|
EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline),
|
||||||
|
}
|
||||||
|
if selected, ok := config.SelectedPipelineProfile(cfg.Pipeline); ok {
|
||||||
|
provenance.SelectedProfile = &manifest.SelectedProfileProvenance{
|
||||||
|
Name: selected.Name,
|
||||||
|
Source: selected.Source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if provenance.SelectedProfile == nil && provenance.EffectiveConfigDigest == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return provenance
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEffectiveConfigProvenance(m *manifest.Manifest, cfg *config.Config) {
|
||||||
|
if m != nil {
|
||||||
|
m.EffectiveConfig = effectiveConfigProvenance(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEffectiveConfigProvenanceToRun(m *manifest.RunManifest, cfg *config.Config) {
|
||||||
|
if m != nil {
|
||||||
|
m.EffectiveConfig = effectiveConfigProvenance(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveConfigSummary(cfg *config.Config) string {
|
||||||
|
provenance := effectiveConfigProvenance(cfg)
|
||||||
|
if provenance == nil {
|
||||||
|
return "profile=none digest=unavailable"
|
||||||
|
}
|
||||||
|
profile := "none"
|
||||||
|
if provenance.SelectedProfile != nil {
|
||||||
|
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
|
||||||
|
}
|
||||||
|
digest := provenance.EffectiveConfigDigest
|
||||||
|
if digest == "" {
|
||||||
|
digest = "unavailable"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistedEffectiveConfigSummary(provenance *manifest.EffectiveConfigProvenance) string {
|
||||||
|
if provenance == nil {
|
||||||
|
return "profile=none digest=unavailable"
|
||||||
|
}
|
||||||
|
profile := "none"
|
||||||
|
if provenance.SelectedProfile != nil {
|
||||||
|
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
|
||||||
|
}
|
||||||
|
digest := provenance.EffectiveConfigDigest
|
||||||
|
if digest == "" {
|
||||||
|
digest = "unavailable"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
|
||||||
|
}
|
||||||
@@ -256,7 +256,10 @@ func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDo
|
|||||||
if after.Stages["extract"].Status != manifest.StatusSucceeded {
|
if after.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("extract status = %#v", after.Stages["extract"])
|
t.Fatalf("extract status = %#v", after.Stages["extract"])
|
||||||
}
|
}
|
||||||
for _, name := range []string{"render", "analyze", "publish"} {
|
if after.Stages["render"] == nil || after.Stages["render"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("render status = %#v, want succeeded sibling", after.Stages["render"])
|
||||||
|
}
|
||||||
|
for _, name := range []string{"analyze", "publish"} {
|
||||||
if after.Stages[name] == nil || after.Stages[name].Status != manifest.StatusStale {
|
if after.Stages[name] == nil || after.Stages[name].Status != manifest.StatusStale {
|
||||||
t.Fatalf("%s status = %#v, want stale", name, after.Stages[name])
|
t.Fatalf("%s status = %#v, want stale", name, after.Stages[name])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func buildHelperArtifactCatalog(cfg *config.Config, m *manifest.Manifest) (*arti
|
|||||||
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
|
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
|
||||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||||
}
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
return catalog, nil
|
return catalog, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +43,13 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
|||||||
}
|
}
|
||||||
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||||
fmt.Fprintln(out, "Configured:")
|
fmt.Fprintln(out, "Configured:")
|
||||||
|
origins := config.ArtifactFamilies(cfg.Pipeline).Members
|
||||||
for _, entry := range catalog.ListConfigured() {
|
for _, entry := range catalog.ListConfigured() {
|
||||||
writeArtifactLine(out, entry.SourceID, lockSet)
|
state := "unavailable"
|
||||||
|
if entry.Available {
|
||||||
|
state = "available"
|
||||||
|
}
|
||||||
|
writeConfiguredArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet, origins)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Extraction:")
|
fmt.Fprintln(out, "Extraction:")
|
||||||
for _, entry := range catalog.ListExtraction() {
|
for _, entry := range catalog.ListExtraction() {
|
||||||
@@ -65,6 +71,22 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func writeConfiguredArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule, origins map[string]config.ArtifactFamilyMemberOrigin) {
|
||||||
|
parts := []string{source, "planned", state}
|
||||||
|
if key, ok := artifactpolicy.ParseConfiguredSource(source); ok {
|
||||||
|
if origin, family := origins[key]; family {
|
||||||
|
parts = append(parts, "family="+origin.Family, "character_id="+origin.CharacterID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(provenance) != "" {
|
||||||
|
parts = append(parts, "provenance="+strings.TrimSpace(provenance))
|
||||||
|
}
|
||||||
|
if _, ok := lockSet[source]; ok {
|
||||||
|
parts = append(parts, "locked")
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
|
||||||
func writeExtractionArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule) {
|
func writeExtractionArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule) {
|
||||||
parts := []string{source, "planned", state}
|
parts := []string{source, "planned", state}
|
||||||
if strings.TrimSpace(provenance) != "" {
|
if strings.TrimSpace(provenance) != "" {
|
||||||
|
|||||||
54
internal/app/operator_artifact_rendering_test.go
Normal file
54
internal/app/operator_artifact_rendering_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildHelperArtifactCatalogUsesAnalyzeManifestEvidence(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Pipeline: &config.PipelineConfig{
|
||||||
|
Workspace: config.WorkspaceConfig{Root: root},
|
||||||
|
Scriptorium: &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
||||||
|
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Session: &config.SessionConfig{Campaign: "campaign", SessionID: "session"},
|
||||||
|
}
|
||||||
|
paths := artifacts.NewLocalStore(root).SessionPathsFor("campaign", "session")
|
||||||
|
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := []byte("# recap\n")
|
||||||
|
if err := os.WriteFile(outputPath, body, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := manifest.New("session", time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
incidental, err := buildHelperArtifactCatalog(cfg, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, _ := incidental.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap"))
|
||||||
|
if entry.Available {
|
||||||
|
t.Fatal("operator catalog advertised incidental configured artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", body)
|
||||||
|
current, err := buildHelperArtifactCatalog(cfg, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, _ = current.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap"))
|
||||||
|
if !entry.Available || entry.Provenance != artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
|
||||||
|
t.Fatalf("operator catalog entry = %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ type commonConfigFlags struct {
|
|||||||
sessionPath string
|
sessionPath string
|
||||||
sessionID string
|
sessionID string
|
||||||
previousSessionID string
|
previousSessionID string
|
||||||
|
profile singletonStringFlag
|
||||||
}
|
}
|
||||||
|
|
||||||
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
||||||
@@ -29,12 +30,15 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
|||||||
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
|
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
|
||||||
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
|
flags.profile.name = "profile"
|
||||||
|
fs.Var(&flags.profile, "profile", "named pipeline profile")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
||||||
return config.SessionLoadOptions{
|
return config.SessionLoadOptions{
|
||||||
SessionID: f.sessionID,
|
SessionID: f.sessionID,
|
||||||
PreviousSessionID: f.previousSessionID,
|
PreviousSessionID: f.previousSessionID,
|
||||||
|
Profile: f.profile.pointer(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
||||||
|
var profile singletonStringFlag
|
||||||
var remote, force bool
|
var remote, force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
@@ -33,6 +34,8 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
fs.StringVar(&output, "output", "", "local output session.yml path")
|
fs.StringVar(&output, "output", "", "local output session.yml path")
|
||||||
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
|
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
|
||||||
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
|
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
|
||||||
|
profile.name = "profile"
|
||||||
|
fs.Var(&profile, "profile", "named pipeline profile")
|
||||||
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
|
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
|
||||||
fs.BoolVar(&force, "force", false, "overwrite existing target")
|
fs.BoolVar(&force, "force", false, "overwrite existing target")
|
||||||
if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil {
|
if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil {
|
||||||
@@ -48,7 +51,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath)
|
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
return fmt.Errorf("session init: %w", err)
|
||||||
}
|
}
|
||||||
@@ -79,7 +82,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
return fmt.Errorf("session init: %w", err)
|
||||||
}
|
}
|
||||||
cfg, err := config.Resolve(base.PipelinePath, base.Pipeline, base.CampaignPath, base.Campaign, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
|
cfg, err := config.ResolveLoadedPipelineCampaign(*base, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
return fmt.Errorf("session init: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign)
|
fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign)
|
||||||
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
|
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
|
||||||
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
|
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
|
||||||
|
fmt.Fprintf(out, "Configuration (current): %s\n", effectiveConfigSummary(cfg))
|
||||||
writeStatusStableInputs(out, inspectStableInputs(cfg))
|
writeStatusStableInputs(out, inspectStableInputs(cfg))
|
||||||
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
|
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
} else {
|
} else {
|
||||||
localManifest = m
|
localManifest = m
|
||||||
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
|
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
|
||||||
|
fmt.Fprintf(out, "Configuration (last persisted): %s\n", persistedEffectiveConfigSummary(m.EffectiveConfig))
|
||||||
writeStageStatuses(out, m)
|
writeStageStatuses(out, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,34 +2,30 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"strings"
|
||||||
"os"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/logging"
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Plan validates configuration, prepares the local workdir, and prints stage order.
|
// Plan validates configuration and prints a read-only execution preview.
|
||||||
func Plan(ctx context.Context, args []string, out io.Writer) error {
|
func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
|
request, err := parseBoundedRunRequest("plan", args, out)
|
||||||
fs.SetOutput(io.Discard)
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
var flags commonConfigFlags
|
return nil
|
||||||
var force bool
|
}
|
||||||
addCommonConfigFlags(fs, &flags)
|
|
||||||
fs.BoolVar(&force, "force", false, "show all stages as scheduled to rerun")
|
|
||||||
|
|
||||||
if err := parseSessionAwareFlags("plan", fs, args, &flags.sessionID); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if flags.sessionID == "" {
|
flags := request.Config
|
||||||
return fmt.Errorf("plan: session_id is required")
|
|
||||||
}
|
|
||||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
@@ -39,38 +35,91 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := config.Validate(cfg); err != nil {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := loadSecretsFromConfig(cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)); err != nil {
|
selectedArtifacts, err := normalizeArtifactSelection(cfg, request.SelectedArtifacts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
effective, err := resolveEffectiveArtifacts(cfg, selectedArtifacts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
m, err := loadManifestIfPresent(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateBoundedPrerequisites(request.Plan, m); err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
||||||
paths, err := store.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
paths := store.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
model, err := cloneManifestForPlan(m, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: prepare workdir: %w", err)
|
return fmt.Errorf("plan: clone session state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stages := BuildFullPlan()
|
stages := request.Plan.Stages()
|
||||||
var m *manifest.Manifest
|
stageEnv := &stage.Env{
|
||||||
m, err = loadManifestIfPresent(ctx, cfg)
|
Config: cfg, SelectedArtifactKeys: append([]string(nil), selectedArtifacts...),
|
||||||
if err != nil {
|
EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force,
|
||||||
return fmt.Errorf("plan: %w", err)
|
|
||||||
}
|
}
|
||||||
decisions := decideStageActions(stages, m, force)
|
|
||||||
|
|
||||||
runCount := 0
|
runCount := 0
|
||||||
skipCount := 0
|
skipCount := 0
|
||||||
if _, err := fmt.Fprintf(out, "narratio session plan: workdir prepared at %s\n", paths.Root); err != nil {
|
if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s; %s\n", paths.Root, effectiveConfigSummary(cfg)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, d := range decisions {
|
for _, selectedStage := range stages {
|
||||||
if d.Action == stageActionRun {
|
semanticConfig, err := currentStageSemanticConfig(selectedStage, stageEnv)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: fingerprint semantic configuration for stage %q: %w", selectedStage.Name(), err)
|
||||||
|
}
|
||||||
|
action := decideStageAction(selectedStage, model, request.Force)
|
||||||
|
var validation *stage.ResumeValidation
|
||||||
|
if action == stageActionSkip {
|
||||||
|
checked, validationErr := evaluateStageResume(ctx, selectedStage, stageEnv, model, semanticConfig)
|
||||||
|
if validationErr != nil {
|
||||||
|
return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr)
|
||||||
|
}
|
||||||
|
validation = checked
|
||||||
|
if checked != nil && !checked.Resumable {
|
||||||
|
at := time.Now().UTC()
|
||||||
|
model.MarkStageStale(selectedStage.Name(), at, checked.Reason)
|
||||||
|
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(
|
||||||
|
model, selectedStage.Name(), at, staleReasonNotResumable,
|
||||||
|
); invalidationErr != nil {
|
||||||
|
return fmt.Errorf("plan: model resume invalidation for stage %q: %w", selectedStage.Name(), invalidationErr)
|
||||||
|
}
|
||||||
|
action = stageActionRun
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action == stageActionRun && selectedStage.Name() == "analyze" {
|
||||||
|
if validator, ok := selectedStage.(stage.ResumeValidator); ok {
|
||||||
|
checked, validationErr := validator.ValidateResume(ctx, stageEnv, model)
|
||||||
|
if validationErr != nil {
|
||||||
|
return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr)
|
||||||
|
}
|
||||||
|
checked = checked.Normalized()
|
||||||
|
validation = &checked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action == stageActionRun {
|
||||||
runCount++
|
runCount++
|
||||||
} else {
|
} else {
|
||||||
skipCount++
|
skipCount++
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprintf(out, "%s: %s\n", d.Stage.Name(), d.Action); err != nil {
|
if _, err := fmt.Fprintf(out, "%s: %s\n", selectedStage.Name(), action); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if validation != nil && validation.Analyze != nil {
|
||||||
|
if err := writeAnalyzePlanDetails(out, validation.Analyze); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action == stageActionRun {
|
||||||
|
if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force, semanticConfig); err != nil {
|
||||||
|
return fmt.Errorf("plan: model stage %q: %w", selectedStage.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
|
if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -78,3 +127,114 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneManifestForPlan(source *manifest.Manifest, cfg *config.Config) (*manifest.Manifest, error) {
|
||||||
|
if source == nil {
|
||||||
|
created := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
created.Campaign = cfg.Session.Campaign
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cloned manifest.Manifest
|
||||||
|
if err := json.Unmarshal(data, &cloned); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cloned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelPlannedStageRun(
|
||||||
|
model *manifest.Manifest,
|
||||||
|
selectedStage stage.Stage,
|
||||||
|
cfg *config.Config,
|
||||||
|
force bool,
|
||||||
|
semanticConfig *manifest.SemanticConfigFingerprint,
|
||||||
|
) error {
|
||||||
|
prior := capturePriorStageOutcome(model, selectedStage.Name())
|
||||||
|
at := time.Now().UTC()
|
||||||
|
model.MarkStageRunning(selectedStage.Name(), at)
|
||||||
|
if force {
|
||||||
|
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||||
|
model, selectedStage.Name(), at, staleReasonForcedReplacement,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" {
|
||||||
|
model.MarkStageSkipped(selectedStage.Name(), at, reason)
|
||||||
|
setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig)
|
||||||
|
if !prior.isSameSelfSkip(reason) {
|
||||||
|
_, err := invalidateDependentSucceededStagesWithReason(
|
||||||
|
model, selectedStage.Name(), at, staleReasonSelfSkip,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
model.MarkStageSucceeded(selectedStage.Name(), at, nil)
|
||||||
|
setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig)
|
||||||
|
if !prior.exists || prior.status != manifest.StatusSucceeded {
|
||||||
|
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||||
|
model, selectedStage.Name(), at, staleReasonChangedResult,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func plannedSelfSkipReason(stageName string, cfg *config.Config) string {
|
||||||
|
if stageName == "extract" && cfg != nil && cfg.Pipeline != nil &&
|
||||||
|
(cfg.Pipeline.Notarius == nil || !cfg.Pipeline.Notarius.Enabled) {
|
||||||
|
return "notarius_disabled"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAnalyzePlanDetails(out io.Writer, summary *stage.AnalyzeResumeSummary) error {
|
||||||
|
if summary == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, " targets: %s\n", planStringList(summary.ExplicitTargets)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, " prerequisites: %s\n", planArtifactList(summary.PrerequisiteWork)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, " execute: %s\n", planArtifactList(summary.ExecutionOrder)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(out, " reuse: %s\n", planArtifactList(summary.ReusedCurrent))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func planStringList(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
return strings.Join(values, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func planArtifactList(values []stage.AnalyzeResumeArtifact) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
detail := value.Role
|
||||||
|
if value.Reason != "" {
|
||||||
|
detail += ":" + value.Reason
|
||||||
|
}
|
||||||
|
if value.Forced {
|
||||||
|
detail += ":forced"
|
||||||
|
}
|
||||||
|
identity := value.Key
|
||||||
|
if value.Family != "" {
|
||||||
|
identity += fmt.Sprintf("[family=%s character_id=%s]", value.Family, value.CharacterID)
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s(%s)", identity, detail))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"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 TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
func TestPlanDoesNotCreateWorkdir(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
@@ -24,10 +29,10 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
t.Fatalf("first Plan() error = %v", err)
|
t.Fatalf("first Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
got := out.String()
|
got := out.String()
|
||||||
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
if !strings.Contains(got, "narratio session plan: read-only workdir at") {
|
||||||
t.Fatalf("first output = %q, want workdir prepared", got)
|
t.Fatalf("first output = %q, want read-only workdir", got)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||||
if !strings.Contains(got, name+": run") {
|
if !strings.Contains(got, name+": run") {
|
||||||
t.Fatalf("first output = %q, missing stage %q", got, name)
|
t.Fatalf("first output = %q, missing stage %q", got, name)
|
||||||
}
|
}
|
||||||
@@ -37,26 +42,16 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
||||||
expectedDirs := []string{
|
if _, err := os.Stat(sessionWorkdir); !errors.Is(err, os.ErrNotExist) {
|
||||||
sessionWorkdir,
|
t.Fatalf("workdir stat error = %v, want absent", err)
|
||||||
filepath.Join(sessionWorkdir, "inputs"),
|
|
||||||
filepath.Join(sessionWorkdir, "audio"),
|
|
||||||
filepath.Join(sessionWorkdir, "transcripts", "raw"),
|
|
||||||
filepath.Join(sessionWorkdir, "transcripts", "trimmed"),
|
|
||||||
filepath.Join(sessionWorkdir, "artifacts"),
|
|
||||||
filepath.Join(sessionWorkdir, "config"),
|
|
||||||
filepath.Join(sessionWorkdir, "logs"),
|
|
||||||
}
|
|
||||||
for _, dir := range expectedDirs {
|
|
||||||
assertDir(t, dir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Reset()
|
out.Reset()
|
||||||
if err := Plan(context.Background(), args, &out); err != nil {
|
if err := Plan(context.Background(), args, &out); err != nil {
|
||||||
t.Fatalf("second Plan() error = %v", err)
|
t.Fatalf("second Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "narratio session plan: workdir prepared at") {
|
if !strings.Contains(out.String(), "narratio session plan: read-only workdir at") {
|
||||||
t.Fatalf("second output = %q, want workdir prepared", out.String())
|
t.Fatalf("second output = %q, want read-only workdir", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +61,13 @@ func TestPlanShowsRunAndSkipFromManifest(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{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
|
||||||
m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m, err := store.Load(context.Background(), manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load prepared manifest: %v", err)
|
||||||
|
}
|
||||||
m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil)
|
||||||
|
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe")
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
t.Fatalf("save manifest: %v", err)
|
||||||
}
|
}
|
||||||
@@ -89,7 +88,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlanFailsWhenConfiguredSecretsDirMissing(t *testing.T) {
|
func TestPlanDoesNotLoadConfiguredSecrets(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
pipelinePath := filepath.Join(configDir, "pipeline.yml")
|
pipelinePath := filepath.Join(configDir, "pipeline.yml")
|
||||||
@@ -130,21 +129,125 @@ inputs:
|
|||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatalf("Plan() error = %v, want missing runtime secrets ignored", err)
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "validate secrets env_dir") {
|
|
||||||
t.Fatalf("error = %q, want secrets validation error context", err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertDir(t *testing.T, path string) {
|
func TestPlanAndRunShareAnalyzeArtifactDecisionsWithoutPlanSideEffects(t *testing.T) {
|
||||||
t.Helper()
|
workspaceRoot := t.TempDir()
|
||||||
info, err := os.Stat(path)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Stat(%q) error = %v", path, err)
|
t.Fatalf("load config: %v", err)
|
||||||
}
|
}
|
||||||
if !info.IsDir() {
|
analyze, err := stage.Select("analyze")
|
||||||
t.Fatalf("%q is not a directory", path)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fake := &scriptorium.FakeRunner{}
|
||||||
|
first, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
|
||||||
|
Env: &Env{Scriptorium: fake},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initial analyze: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 2 {
|
||||||
|
t.Fatalf("initial adapter requests = %d, want 2", len(fake.RunRequests))
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
m, err := store.Load(context.Background(), first.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
m.MarkStageStale("render", time.Now().UTC(), "upstream selection requires reconsideration")
|
||||||
|
m.MarkStageSkipped("extract", time.Now().UTC(), "notarius_disabled")
|
||||||
|
if err := store.Save(context.Background(), first.ManifestPath, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifestBefore, err := os.ReadFile(first.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
filesBefore := planFixtureFiles(t, filepath.Dir(first.ManifestPath))
|
||||||
|
marker := filepath.Join(t.TempDir(), "adapter-invoked")
|
||||||
|
binaryDir := t.TempDir()
|
||||||
|
binary := filepath.Join(binaryDir, "scriptorium")
|
||||||
|
if err := os.WriteFile(binary, []byte("#!/bin/sh\ntouch \""+marker+"\"\nexit 99\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", binaryDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
err = Plan(context.Background(), []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
|
"--from", "render", "--through", "analyze",
|
||||||
|
}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Plan() error = %v", err)
|
||||||
|
}
|
||||||
|
got := out.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"render: run", "extract: run", "analyze: run",
|
||||||
|
" targets: player_handout, session_recap",
|
||||||
|
" prerequisites: none", " execute: none",
|
||||||
|
"player_handout(target:current)", "session_recap(target:current)",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("plan output = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
manifestAfter, err := os.ReadFile(first.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(manifestBefore, manifestAfter) {
|
||||||
|
t.Fatal("plan modified the session manifest")
|
||||||
|
}
|
||||||
|
if filesAfter := planFixtureFiles(t, filepath.Dir(first.ManifestPath)); !reflect.DeepEqual(filesAfter, filesBefore) {
|
||||||
|
t.Fatalf("plan files = %#v, want unchanged %#v", filesAfter, filesBefore)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("adapter marker stat = %v, want absent", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fake.RunRequests = nil
|
||||||
|
actual, err := executeStages(context.Background(), cfg, []stage.Stage{
|
||||||
|
resultStage{name: "render", result: &stage.StageResult{}},
|
||||||
|
resultStage{name: "extract", result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "notarius_disabled"}},
|
||||||
|
analyze,
|
||||||
|
}, RunOptions{
|
||||||
|
Env: &Env{Scriptorium: fake},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("actual analyze: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(actual.Executed, []string{"render", "extract", "analyze"}) ||
|
||||||
|
!reflect.DeepEqual(actual.Skipped, []string{"extract"}) || len(fake.RunRequests) != 0 {
|
||||||
|
t.Fatalf("actual decision: executed=%#v skipped=%#v adapter_requests=%d, want planned stage decisions with artifact reuse", actual.Executed, actual.Skipped, len(fake.RunRequests))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func planFixtureFiles(t *testing.T, root string) []string {
|
||||||
|
t.Helper()
|
||||||
|
var files []string
|
||||||
|
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files = append(files, relative+":"+entry.Type().String())
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,134 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BoundedPlan is one validated inclusive range of the canonical pipeline.
|
||||||
|
// It owns effective endpoints and membership so command, runner, and
|
||||||
|
// composition callers do not independently interpret range bounds.
|
||||||
|
type BoundedPlan struct {
|
||||||
|
stages []stage.Stage
|
||||||
|
canonicalNames []string
|
||||||
|
startIndex int
|
||||||
|
endIndex int
|
||||||
|
explicitFrom bool
|
||||||
|
explicitThrough bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildBoundedPlan selects an inclusive contiguous range of the canonical
|
||||||
|
// pipeline. Empty endpoints default to the beginning or end respectively.
|
||||||
|
func BuildBoundedPlan(from, through string) (BoundedPlan, error) {
|
||||||
|
registry := stage.All()
|
||||||
|
names := make([]string, len(registry))
|
||||||
|
indices := make(map[string]int, len(registry))
|
||||||
|
for index, candidate := range registry {
|
||||||
|
if candidate == nil {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage %d is nil", index)
|
||||||
|
}
|
||||||
|
name := candidate.Name()
|
||||||
|
if _, duplicate := indices[name]; duplicate {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: duplicate canonical stage %q", name)
|
||||||
|
}
|
||||||
|
names[index] = name
|
||||||
|
indices[name] = index
|
||||||
|
}
|
||||||
|
if len(registry) == 0 {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage registry is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
if from != "" {
|
||||||
|
var ok bool
|
||||||
|
start, ok = indices[from]
|
||||||
|
if !ok {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown from stage %q; valid stages: %s", from, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := len(registry) - 1
|
||||||
|
if through != "" {
|
||||||
|
var ok bool
|
||||||
|
end, ok = indices[through]
|
||||||
|
if !ok {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown through stage %q; valid stages: %s", through, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start > end {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: from stage %q occurs after through stage %q; valid stages: %s", from, through, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return BoundedPlan{
|
||||||
|
stages: append([]stage.Stage(nil), registry[start:end+1]...),
|
||||||
|
canonicalNames: append([]string(nil), names...),
|
||||||
|
startIndex: start,
|
||||||
|
endIndex: end,
|
||||||
|
explicitFrom: from != "",
|
||||||
|
explicitThrough: through != "",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stages returns a copy of the selected canonical stages.
|
||||||
|
func (p BoundedPlan) Stages() []stage.Stage {
|
||||||
|
return append([]stage.Stage(nil), p.stages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names returns selected stage names in canonical order.
|
||||||
|
func (p BoundedPlan) Names() []string {
|
||||||
|
out := make([]string, 0, len(p.stages))
|
||||||
|
for _, candidate := range p.stages {
|
||||||
|
out = append(out, candidate.Name())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// From returns the effective inclusive start stage.
|
||||||
|
func (p BoundedPlan) From() string {
|
||||||
|
if len(p.canonicalNames) == 0 || p.startIndex < 0 || p.startIndex >= len(p.canonicalNames) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.canonicalNames[p.startIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Through returns the effective inclusive end stage.
|
||||||
|
func (p BoundedPlan) Through() string {
|
||||||
|
if len(p.canonicalNames) == 0 || p.endIndex < 0 || p.endIndex >= len(p.canonicalNames) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.canonicalNames[p.endIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether a canonical stage is selected by the range.
|
||||||
|
func (p BoundedPlan) Contains(name string) bool {
|
||||||
|
for _, candidate := range p.stages {
|
||||||
|
if candidate.Name() == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefixNames returns canonical stages excluded before the selected start.
|
||||||
|
func (p BoundedPlan) PrefixNames() []string {
|
||||||
|
if p.startIndex <= 0 || p.startIndex > len(p.canonicalNames) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]string(nil), p.canonicalNames[:p.startIndex]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasExplicitBounds reports whether either endpoint was supplied by the caller.
|
||||||
|
func (p BoundedPlan) HasExplicitBounds() bool {
|
||||||
|
return p.explicitFrom || p.explicitThrough
|
||||||
|
}
|
||||||
|
|
||||||
// BuildFullPlan returns the canonical full stage list in deterministic order.
|
// BuildFullPlan returns the canonical full stage list in deterministic order.
|
||||||
func BuildFullPlan() []stage.Stage {
|
func BuildFullPlan() []stage.Stage {
|
||||||
return stage.All()
|
plan, err := BuildBoundedPlan("", "")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return plan.Stages()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSingleStagePlan returns a one-stage plan for an exact stage name.
|
// BuildSingleStagePlan returns a one-stage plan for an exact stage name.
|
||||||
@@ -19,3 +140,22 @@ func BuildSingleStagePlan(name string) ([]stage.Stage, error) {
|
|||||||
}
|
}
|
||||||
return []stage.Stage{s}, nil
|
return []stage.Stage{s}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildSingleStageExecutionPlan selects one canonical stage without applying
|
||||||
|
// bounded-run prefix prerequisites. The run-stage family validates the stage's
|
||||||
|
// concrete inputs and intentionally retains its established direct-execution
|
||||||
|
// semantics.
|
||||||
|
func buildSingleStageExecutionPlan(name string) (BoundedPlan, error) {
|
||||||
|
stages, err := BuildSingleStagePlan(name)
|
||||||
|
if err != nil {
|
||||||
|
return BoundedPlan{}, err
|
||||||
|
}
|
||||||
|
plan, err := BuildBoundedPlan(name, name)
|
||||||
|
if err != nil {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build stage plan: %w", err)
|
||||||
|
}
|
||||||
|
plan.stages = stages
|
||||||
|
plan.explicitFrom = false
|
||||||
|
plan.explicitThrough = false
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBuildFullPlanOrder(t *testing.T) {
|
func TestBuildFullPlanOrder(t *testing.T) {
|
||||||
got := BuildFullPlan()
|
got := BuildFullPlan()
|
||||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"}
|
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
||||||
}
|
}
|
||||||
@@ -34,3 +40,113 @@ func TestBuildSingleStagePlanUnknown(t *testing.T) {
|
|||||||
t.Fatal("expected error for unknown stage, got nil")
|
t.Fatal("expected error for unknown stage, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildBoundedPlanEndpoints(t *testing.T) {
|
||||||
|
canonical := stageNames(BuildFullPlan())
|
||||||
|
for index, name := range canonical {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
one, err := BuildBoundedPlan(name, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", name, name, err)
|
||||||
|
}
|
||||||
|
if got := one.Names(); !reflect.DeepEqual(got, []string{name}) {
|
||||||
|
t.Fatalf("one-stage names = %#v, want %q", got, name)
|
||||||
|
}
|
||||||
|
if one.From() != name || one.Through() != name || !one.Contains(name) || !one.HasExplicitBounds() {
|
||||||
|
t.Fatalf("one-stage plan endpoints or membership = %#v", one)
|
||||||
|
}
|
||||||
|
|
||||||
|
from, err := BuildBoundedPlan(name, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(%q, empty) error = %v", name, err)
|
||||||
|
}
|
||||||
|
if got := from.Names(); !reflect.DeepEqual(got, canonical[index:]) {
|
||||||
|
t.Fatalf("from names = %#v, want %#v", got, canonical[index:])
|
||||||
|
}
|
||||||
|
|
||||||
|
through, err := BuildBoundedPlan("", name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(empty, %q) error = %v", name, err)
|
||||||
|
}
|
||||||
|
if got := through.Names(); !reflect.DeepEqual(got, canonical[:index+1]) {
|
||||||
|
t.Fatalf("through names = %#v, want %#v", got, canonical[:index+1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBoundedPlanDefaultsToFullCanonicalPlan(t *testing.T) {
|
||||||
|
plan, err := BuildBoundedPlan("", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan() error = %v", err)
|
||||||
|
}
|
||||||
|
want := stageNames(BuildFullPlan())
|
||||||
|
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("bounded names = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if plan.From() != want[0] || plan.Through() != want[len(want)-1] || plan.HasExplicitBounds() {
|
||||||
|
t.Fatalf("default endpoints = %q through %q explicit=%t", plan.From(), plan.Through(), plan.HasExplicitBounds())
|
||||||
|
}
|
||||||
|
if got := plan.PrefixNames(); len(got) != 0 {
|
||||||
|
t.Fatalf("default prefix = %#v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBoundedPlanRejectsInvalidBounds(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
from string
|
||||||
|
through string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "unknown from", from: "missing", through: "analyze", want: []string{"unknown from stage", "missing", "prepare", "notify"}},
|
||||||
|
{name: "unknown through", from: "extract", through: "missing", want: []string{"unknown through stage", "missing", "prepare", "notify"}},
|
||||||
|
{name: "reversed", from: "publish", through: "render", want: []string{"publish", "occurs after", "render", "prepare", "notify"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := BuildBoundedPlan(test.from, test.through)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildBoundedPlan() error = nil")
|
||||||
|
}
|
||||||
|
for _, fragment := range test.want {
|
||||||
|
if !strings.Contains(err.Error(), fragment) {
|
||||||
|
t.Fatalf("error = %q, want fragment %q", err, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedPlanIsContiguousAndCannotMutateRegistry(t *testing.T) {
|
||||||
|
before := stageNames(BuildFullPlan())
|
||||||
|
plan, err := BuildBoundedPlan("trim", "analyze")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"trim", "render", "extract", "analyze"}
|
||||||
|
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("names = %#v, want contiguous %#v", got, want)
|
||||||
|
}
|
||||||
|
if got := plan.PrefixNames(); !reflect.DeepEqual(got, before[:5]) {
|
||||||
|
t.Fatalf("prefix = %#v, want %#v", got, before[:5])
|
||||||
|
}
|
||||||
|
stages := plan.Stages()
|
||||||
|
stages[0] = nil
|
||||||
|
names := plan.Names()
|
||||||
|
names[0] = "changed"
|
||||||
|
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("mutated plan names = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if got := stageNames(BuildFullPlan()); !reflect.DeepEqual(got, before) {
|
||||||
|
t.Fatalf("canonical registry changed = %#v, want %#v", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stageNames(stages []stage.Stage) []string {
|
||||||
|
names := make([]string, 0, len(stages))
|
||||||
|
for _, candidate := range stages {
|
||||||
|
names = append(names, candidate.Name())
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,7 +146,12 @@ func TestPostPublishCleanupRetriesWhenInitialObligationSaveFails(t *testing.T) {
|
|||||||
|
|
||||||
store.fail = nil
|
store.fail = nil
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("retry executeStages() error = %v", err)
|
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertCleanupPending(t, cfg)
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
assertMissing(t, seed.spoolAudioDir)
|
assertMissing(t, seed.spoolAudioDir)
|
||||||
assertCleanupComplete(t, cfg)
|
assertCleanupComplete(t, cfg)
|
||||||
@@ -178,7 +183,12 @@ func TestPostPublishCleanupRetriesFailedDeletionWithoutTouchingOtherRuns(t *test
|
|||||||
|
|
||||||
removeRunScopedDirFn = originalRemove
|
removeRunScopedDirFn = originalRemove
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("retry executeStages() error = %v", err)
|
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
assertCleanupPending(t, cfg)
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
assertMissing(t, seed.runWorkDir)
|
assertMissing(t, seed.runWorkDir)
|
||||||
assertExists(t, seed.otherRunDir)
|
assertExists(t, seed.otherRunDir)
|
||||||
@@ -218,12 +228,16 @@ func TestPostPublishCleanupRetriesWhenCompletionEvidenceSaveFails(t *testing.T)
|
|||||||
|
|
||||||
store.fail = nil
|
store.fail = nil
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("retry executeStages() error = %v", err)
|
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
assertCleanupPending(t, cfg)
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
assertCleanupComplete(t, cfg)
|
assertCleanupComplete(t, cfg)
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("idempotent retry executeStages() error = %v", err)
|
t.Fatalf("idempotent non-publish executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
assertMissing(t, seed.spoolAudioDir)
|
assertMissing(t, seed.spoolAudioDir)
|
||||||
}
|
}
|
||||||
@@ -328,7 +342,10 @@ func TestPostPublishCleanupFailsOnUnsafePath(t *testing.T) {
|
|||||||
t.Fatalf("Save() error = %v", err)
|
t.Fatalf("Save() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
if _, err = executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||||
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
|
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
|
||||||
t.Fatalf("executeStages() error = %v, want safe-path failure", err)
|
t.Fatalf("executeStages() error = %v, want safe-path failure", err)
|
||||||
}
|
}
|
||||||
@@ -525,6 +542,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
|
|||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
||||||
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
|
setAppAnalyzeEvidence(seedManifest, "session_recap", "artifacts/session_recap.md", []byte("# recap\n"))
|
||||||
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
||||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
||||||
|
|||||||
72
internal/app/profile_commands_test.go
Normal file
72
internal/app/profile_commands_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
enableCommandTestProfiles(t, pipelinePath)
|
||||||
|
|
||||||
|
original := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = original })
|
||||||
|
var language string
|
||||||
|
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||||
|
language = cfg.Pipeline.WhisperX.Language
|
||||||
|
return &RunSummary{SessionID: cfg.Session.SessionID, ManifestPath: "manifest.json"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Run(context.Background(), []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing",
|
||||||
|
}, io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if language != "fr" {
|
||||||
|
t.Fatalf("explicit profile language = %q, want fr", language)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Run(context.Background(), []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
|
}, io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if language != "en" {
|
||||||
|
t.Fatalf("default profile language = %q, want en", language)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, profile := range []string{"unknown", ""} {
|
||||||
|
err := Run(context.Background(), []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", profile,
|
||||||
|
}, io.Discard)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "profile") {
|
||||||
|
t.Fatalf("profile %q error = %v, want selection rejection", profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableCommandTestProfiles(t *testing.T, pipelinePath string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(pipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
composition := "composition:\n default_profile: production\n profiles:\n production:\n overlay: production.yml\n testing:\n overlay: testing.yml\n"
|
||||||
|
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(pipelinePath)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "production.yml"), []byte("whisperx:\n language: en\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "testing.yml"), []byte("whisperx:\n language: fr\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/app/regenerate_artifacts.go
Normal file
43
internal/app/regenerate_artifacts.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegenerateArtifacts expands the convenience command into its canonical run
|
||||||
|
// invocation. The run command remains the sole owner of parsing and execution.
|
||||||
|
func RegenerateArtifacts(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
if containsHelpOption(args) {
|
||||||
|
printRegenerateArtifactsHelp(out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded := make([]string, 0, len(args)+5)
|
||||||
|
if len(args) > 0 && !isCLIFlagToken(args[0]) {
|
||||||
|
expanded = append(expanded, args[0])
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
expanded = append(expanded, "--force", "--from", "extract", "--through", "analyze")
|
||||||
|
expanded = append(expanded, args...)
|
||||||
|
return runCommandFn(ctx, expanded, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsHelpOption(args []string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-h" || arg == "--help" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func printRegenerateArtifactsHelp(out io.Writer) {
|
||||||
|
_, _ = fmt.Fprintln(out, "Usage: narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [common config flags]")
|
||||||
|
_, _ = fmt.Fprintln(out)
|
||||||
|
_, _ = fmt.Fprintln(out, "Exactly equivalent to:")
|
||||||
|
_, _ = fmt.Fprintln(out, " narratio run <session_id> --force --from extract --through analyze [caller options]")
|
||||||
|
_, _ = fmt.Fprintln(out)
|
||||||
|
_, _ = fmt.Fprintln(out, "Extraction always runs; selected analysis artifacts and their required prerequisites are rebuilt. Publish and notify never run.")
|
||||||
|
}
|
||||||
137
internal/app/regenerate_artifacts_test.go
Normal file
137
internal/app/regenerate_artifacts_test.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
|
||||||
|
original := runCommandFn
|
||||||
|
t.Cleanup(func() { runCommandFn = original })
|
||||||
|
var captured []string
|
||||||
|
runCommandFn = func(_ context.Context, args []string, _ io.Writer) error {
|
||||||
|
captured = append([]string(nil), args...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
code := Execute([]string{
|
||||||
|
"regenerate-artifacts", "2026-05-03",
|
||||||
|
"--artifacts", "session_recap,player_handout",
|
||||||
|
"--artifacts=player_handout",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
"--campaign", "sample-campaign",
|
||||||
|
"--profile", "testing",
|
||||||
|
}, io.Discard, io.Discard)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Execute() code = %d, want 0", code)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"2026-05-03", "--force", "--from", "extract", "--through", "analyze",
|
||||||
|
"--artifacts", "session_recap,player_handout",
|
||||||
|
"--artifacts=player_handout",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
"--campaign", "sample-campaign",
|
||||||
|
"--profile", "testing",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured, want) {
|
||||||
|
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsForwardsSessionIDCompatibilityFlag(t *testing.T) {
|
||||||
|
original := runCommandFn
|
||||||
|
t.Cleanup(func() { runCommandFn = original })
|
||||||
|
var captured []string
|
||||||
|
runCommandFn = func(_ context.Context, args []string, _ io.Writer) error {
|
||||||
|
captured = append([]string(nil), args...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
code := Execute([]string{
|
||||||
|
"regenerate-artifacts",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
"--session-id", "2026-05-03",
|
||||||
|
"--artifacts", "session_recap",
|
||||||
|
}, io.Discard, io.Discard)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Execute() code = %d, want 0", code)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"--force", "--from", "extract", "--through", "analyze",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
"--session-id", "2026-05-03",
|
||||||
|
"--artifacts", "session_recap",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured, want) {
|
||||||
|
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsHelpDoesNotInvokeRun(t *testing.T) {
|
||||||
|
original := runCommandFn
|
||||||
|
t.Cleanup(func() { runCommandFn = original })
|
||||||
|
called := false
|
||||||
|
runCommandFn = func(_ context.Context, _ []string, _ io.Writer) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute([]string{"regenerate-artifacts", "--help"}, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("Execute() code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("help invoked canonical run handler")
|
||||||
|
}
|
||||||
|
for _, detail := range []string{
|
||||||
|
"narratio run <session_id> --force --from extract --through analyze",
|
||||||
|
"Extraction always runs",
|
||||||
|
"Publish and notify never run",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(stdout.String(), detail) {
|
||||||
|
t.Fatalf("help = %q, want %q", stdout.String(), detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsOwnedOptionsFailThroughRunParser(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
owned string
|
||||||
|
}{
|
||||||
|
{name: "force", args: []string{"--force"}, owned: "force"},
|
||||||
|
{name: "from", args: []string{"--from=render"}, owned: "from"},
|
||||||
|
{name: "through", args: []string{"--through", "publish"}, owned: "through"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
args := []string{"regenerate-artifacts", "2026-05-03"}
|
||||||
|
args = append(args, test.args...)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute(args, io.Discard, &stderr); code == 0 {
|
||||||
|
t.Fatalf("Execute(%#v) code = 0", args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "--"+test.owned+" may be specified only once") {
|
||||||
|
t.Fatalf("stderr = %q, want shared duplicate %s error", stderr.String(), test.owned)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsRejectsUnknownOptionsThroughRunParser(t *testing.T) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute([]string{"regenerate-artifacts", "2026-05-03", "--regenerate-only"}, io.Discard, &stderr); code == 0 {
|
||||||
|
t.Fatal("Execute() code = 0")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "flag provided but not defined") || !strings.Contains(stderr.String(), "regenerate-only") {
|
||||||
|
t.Fatalf("stderr = %q, want canonical parser unknown-option error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
|
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
|
||||||
@@ -37,7 +36,7 @@ inputs:
|
|||||||
if storeInitCalls != 1 {
|
if storeInitCalls != 1 {
|
||||||
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "narratio session plan: workdir prepared") {
|
if !strings.Contains(stdout.String(), "narratio session plan: read-only workdir") {
|
||||||
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
||||||
}
|
}
|
||||||
if _, ok := fake.Objects[remoteKey]; !ok {
|
if _, ok := fake.Objects[remoteKey]; !ok {
|
||||||
@@ -45,6 +44,49 @@ inputs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteSessionLoadingRetainsCampaignCanonicalParty(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
canonicalCampaign := `campaign_id: sample-campaign
|
||||||
|
inputs:
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
party_file: ./party.yml
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(campaignPath, []byte(canonicalCampaign), 0o644); err != nil {
|
||||||
|
t.Fatalf("write canonical campaign: %v", err)
|
||||||
|
}
|
||||||
|
canonicalParty := `schema_version: narratio.party.v1
|
||||||
|
characters:
|
||||||
|
arannis:
|
||||||
|
player: {name: Eric}
|
||||||
|
character:
|
||||||
|
name: Arannis
|
||||||
|
classes: [{name: wizard}]
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(filepath.Dir(campaignPath), "party.yml"), []byte(canonicalParty), 0o644); err != nil {
|
||||||
|
t.Fatalf("write canonical party: %v", err)
|
||||||
|
}
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
|
||||||
|
inputs:
|
||||||
|
audio_s3:
|
||||||
|
prefix: audio/
|
||||||
|
`)
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
||||||
|
|
||||||
|
loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, "", config.SessionLoadOptions{SessionID: "2026-05-03"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadCommandConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = loaded.Close() }()
|
||||||
|
if loaded.Config.Party.Mode != config.PartyModeCanonical || loaded.Config.StableInputs.PlayersFile.Source != "derived_from_party" {
|
||||||
|
t.Fatalf("remote config party = %#v, players = %#v", loaded.Config.Party, loaded.Config.StableInputs.PlayersFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) {
|
func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -85,7 +127,7 @@ inputs:
|
|||||||
`,
|
`,
|
||||||
command: []string{"run", "2026-05-03"},
|
command: []string{"run", "2026-05-03"},
|
||||||
configureRun: func() {
|
configureRun: func() {
|
||||||
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||||
return nil, errors.New("adapter failed")
|
return nil, errors.New("adapter failed")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,7 +141,7 @@ inputs:
|
|||||||
`,
|
`,
|
||||||
command: []string{"run", "2026-05-03"},
|
command: []string{"run", "2026-05-03"},
|
||||||
configureRun: func() {
|
configureRun: func() {
|
||||||
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||||
return nil, context.Canceled
|
return nil, context.Canceled
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -167,6 +209,9 @@ inputs:
|
|||||||
if err := loaded.Close(); err != nil {
|
if err := loaded.Close(); err != nil {
|
||||||
t.Fatalf("second Close() error = %v", err)
|
t.Fatalf("second Close() error = %v", err)
|
||||||
}
|
}
|
||||||
|
if loaded.Config == nil || loaded.Config.Pipeline == nil || loaded.Config.Campaign == nil || loaded.Config.Session == nil {
|
||||||
|
t.Fatal("closing remote session cleanup discarded retained configuration")
|
||||||
|
}
|
||||||
if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err)
|
t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"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 TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
||||||
@@ -34,12 +33,12 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
if opts.Env == nil {
|
if opts.Env == nil {
|
||||||
opts.Env = &Env{}
|
opts.Env = &Env{}
|
||||||
}
|
}
|
||||||
opts.Env.Scriptorium = &scriptorium.NoopRunner{}
|
opts.Env.Scriptorium = &scriptorium.NoopRunner{}
|
||||||
return executeStages(ctx, cfg, stages, opts)
|
return executePlan(ctx, cfg, plan, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
@@ -224,12 +223,12 @@ previous_session_id: 2026-04-26
|
|||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
newObjectStoreFromConfigFn = origObjectStoreFn
|
newObjectStoreFromConfigFn = origObjectStoreFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
if opts.Env == nil {
|
if opts.Env == nil {
|
||||||
opts.Env = &Env{}
|
opts.Env = &Env{}
|
||||||
}
|
}
|
||||||
opts.Env.Scriptorium = scriptoriumFake
|
opts.Env.Scriptorium = scriptoriumFake
|
||||||
return executeStages(ctx, cfg, stages, opts)
|
return executePlan(ctx, cfg, plan, opts)
|
||||||
}
|
}
|
||||||
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
||||||
objectStoreConstructed = true
|
objectStoreConstructed = true
|
||||||
@@ -282,6 +281,7 @@ func restoreWorkflowManifestJSON(t *testing.T, sessionID, campaign string) []byt
|
|||||||
for i, stageName := range stages {
|
for i, stageName := range stages {
|
||||||
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
|
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
|
||||||
}
|
}
|
||||||
|
setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", []byte("# restored recap\n"))
|
||||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||||
if err := store.Save(context.Background(), path, m); err != nil {
|
if err := store.Save(context.Background(), path, m); err != nil {
|
||||||
t.Fatalf("save workflow manifest fixture: %v", err)
|
t.Fatalf("save workflow manifest fixture: %v", err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -11,22 +12,14 @@ import (
|
|||||||
|
|
||||||
// Run executes the pipeline plan and persists manifest state.
|
// Run executes the pipeline plan and persists manifest state.
|
||||||
func Run(ctx context.Context, args []string, out io.Writer) error {
|
func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
request, err := parseBoundedRunRequest("run", args, out)
|
||||||
fs.SetOutput(io.Discard)
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
var flags commonConfigFlags
|
return nil
|
||||||
var force bool
|
}
|
||||||
var selectedArtifacts artifactSelectionFlag
|
|
||||||
addCommonConfigFlags(fs, &flags)
|
|
||||||
fs.BoolVar(&force, "force", false, "rerun stages even when already succeeded")
|
|
||||||
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
|
|
||||||
|
|
||||||
if err := parseSessionAwareFlags("run", fs, args, &flags.sessionID); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if flags.sessionID == "" {
|
flags := request.Config
|
||||||
return fmt.Errorf("run: session_id is required")
|
|
||||||
}
|
|
||||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
@@ -36,18 +29,17 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := config.Validate(cfg); err != nil {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
selectedArtifacts, err := normalizeArtifactSelection(cfg, request.SelectedArtifacts)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("run: invalid --artifacts: %w", err)
|
|
||||||
}
|
|
||||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, normalizedArtifacts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
stages := BuildFullPlan()
|
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, selectedArtifacts)
|
||||||
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
|
if err != nil {
|
||||||
Force: force,
|
return fmt.Errorf("run: %w", err)
|
||||||
SelectedArtifacts: normalizedArtifacts,
|
}
|
||||||
|
summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{
|
||||||
|
Force: request.Force,
|
||||||
|
SelectedArtifacts: selectedArtifacts,
|
||||||
EffectiveArtifacts: effectiveArtifacts,
|
EffectiveArtifacts: effectiveArtifacts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,11 +48,12 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
_, err = fmt.Fprintf(
|
_, err = fmt.Fprintf(
|
||||||
out,
|
out,
|
||||||
"narratio run: session %s; executed=%d skipped=%d; manifest=%s\n",
|
"narratio run: session %s; executed=%d skipped=%d; manifest=%s; %s\n",
|
||||||
summary.SessionID,
|
summary.SessionID,
|
||||||
len(summary.Executed),
|
len(summary.Executed),
|
||||||
len(summary.Skipped),
|
len(summary.Skipped),
|
||||||
summary.ManifestPath,
|
summary.ManifestPath,
|
||||||
|
effectiveConfigSummary(cfg),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ const (
|
|||||||
stageActionSkip stageAction = "skip"
|
stageActionSkip stageAction = "skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stageDecision struct {
|
|
||||||
Stage stage.Stage
|
|
||||||
Action stageAction
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
staleReasonForcedReplacement = "upstream stage was force-run"
|
staleReasonForcedReplacement = "upstream stage was force-run"
|
||||||
staleReasonChangedResult = "upstream stage result changed"
|
staleReasonChangedResult = "upstream stage result changed"
|
||||||
@@ -39,19 +34,9 @@ type priorStageOutcome struct {
|
|||||||
outputs int
|
outputs int
|
||||||
}
|
}
|
||||||
|
|
||||||
func decideStageActions(stages []stage.Stage, m *manifest.Manifest, force bool) []stageDecision {
|
|
||||||
out := make([]stageDecision, 0, len(stages))
|
|
||||||
for _, s := range stages {
|
|
||||||
out = append(out, stageDecision{
|
|
||||||
Stage: s,
|
|
||||||
Action: decideStageAction(s, m, force),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func decideStageAction(s stage.Stage, m *manifest.Manifest, force bool) stageAction {
|
func decideStageAction(s stage.Stage, m *manifest.Manifest, force bool) stageAction {
|
||||||
// TODO: incorporate stale detection once checksum/input change tracking is implemented.
|
// A succeeded aggregate record is the initial skip candidate. The runner and
|
||||||
|
// planner then let stage-owned resume validation refine that decision.
|
||||||
if !force && stageSucceeded(m, s.Name()) {
|
if !force && stageSucceeded(m, s.Name()) {
|
||||||
return stageActionSkip
|
return stageActionSkip
|
||||||
}
|
}
|
||||||
@@ -122,30 +107,153 @@ func canonicalStageNames() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func downstreamStageNames(stageName string) []string {
|
type invalidationRelation struct {
|
||||||
names := canonicalStageNames()
|
canonical []string
|
||||||
for i, name := range names {
|
direct map[string][]string
|
||||||
if name != stageName {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return append([]string(nil), names[i+1:]...)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidateDownstreamSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) []string {
|
var canonicalInvalidationEdges = map[string][]string{
|
||||||
if m == nil || m.Stages == nil {
|
"prepare": {"transcribe"},
|
||||||
|
"transcribe": {"merge"},
|
||||||
|
"merge": {"polish"},
|
||||||
|
"polish": {"normalize"},
|
||||||
|
"normalize": {"trim"},
|
||||||
|
"trim": {"render", "extract"},
|
||||||
|
"render": {"analyze"},
|
||||||
|
"extract": {"analyze"},
|
||||||
|
"analyze": {"publish"},
|
||||||
|
"publish": {"notify"},
|
||||||
|
"notify": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInvalidationRelation(registry []stage.Stage, direct map[string][]string) (*invalidationRelation, error) {
|
||||||
|
canonical := make([]string, 0, len(registry))
|
||||||
|
known := make(map[string]struct{}, len(registry))
|
||||||
|
for index, candidate := range registry {
|
||||||
|
if candidate == nil {
|
||||||
|
return nil, fmt.Errorf("canonical stage registry entry %d is nil", index)
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(candidate.Name())
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("canonical stage registry entry %d has an empty name", index)
|
||||||
|
}
|
||||||
|
if _, duplicate := known[name]; duplicate {
|
||||||
|
return nil, fmt.Errorf("canonical stage registry contains duplicate stage %q", name)
|
||||||
|
}
|
||||||
|
known[name] = struct{}{}
|
||||||
|
canonical = append(canonical, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := make(map[string][]string, len(direct))
|
||||||
|
for source, targets := range direct {
|
||||||
|
if _, ok := known[source]; !ok {
|
||||||
|
return nil, fmt.Errorf("invalidation relation classifies unknown stage %q", source)
|
||||||
|
}
|
||||||
|
cloned[source] = []string{}
|
||||||
|
seenTargets := make(map[string]struct{}, len(targets))
|
||||||
|
for _, target := range targets {
|
||||||
|
if _, ok := known[target]; !ok {
|
||||||
|
return nil, fmt.Errorf("invalidation relation edge %q -> %q references an unknown stage", source, target)
|
||||||
|
}
|
||||||
|
if _, duplicate := seenTargets[target]; duplicate {
|
||||||
|
return nil, fmt.Errorf("invalidation relation contains duplicate edge %q -> %q", source, target)
|
||||||
|
}
|
||||||
|
seenTargets[target] = struct{}{}
|
||||||
|
cloned[source] = append(cloned[source], target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range canonical {
|
||||||
|
if _, classified := direct[name]; !classified {
|
||||||
|
return nil, fmt.Errorf("invalidation relation is missing classification for stage %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relation := &invalidationRelation{canonical: canonical, direct: cloned}
|
||||||
|
visiting := make(map[string]bool, len(canonical))
|
||||||
|
visited := make(map[string]bool, len(canonical))
|
||||||
|
var visit func(string) error
|
||||||
|
visit = func(name string) error {
|
||||||
|
if visiting[name] {
|
||||||
|
return fmt.Errorf("invalidation relation contains a cycle involving stage %q", name)
|
||||||
|
}
|
||||||
|
if visited[name] {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
visiting[name] = true
|
||||||
|
for _, target := range relation.direct[name] {
|
||||||
|
if err := visit(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visiting[name] = false
|
||||||
|
visited[name] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, name := range canonical {
|
||||||
|
if err := visit(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return relation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalInvalidationRelation() (*invalidationRelation, error) {
|
||||||
|
return newInvalidationRelation(stage.All(), canonicalInvalidationEdges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *invalidationRelation) Dependents(stageName string) ([]string, error) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("invalidation relation is nil")
|
||||||
|
}
|
||||||
|
if _, ok := r.direct[stageName]; !ok {
|
||||||
|
return nil, fmt.Errorf("unknown stage %q in invalidation relation", stageName)
|
||||||
|
}
|
||||||
|
reachable := make(map[string]bool, len(r.canonical))
|
||||||
|
var collect func(string)
|
||||||
|
collect = func(name string) {
|
||||||
|
for _, target := range r.direct[name] {
|
||||||
|
if reachable[target] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reachable[target] = true
|
||||||
|
collect(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect(stageName)
|
||||||
|
out := make([]string, 0, len(reachable))
|
||||||
|
for _, name := range r.canonical {
|
||||||
|
if reachable[name] {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dependentStageNames(stageName string) ([]string, error) {
|
||||||
|
relation, err := canonicalInvalidationRelation()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return relation.Dependents(stageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateDependentSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) ([]string, error) {
|
||||||
|
dependents, err := dependentStageNames(upstreamStage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m == nil || m.Stages == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
invalidated := make([]string, 0)
|
invalidated := make([]string, 0)
|
||||||
for _, downstream := range downstreamStageNames(upstreamStage) {
|
for _, dependent := range dependents {
|
||||||
sr := m.Stages[downstream]
|
sr := m.Stages[dependent]
|
||||||
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.MarkStageStale(downstream, at, reason)
|
m.MarkStageStale(dependent, at, reason)
|
||||||
invalidated = append(invalidated, downstream)
|
invalidated = append(invalidated, dependent)
|
||||||
}
|
}
|
||||||
return invalidated
|
return invalidated, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,109 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDecideStageActions(t *testing.T) {
|
func TestDecideStageAction(t *testing.T) {
|
||||||
stages := BuildFullPlan()[:2]
|
stages := BuildFullPlan()[:2]
|
||||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||||
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
||||||
|
|
||||||
got := decideStageActions(stages, m, false)
|
if got := decideStageAction(stages[0], m, false); got != stageActionSkip {
|
||||||
if len(got) != 2 {
|
t.Fatalf("prepare action = %q, want %q", got, stageActionSkip)
|
||||||
t.Fatalf("len(decisions) = %d, want 2", len(got))
|
|
||||||
}
|
}
|
||||||
if got[0].Action != stageActionSkip {
|
if got := decideStageAction(stages[1], m, false); got != stageActionRun {
|
||||||
t.Fatalf("prepare action = %q, want %q", got[0].Action, stageActionSkip)
|
t.Fatalf("transcribe action = %q, want %q", got, stageActionRun)
|
||||||
}
|
|
||||||
if got[1].Action != stageActionRun {
|
|
||||||
t.Fatalf("transcribe action = %q, want %q", got[1].Action, stageActionRun)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
forced := decideStageActions(stages, m, true)
|
if got := decideStageAction(stages[0], m, true); got != stageActionRun {
|
||||||
if forced[0].Action != stageActionRun {
|
t.Fatalf("forced prepare action = %q, want %q", got, stageActionRun)
|
||||||
t.Fatalf("forced prepare action = %q, want %q", forced[0].Action, stageActionRun)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDownstreamStageNames(t *testing.T) {
|
func TestInvalidationDependents(t *testing.T) {
|
||||||
got := downstreamStageNames("polish")
|
tests := []struct {
|
||||||
want := []string{"normalize", "trim", "extract", "render", "analyze", "publish", "notify"}
|
stage string
|
||||||
if !reflect.DeepEqual(got, want) {
|
want []string
|
||||||
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
}{
|
||||||
|
{"prepare", []string{"transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"transcribe", []string{"merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"merge", []string{"polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"polish", []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"normalize", []string{"trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"trim", []string{"render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"render", []string{"analyze", "publish", "notify"}},
|
||||||
|
{"extract", []string{"analyze", "publish", "notify"}},
|
||||||
|
{"analyze", []string{"publish", "notify"}},
|
||||||
|
{"publish", []string{"notify"}},
|
||||||
|
{"notify", []string{}},
|
||||||
}
|
}
|
||||||
|
for _, test := range tests {
|
||||||
missing := downstreamStageNames("unknown")
|
t.Run(test.stage, func(t *testing.T) {
|
||||||
if len(missing) != 0 {
|
got, err := dependentStageNames(test.stage)
|
||||||
t.Fatalf("downstreamStageNames(unknown) = %#v, want empty", missing)
|
if err != nil {
|
||||||
|
t.Fatalf("dependentStageNames(%q) error = %v", test.stage, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("dependentStageNames(%q) = %#v, want %#v", test.stage, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := dependentStageNames("unknown"); err == nil || !strings.Contains(err.Error(), "unknown stage") {
|
||||||
|
t.Fatalf("dependentStageNames(unknown) error = %v, want unknown-stage error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInvalidateDownstreamSucceededStagesWithReason(t *testing.T) {
|
func TestInvalidationRelationRejectsInvalidInventory(t *testing.T) {
|
||||||
|
canonical := []stage.Stage{
|
||||||
|
invalidationTestStage("one"),
|
||||||
|
invalidationTestStage("two"),
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
registry []stage.Stage
|
||||||
|
edges map[string][]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "duplicate registry name", registry: append(canonical, invalidationTestStage("one")), edges: map[string][]string{"one": {"two"}, "two": {}}, want: "duplicate stage"},
|
||||||
|
{name: "unknown source", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {}, "three": {}}, want: "unknown stage"},
|
||||||
|
{name: "unknown target", registry: canonical, edges: map[string][]string{"one": {"three"}, "two": {}}, want: "unknown stage"},
|
||||||
|
{name: "missing classification", registry: canonical, edges: map[string][]string{"one": {"two"}}, want: "missing classification"},
|
||||||
|
{name: "cycle", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {"one"}}, want: "cycle"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := newInvalidationRelation(test.registry, test.edges)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("newInvalidationRelation() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidateDependentSucceededStagesWithReason(t *testing.T) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
m := manifest.New("2026-05-03", now)
|
m := manifest.New("2026-05-03", now)
|
||||||
m.MarkStageSucceeded("prepare", now, nil)
|
for _, name := range canonicalStageNames() {
|
||||||
m.MarkStageSucceeded("transcribe", now, nil)
|
m.MarkStageSucceeded(name, now, nil)
|
||||||
m.MarkStageSucceeded("merge", now, nil)
|
|
||||||
m.MarkStageSucceeded("polish", now, nil)
|
|
||||||
m.MarkStageSucceeded("normalize", now, nil)
|
|
||||||
m.MarkStageSucceeded("trim", now, nil)
|
|
||||||
m.MarkStageSucceeded("extract", now, nil)
|
|
||||||
m.MarkStageSucceeded("render", now, nil)
|
|
||||||
m.MarkStageFailed("analyze", now, "analysis failed")
|
|
||||||
m.MarkStageSucceeded("publish", now, nil)
|
|
||||||
m.MarkStageSucceeded("notify", now, nil)
|
|
||||||
|
|
||||||
got := invalidateDownstreamSucceededStagesWithReason(m, "polish", now.Add(1*time.Second), staleReasonChangedResult)
|
|
||||||
want := []string{"normalize", "trim", "extract", "render", "publish", "notify"}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("invalidateDownstreamSucceededStagesWithReason() = %#v, want %#v", got, want)
|
|
||||||
}
|
}
|
||||||
|
m.MarkStageFailed("analyze", now, "analysis failed")
|
||||||
|
|
||||||
|
got, err := invalidateDependentSucceededStagesWithReason(m, "polish", now.Add(time.Second), staleReasonChangedResult)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invalidateDependentSucceededStagesWithReason() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"normalize", "trim", "render", "extract", "publish", "notify"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("invalidateDependentSucceededStagesWithReason() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
for _, stageName := range want {
|
for _, stageName := range want {
|
||||||
if m.Stages[stageName].Status != manifest.StatusStale {
|
if m.Stages[stageName].Status != manifest.StatusStale {
|
||||||
t.Fatalf("%s status = %q, want stale", stageName, m.Stages[stageName].Status)
|
t.Fatalf("%s status = %q, want stale", stageName, m.Stages[stageName].Status)
|
||||||
@@ -72,34 +112,38 @@ func TestInvalidateDownstreamSucceededStagesWithReason(t *testing.T) {
|
|||||||
if m.Stages["analyze"].Status != manifest.StatusFailed {
|
if m.Stages["analyze"].Status != manifest.StatusFailed {
|
||||||
t.Fatalf("analyze status = %q, want failed", m.Stages["analyze"].Status)
|
t.Fatalf("analyze status = %q, want failed", m.Stages["analyze"].Status)
|
||||||
}
|
}
|
||||||
if m.Stages["prepare"].Status != manifest.StatusSucceeded {
|
|
||||||
t.Fatalf("prepare status = %q, want succeeded", m.Stages["prepare"].Status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractionPositionControlsForceInvalidation(t *testing.T) {
|
func TestRenderAndExtractInvalidationAreIndependent(t *testing.T) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
tests := []struct {
|
for _, upstream := range []string{"render", "extract"} {
|
||||||
upstream string
|
t.Run(upstream, func(t *testing.T) {
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{upstream: "trim", want: []string{"extract", "render", "analyze", "publish", "notify"}},
|
|
||||||
{upstream: "extract", want: []string{"render", "analyze", "publish", "notify"}},
|
|
||||||
{upstream: "render", want: []string{"analyze", "publish", "notify"}},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.upstream, func(t *testing.T) {
|
|
||||||
m := manifest.New("2026-05-03", now)
|
m := manifest.New("2026-05-03", now)
|
||||||
for _, name := range canonicalStageNames() {
|
for _, name := range canonicalStageNames() {
|
||||||
m.MarkStageSucceeded(name, now, nil)
|
m.MarkStageSucceeded(name, now, nil)
|
||||||
}
|
}
|
||||||
got := invalidateDownstreamSucceededStagesWithReason(m, test.upstream, now.Add(time.Second), staleReasonForcedReplacement)
|
got, err := invalidateDependentSucceededStagesWithReason(m, upstream, now.Add(time.Second), staleReasonForcedReplacement)
|
||||||
if !reflect.DeepEqual(got, test.want) {
|
if err != nil {
|
||||||
t.Fatalf("invalidated = %#v, want %#v", got, test.want)
|
t.Fatalf("invalidate dependents: %v", err)
|
||||||
}
|
}
|
||||||
if test.upstream == "render" && m.Stages["extract"].Status != manifest.StatusSucceeded {
|
want := []string{"analyze", "publish", "notify"}
|
||||||
t.Fatalf("forcing render changed extract: %#v", m.Stages["extract"])
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("invalidated = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
sibling := "render"
|
||||||
|
if upstream == "render" {
|
||||||
|
sibling = "extract"
|
||||||
|
}
|
||||||
|
if m.Stages[sibling].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("%s invalidated sibling %s: %#v", upstream, sibling, m.Stages[sibling])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type invalidationTestStage string
|
||||||
|
|
||||||
|
func (s invalidationTestStage) Name() string { return string(s) }
|
||||||
|
func (s invalidationTestStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
return &stage.StageResult{}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ type singleStageCommand struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSummary, error) {
|
func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSummary, error) {
|
||||||
stages, err := BuildSingleStagePlan(req.StageName)
|
plan, err := buildSingleStageExecutionPlan(req.StageName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
@@ -216,13 +216,17 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
|
|||||||
if err := config.Validate(cfg); err != nil {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, req.SelectedArtifacts)
|
selectedArtifacts, err := normalizeArtifactSelection(cfg, req.SelectedArtifacts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
|
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, selectedArtifacts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
|
}
|
||||||
|
summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{
|
||||||
Force: req.Force,
|
Force: req.Force,
|
||||||
SelectedArtifacts: req.SelectedArtifacts,
|
SelectedArtifacts: selectedArtifacts,
|
||||||
EffectiveArtifacts: effectiveArtifacts,
|
EffectiveArtifacts: effectiveArtifacts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,20 +19,21 @@ func TestRunContinuesAfterCompletedStages(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{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
|
||||||
m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m, err := store.Load(context.Background(), manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load prepared manifest: %v", err)
|
||||||
|
}
|
||||||
m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil)
|
||||||
|
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe")
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
t.Fatalf("save manifest: %v", err)
|
||||||
}
|
}
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "raw", "alice.json"), `{"segments":[]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "raw", "alice.json"), `{"segments":[]}`)
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "autocorrect.yml"), "[]\n")
|
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
err = Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -55,17 +56,22 @@ func TestRunNoRemainingStagesRecordsSkippedStages(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{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
|
||||||
|
m, err := store.Load(context.Background(), manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load prepared manifest: %v", err)
|
||||||
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
m.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
|
m.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
|
||||||
|
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "publish", "notify")
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
t.Fatalf("save manifest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
err = Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -172,6 +178,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
|
|||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
m.MarkStageSucceeded("polish", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded("polish", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
|
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "polish")
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
t.Fatalf("save manifest: %v", err)
|
||||||
}
|
}
|
||||||
@@ -201,19 +208,23 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(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")
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`)
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
|
||||||
|
seed, err := store.Load(context.Background(), manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load prepared manifest: %v", err)
|
||||||
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
||||||
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
|
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge")
|
||||||
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 := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
err = RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage(force) error = %v", err)
|
t.Fatalf("RunStage(force) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -225,7 +236,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load manifest after force: %v", err)
|
t.Fatalf("load manifest after force: %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
for _, name := range []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||||
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
||||||
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,17 @@ type RunSummary struct {
|
|||||||
Skipped []string
|
Skipped []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var executeStagesFn = executeStages
|
var executeStagesFn = executePlan
|
||||||
|
|
||||||
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (summary *RunSummary, resultErr error) {
|
func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (summary *RunSummary, resultErr error) {
|
||||||
|
stages := plan.Stages()
|
||||||
|
var prerequisiteStore manifest.Store
|
||||||
|
if opts.Env != nil {
|
||||||
|
prerequisiteStore = opts.Env.ManifestStore
|
||||||
|
}
|
||||||
|
if err := inspectBoundedPrerequisites(ctx, cfg, plan, prerequisiteStore); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate bounded run prerequisites: %w", err)
|
||||||
|
}
|
||||||
effectiveArtifacts := opts.EffectiveArtifacts
|
effectiveArtifacts := opts.EffectiveArtifacts
|
||||||
if !effectiveArtifacts.Resolved() && cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
|
if !effectiveArtifacts.Resolved() && cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
|
||||||
var err error
|
var err error
|
||||||
@@ -131,7 +139,15 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
return nil, fmt.Errorf("create manifest: %w", err)
|
return nil, fmt.Errorf("create manifest: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The preflight prerequisite inspection avoids creating run state for an
|
||||||
|
// already-invalid request. Recheck the manifest protected by the session
|
||||||
|
// lock because another invocation may have changed prerequisite state while
|
||||||
|
// this invocation waited to acquire the lock.
|
||||||
|
if err := validateBoundedPrerequisites(plan, m); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err)
|
||||||
|
}
|
||||||
identity.applyToSessionManifest(m)
|
identity.applyToSessionManifest(m)
|
||||||
|
applyEffectiveConfigProvenance(m, cfg)
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
||||||
}
|
}
|
||||||
@@ -157,6 +173,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
return nil, fmt.Errorf("create run manifest: %w", err)
|
return nil, fmt.Errorf("create run manifest: %w", err)
|
||||||
}
|
}
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
|
applyEffectiveConfigProvenanceToRun(runManifest, cfg)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
@@ -169,7 +186,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
fmt.Errorf("load secrets from files: %w", err),
|
fmt.Errorf("load secrets from files: %w", err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if env.WhisperX == nil {
|
if env.WhisperX == nil && stagesContainAny(stages, "transcribe") {
|
||||||
client, err := buildDefaultWhisperXClient(env.Config)
|
client, err := buildDefaultWhisperXClient(env.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -179,7 +196,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
env.WhisperX = client
|
env.WhisperX = client
|
||||||
}
|
}
|
||||||
if env.Seriatim == nil {
|
if env.Seriatim == nil && stagesContainAny(stages, "merge", "normalize", "trim", "render") {
|
||||||
runner, err := buildDefaultSeriatimRunner(env.Config)
|
runner, err := buildDefaultSeriatimRunner(env.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -189,7 +206,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
env.Seriatim = runner
|
env.Seriatim = runner
|
||||||
}
|
}
|
||||||
if env.Audita == nil {
|
if env.Audita == nil && stagesContainAny(stages, "polish") {
|
||||||
runner, err := buildDefaultAuditaRunner(env.Config)
|
runner, err := buildDefaultAuditaRunner(env.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -202,7 +219,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
if env.Notarius == nil && needsNotariusForRun(env.Config, stages) {
|
if env.Notarius == nil && needsNotariusForRun(env.Config, stages) {
|
||||||
env.Notarius = notarius.NewSubprocessRunner()
|
env.Notarius = notarius.NewSubprocessRunner()
|
||||||
}
|
}
|
||||||
if env.Scriptorium == nil {
|
if env.Scriptorium == nil && stagesContainAny(stages, "trim", "analyze") {
|
||||||
env.Scriptorium = scriptorium.NewSubprocessRunner()
|
env.Scriptorium = scriptorium.NewSubprocessRunner()
|
||||||
}
|
}
|
||||||
if env.ObjectStore == nil && needsObjectStoreForRun(env.Config, stages, effectiveArtifacts) {
|
if env.ObjectStore == nil && needsObjectStoreForRun(env.Config, stages, effectiveArtifacts) {
|
||||||
@@ -232,38 +249,46 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
return config.MergePublishLockRules(staticLocks, remote.Locks), nil
|
return config.MergePublishLockRules(staticLocks, remote.Locks), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if env.Notifier == nil {
|
if env.Notifier == nil && stagesContainAny(stages, "notify") {
|
||||||
env.Notifier = ¬ify.NoopSender{}
|
env.Notifier = ¬ify.NoopSender{}
|
||||||
}
|
}
|
||||||
|
|
||||||
stageEnv := env
|
stageEnv := env
|
||||||
|
|
||||||
decisions := decideStageActions(stages, m, opts.Force)
|
runNames := make([]string, 0, len(stages))
|
||||||
|
executed := make([]string, 0, len(stages))
|
||||||
runNames := make([]string, 0, len(decisions))
|
skipped := make([]string, 0, len(stages))
|
||||||
executed := make([]string, 0, len(decisions))
|
for _, s := range stages {
|
||||||
skipped := make([]string, 0, len(decisions))
|
stageEnv.Force = opts.Force
|
||||||
for _, d := range decisions {
|
|
||||||
s := d.Stage
|
|
||||||
runNames = append(runNames, s.Name())
|
runNames = append(runNames, s.Name())
|
||||||
d.Action = decideStageAction(s, m, opts.Force)
|
semanticConfig, err := currentStageSemanticConfig(s, stageEnv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, persistTerminalFailure(
|
||||||
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
fmt.Errorf("fingerprint semantic configuration for stage %q: %w", s.Name(), err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
action := decideStageAction(s, m, opts.Force)
|
||||||
|
|
||||||
if d.Action == stageActionSkip {
|
if action == stageActionSkip {
|
||||||
if validator, ok := s.(stage.ResumeValidator); ok {
|
validation, err := evaluateStageResume(ctx, s, stageEnv, m, semanticConfig)
|
||||||
validation, err := validator.ValidateResume(ctx, stageEnv, m)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
fmt.Errorf("validate resume for stage %q: %w", s.Name(), err),
|
fmt.Errorf("validate resume for stage %q: %w", s.Name(), err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
validation = validation.Normalized()
|
if validation != nil && !validation.Resumable {
|
||||||
if !validation.Resumable {
|
|
||||||
staleAt := nowUTC()
|
staleAt := nowUTC()
|
||||||
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
||||||
invalidateDownstreamSucceededStagesWithReason(
|
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||||
m, s.Name(), staleAt, staleReasonNotResumable,
|
m, s.Name(), staleAt, staleReasonNotResumable,
|
||||||
|
); err != nil {
|
||||||
|
return nil, persistTerminalFailure(
|
||||||
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
fmt.Errorf("invalidate dependents after resume validation for stage %q: %w", s.Name(), err),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
@@ -271,16 +296,16 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
|
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
|
||||||
d.Action = stageActionRun
|
action = stageActionRun
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.Action == stageActionSkip {
|
if action == stageActionSkip {
|
||||||
skipped = append(skipped, s.Name())
|
skipped = append(skipped, s.Name())
|
||||||
skipAt := nowUTC()
|
skipAt := nowUTC()
|
||||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
|
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
|
||||||
runManifest.MarkStageSkipped(s.Name(), skipAt, "already_succeeded")
|
runManifest.MarkStageSkipped(s.Name(), skipAt, "already_succeeded")
|
||||||
|
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
@@ -292,6 +317,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
executed = append(executed, s.Name())
|
executed = append(executed, s.Name())
|
||||||
priorOutcome := capturePriorStageOutcome(m, s.Name())
|
priorOutcome := capturePriorStageOutcome(m, s.Name())
|
||||||
|
priorAnalyzeState := captureAnalyzeState(m, s.Name())
|
||||||
|
|
||||||
now := nowUTC()
|
now := nowUTC()
|
||||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionRun, now)
|
runManifest.SetStageAction(s.Name(), manifest.RunStageActionRun, now)
|
||||||
@@ -305,13 +331,20 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
m.MarkStageRunning(s.Name(), now)
|
m.MarkStageRunning(s.Name(), now)
|
||||||
if opts.Force {
|
if opts.Force {
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), now, staleReasonForcedReplacement)
|
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), now, staleReasonForcedReplacement); err != nil {
|
||||||
|
return nil, persistTerminalFailure(
|
||||||
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
fmt.Errorf("invalidate dependents before forced stage %q: %w", s.Name(), err),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
env.Logger.Info("starting stage", "stage", s.Name())
|
env.Logger.Info("starting stage", "stage", s.Name())
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
operationErr := fmt.Errorf("save manifest before stage %q: %w", s.Name(), err)
|
operationErr := fmt.Errorf("save manifest before stage %q: %w", s.Name(), err)
|
||||||
m.MarkStageFailed(s.Name(), nowUTC(), operationErr.Error())
|
m.MarkStageFailed(s.Name(), nowUTC(), operationErr.Error())
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), nowUTC(), staleReasonFailure)
|
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), nowUTC(), staleReasonFailure); invalidationErr != nil {
|
||||||
|
operationErr = errors.Join(operationErr, fmt.Errorf("invalidate dependents after stage %q persistence failure: %w", s.Name(), invalidationErr))
|
||||||
|
}
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest, operationErr,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest, operationErr,
|
||||||
)
|
)
|
||||||
@@ -319,13 +352,28 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath)
|
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath)
|
||||||
|
|
||||||
result, err := s.Run(ctx, stageEnv, m)
|
result, err := s.Run(ctx, stageEnv, m)
|
||||||
|
var analyzeProjection *validatedAnalyzeProjection
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = validateStageResult(result)
|
err = validateStageResult(result)
|
||||||
|
if err == nil {
|
||||||
|
analyzeProjection, err = validateSuccessfulAnalyzeProjection(s.Name(), result)
|
||||||
|
}
|
||||||
|
} else if result != nil && result.AnalyzeState != nil {
|
||||||
|
var projectionErr error
|
||||||
|
analyzeProjection, projectionErr = validateFailedAnalyzeProjection(s.Name(), result)
|
||||||
|
if projectionErr != nil {
|
||||||
|
err = errors.Join(err, projectionErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if analyzeProjection != nil {
|
||||||
|
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||||
|
}
|
||||||
failedAt := nowUTC()
|
failedAt := nowUTC()
|
||||||
m.MarkStageFailed(s.Name(), failedAt, err.Error())
|
m.MarkStageFailed(s.Name(), failedAt, err.Error())
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure)
|
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure); invalidationErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("invalidate dependents after stage %q failure: %w", s.Name(), invalidationErr))
|
||||||
|
}
|
||||||
runManifest.MarkStageFailed(s.Name(), failedAt, err.Error())
|
runManifest.MarkStageFailed(s.Name(), failedAt, err.Error())
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
env.Logger.Info("stage failed", "stage", s.Name(), "error", err)
|
env.Logger.Info("stage failed", "stage", s.Name(), "error", err)
|
||||||
@@ -339,8 +387,14 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
skippedAt := nowUTC()
|
skippedAt := nowUTC()
|
||||||
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||||
applyStageResultToManifest(m, s.Name(), result)
|
applyStageResultToManifest(m, s.Name(), result)
|
||||||
|
setSessionStageSemanticConfig(m, s.Name(), semanticConfig)
|
||||||
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
|
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip)
|
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip); err != nil {
|
||||||
|
return nil, persistTerminalFailure(
|
||||||
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
fmt.Errorf("invalidate dependents after stage %q self-skip: %w", s.Name(), err),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -350,6 +404,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
runManifest.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
runManifest.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||||
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||||
|
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -362,22 +417,45 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
outputs := mapResultOutputs(s.Name(), result, runID)
|
sessionOutputs := mapResultOutputs(s.Name(), result, runID)
|
||||||
|
runOutputs := sessionOutputs
|
||||||
|
if analyzeProjection != nil {
|
||||||
|
sessionOutputs = analyzeProjectionOutputs(analyzeProjection.session, "")
|
||||||
|
runOutputs = analyzeProjectionOutputs(analyzeProjection.invocation, runID)
|
||||||
|
}
|
||||||
succeededAt := nowUTC()
|
succeededAt := nowUTC()
|
||||||
m.MarkStageSucceeded(s.Name(), succeededAt, outputs)
|
m.MarkStageSucceeded(s.Name(), succeededAt, sessionOutputs)
|
||||||
|
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||||
applyStageResultToManifest(m, s.Name(), result)
|
applyStageResultToManifest(m, s.Name(), result)
|
||||||
|
setSessionStageSemanticConfig(m, s.Name(), semanticConfig)
|
||||||
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
|
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult)
|
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult); err != nil {
|
||||||
|
return nil, persistTerminalFailure(
|
||||||
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
fmt.Errorf("invalidate dependents after changed stage %q result: %w", s.Name(), err),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
|
operationErr := fmt.Errorf("save manifest after stage %q: %w", s.Name(), err)
|
||||||
|
if analyzeProjection != nil {
|
||||||
|
restoreAnalyzeState(m, priorAnalyzeState)
|
||||||
|
failedAt := nowUTC()
|
||||||
|
m.MarkStageFailed(s.Name(), failedAt, operationErr.Error())
|
||||||
|
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure); invalidationErr != nil {
|
||||||
|
operationErr = errors.Join(operationErr, fmt.Errorf("invalidate dependents after analyze projection persistence failure: %w", invalidationErr))
|
||||||
|
}
|
||||||
|
runManifest.MarkStageFailed(s.Name(), failedAt, operationErr.Error())
|
||||||
|
}
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
fmt.Errorf("save manifest after stage %q: %w", s.Name(), err),
|
operationErr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
runManifest.MarkStageSucceeded(s.Name(), succeededAt, outputs)
|
runManifest.MarkStageSucceeded(s.Name(), succeededAt, runOutputs)
|
||||||
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||||
|
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -401,12 +479,14 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
// The run record lives inside the run work directory, which cleanup may
|
// The run record lives inside the run work directory, which cleanup may
|
||||||
// remove. Persist its completed publishing result before cleanup starts so a
|
// remove. Persist its completed publishing result before cleanup starts so a
|
||||||
// successful deletion cannot be undone by a later diagnostic write.
|
// successful deletion cannot be undone by a later diagnostic write.
|
||||||
|
if containsStage(executed, "publish") {
|
||||||
if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
|
if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
|
||||||
return nil, persistPostPublishCleanupFailure(
|
return nil, persistPostPublishCleanupFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m,
|
ctx, env.ManifestStore, manifestPath, m,
|
||||||
fmt.Errorf("post-publish cleanup incomplete: %w", err),
|
fmt.Errorf("post-publish cleanup incomplete: %w", err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &RunSummary{
|
return &RunSummary{
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
@@ -419,6 +499,22 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stagesContainAny(stages []stage.Stage, names ...string) bool {
|
||||||
|
wanted := make(map[string]struct{}, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
wanted[name] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, candidate := range stages {
|
||||||
|
if candidate == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := wanted[candidate.Name()]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func persistPostPublishCleanupFailure(
|
func persistPostPublishCleanupFailure(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionStore manifest.Store,
|
sessionStore manifest.Store,
|
||||||
|
|||||||
@@ -564,12 +564,12 @@ func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
|
|||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
seed.MarkStageSucceeded("checked", time.Now().UTC(), nil)
|
seed.MarkStageSucceeded("extract", time.Now().UTC(), nil)
|
||||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
t.Fatalf("Save() error = %v", err)
|
t.Fatalf("Save() error = %v", err)
|
||||||
}
|
}
|
||||||
runs := 0
|
runs := 0
|
||||||
candidate := resumeCheckingStage{name: "checked", validation: test.validation, runs: &runs}
|
candidate := resumeCheckingStage{name: "extract", validation: test.validation, runs: &runs}
|
||||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -581,7 +581,7 @@ func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T) {
|
func TestExecuteStagesNonResumableResultPreservesSucceededSibling(t *testing.T) {
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
@@ -599,7 +599,7 @@ func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
if extractRuns != 1 || renderRuns != 1 || len(summary.Executed) != 2 || len(summary.Skipped) != 0 {
|
if extractRuns != 1 || renderRuns != 0 || len(summary.Executed) != 1 || len(summary.Skipped) != 1 {
|
||||||
t.Fatalf("extract runs=%d render runs=%d summary=%#v", extractRuns, renderRuns, summary)
|
t.Fatalf("extract runs=%d render runs=%d summary=%#v", extractRuns, renderRuns, summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -642,6 +642,7 @@ func TestExecuteStagesForceRerunsSucceeded(t *testing.T) {
|
|||||||
|
|
||||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||||
existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
||||||
|
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
|
||||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -983,6 +984,7 @@ func TestExecuteStagesRunManifestRecordsSkippedStage(t *testing.T) {
|
|||||||
manifestPath := manifestPathFor(cfg)
|
manifestPath := manifestPathFor(cfg)
|
||||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||||
existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
||||||
|
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
|
||||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1019,14 +1021,14 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
manifestPath := manifestPathFor(cfg)
|
manifestPath := manifestPathFor(cfg)
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
seed := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||||
seed.MarkStageSucceeded("optional", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
|
seed.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
|
||||||
Kind: "old_output",
|
Kind: "old_output",
|
||||||
SourceID: "narratio.example.old",
|
SourceID: "narratio.example.old",
|
||||||
LocalPath: "artifacts/old.json",
|
LocalPath: "artifacts/old.json",
|
||||||
}})
|
}})
|
||||||
seed.Stages["optional"].Logs = []string{"old.log"}
|
seed.Stages["extract"].Logs = []string{"old.log"}
|
||||||
seed.Stages["optional"].GeneratedConfigs = []string{"old.yml"}
|
seed.Stages["extract"].GeneratedConfigs = []string{"old.yml"}
|
||||||
seed.Stages["optional"].Metadata = map[string]any{"old": true}
|
seed.Stages["extract"].Metadata = map[string]any{"old": true}
|
||||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1038,7 +1040,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
optionalRuns := 0
|
optionalRuns := 0
|
||||||
stages := []stage.Stage{
|
stages := []stage.Stage{
|
||||||
resultStage{
|
resultStage{
|
||||||
name: "optional",
|
name: "extract",
|
||||||
runs: &optionalRuns,
|
runs: &optionalRuns,
|
||||||
order: &order,
|
order: &order,
|
||||||
result: &stage.StageResult{
|
result: &stage.StageResult{
|
||||||
@@ -1049,16 +1051,16 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
Metadata: map[string]any{"enabled": false},
|
Metadata: map[string]any{"enabled": false},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resultStage{name: "later", order: &order, result: &stage.StageResult{}},
|
resultStage{name: "analyze", order: &order, result: &stage.StageResult{}},
|
||||||
}
|
}
|
||||||
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{Force: true})
|
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{Force: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
if strings.Join(order, ",") != "optional,later" {
|
if strings.Join(order, ",") != "extract,analyze" {
|
||||||
t.Fatalf("execution order = %v, want optional then later", order)
|
t.Fatalf("execution order = %v, want extract then analyze", order)
|
||||||
}
|
}
|
||||||
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "optional" {
|
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "extract" {
|
||||||
t.Fatalf("summary = %#v, want optional executed and self-skipped before later", summary)
|
t.Fatalf("summary = %#v, want optional executed and self-skipped before later", summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,7 +1068,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() session manifest error = %v", err)
|
t.Fatalf("Load() session manifest error = %v", err)
|
||||||
}
|
}
|
||||||
selfSkipped := sessionManifest.Stages["optional"]
|
selfSkipped := sessionManifest.Stages["extract"]
|
||||||
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
|
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
|
||||||
t.Fatalf("optional stage = %#v, want skipped", selfSkipped)
|
t.Fatalf("optional stage = %#v, want skipped", selfSkipped)
|
||||||
}
|
}
|
||||||
@@ -1081,7 +1083,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
selfSkipped.Metadata["enabled"] != false || selfSkipped.Metadata["old"] != nil {
|
selfSkipped.Metadata["enabled"] != false || selfSkipped.Metadata["old"] != nil {
|
||||||
t.Fatalf("optional result details = %#v, want current bounded diagnostics and metadata", selfSkipped)
|
t.Fatalf("optional result details = %#v, want current bounded diagnostics and metadata", selfSkipped)
|
||||||
}
|
}
|
||||||
if later := sessionManifest.Stages["later"]; later == nil || later.Status != manifest.StatusSucceeded {
|
if later := sessionManifest.Stages["analyze"]; later == nil || later.Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("later stage = %#v, want succeeded", later)
|
t.Fatalf("later stage = %#v, want succeeded", later)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,7 +1091,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadRun() error = %v", err)
|
t.Fatalf("LoadRun() error = %v", err)
|
||||||
}
|
}
|
||||||
runStage := runManifest.Stages["optional"]
|
runStage := runManifest.Stages["extract"]
|
||||||
if runStage == nil || runStage.Action != manifest.RunStageActionRun || runStage.Status != manifest.StatusSkipped {
|
if runStage == nil || runStage.Action != manifest.RunStageActionRun || runStage.Status != manifest.StatusSkipped {
|
||||||
t.Fatalf("run optional stage = %#v, want run action with skipped status", runStage)
|
t.Fatalf("run optional stage = %#v, want run action with skipped status", runStage)
|
||||||
}
|
}
|
||||||
@@ -1108,7 +1110,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
|
|
||||||
func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
invalid := resultStage{name: "optional", result: &stage.StageResult{
|
invalid := resultStage{name: "extract", result: &stage.StageResult{
|
||||||
Disposition: stage.StageDispositionSkipped,
|
Disposition: stage.StageDispositionSkipped,
|
||||||
SkipReason: "integration_disabled",
|
SkipReason: "integration_disabled",
|
||||||
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
|
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
|
||||||
@@ -1129,7 +1131,7 @@ func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
|||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
t.Fatalf("Load() session manifest error = %v", loadErr)
|
t.Fatalf("Load() session manifest error = %v", loadErr)
|
||||||
}
|
}
|
||||||
if got := loaded.Stages["optional"]; got == nil || got.Status != manifest.StatusFailed {
|
if got := loaded.Stages["extract"]; got == nil || got.Status != manifest.StatusFailed {
|
||||||
t.Fatalf("optional stage = %#v, want failed", got)
|
t.Fatalf("optional stage = %#v, want failed", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1232,6 +1234,7 @@ func TestExecuteStagesSkippedStagePreservesExistingOutputsProvenance(t *testing.
|
|||||||
ProducerRunID: "20260501T000000Z-deadbeef",
|
ProducerRunID: "20260501T000000Z-deadbeef",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
|
||||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1403,6 +1406,7 @@ inputs:
|
|||||||
mustWriteFile(t, pipelinePath, pipelineYAML)
|
mustWriteFile(t, pipelinePath, pipelineYAML)
|
||||||
mustWriteFile(t, campaignPath, campaignYAML)
|
mustWriteFile(t, campaignPath, campaignYAML)
|
||||||
mustWriteFile(t, sessionPath, sessionYAML)
|
mustWriteFile(t, sessionPath, sessionYAML)
|
||||||
|
mustWriteFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
|
||||||
|
|
||||||
cfg, err := config.Load(pipelinePath, sessionPath)
|
cfg, err := config.Load(pipelinePath, sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
51
internal/app/runner_test_helpers_test.go
Normal file
51
internal/app/runner_test_helpers_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// executeStages keeps runner tests focused on controlled stage doubles. The
|
||||||
|
// production command path always supplies one validated BoundedPlan directly.
|
||||||
|
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||||
|
plan := BoundedPlan{stages: append([]stage.Stage(nil), stages...)}
|
||||||
|
return executePlan(ctx, cfg, plan, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedCurrentSemanticEvidence(t *testing.T, cfg *config.Config, m *manifest.Manifest, names ...string) {
|
||||||
|
t.Helper()
|
||||||
|
providers := make(map[string]stage.SemanticConfigFingerprinter)
|
||||||
|
for _, candidate := range stage.All() {
|
||||||
|
if provider, ok := candidate.(stage.SemanticConfigFingerprinter); ok {
|
||||||
|
providers[candidate.Name()] = provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
record := m.Stages[name]
|
||||||
|
if record == nil {
|
||||||
|
t.Fatalf("stage %q must exist before semantic evidence is seeded", name)
|
||||||
|
}
|
||||||
|
provider, ok := providers[name]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("stage %q has no semantic fingerprint provider", name)
|
||||||
|
}
|
||||||
|
fingerprint, err := provider.SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fingerprint stage %q: %v", name, err)
|
||||||
|
}
|
||||||
|
record.SemanticConfig = &fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfigForSemanticEvidence(t *testing.T, pipelinePath, campaignPath, sessionPath string) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
cfg, err := config.Load(pipelinePath, campaignPath, sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load config for semantic evidence: %v", err)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
95
internal/app/semantic_resume.go
Normal file
95
internal/app/semantic_resume.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func currentStageSemanticConfig(selected stage.Stage, env *stage.Env) (*manifest.SemanticConfigFingerprint, error) {
|
||||||
|
provider, ok := selected.(stage.SemanticConfigFingerprinter)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
record, err := provider.SemanticConfigFingerprint(env)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := record.Validate(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid current semantic configuration fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
return cloneSemanticConfig(&record), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateStageSemanticResume(selected stage.Stage, m *manifest.Manifest, current *manifest.SemanticConfigFingerprint) stage.ResumeValidation {
|
||||||
|
if current == nil {
|
||||||
|
return stage.Resumable()
|
||||||
|
}
|
||||||
|
var persisted *manifest.SemanticConfigFingerprint
|
||||||
|
if m != nil && m.Stages != nil && m.Stages[selected.Name()] != nil {
|
||||||
|
persisted = m.Stages[selected.Name()].SemanticConfig
|
||||||
|
}
|
||||||
|
if persisted == nil {
|
||||||
|
return stage.NonResumable("semantic configuration evidence is missing; rerun the stage to establish current evidence")
|
||||||
|
}
|
||||||
|
if err := persisted.Validate(); err != nil {
|
||||||
|
return stage.NonResumable("persisted semantic configuration evidence is malformed; rerun the stage")
|
||||||
|
}
|
||||||
|
if persisted.Version != current.Version {
|
||||||
|
return stage.NonResumable("semantic configuration contract version changed; rerun the stage")
|
||||||
|
}
|
||||||
|
if persisted.Digest != current.Digest {
|
||||||
|
return stage.NonResumable("result-affecting configuration changed; rerun the stage")
|
||||||
|
}
|
||||||
|
return stage.Resumable()
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateStageResume(
|
||||||
|
ctx context.Context,
|
||||||
|
selected stage.Stage,
|
||||||
|
env *stage.Env,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
current *manifest.SemanticConfigFingerprint,
|
||||||
|
) (*stage.ResumeValidation, error) {
|
||||||
|
semantic := validateStageSemanticResume(selected, m, current).Normalized()
|
||||||
|
if !semantic.Resumable {
|
||||||
|
return &semantic, nil
|
||||||
|
}
|
||||||
|
validator, ok := selected.(stage.ResumeValidator)
|
||||||
|
if !ok {
|
||||||
|
if current == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &semantic, nil
|
||||||
|
}
|
||||||
|
validation, err := validator.ValidateResume(ctx, env, m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
validation = validation.Normalized()
|
||||||
|
return &validation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionStageSemanticConfig(m *manifest.Manifest, name string, value *manifest.SemanticConfigFingerprint) {
|
||||||
|
if m == nil || m.Stages == nil || m.Stages[name] == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.Stages[name].SemanticConfig = cloneSemanticConfig(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRunStageSemanticConfig(m *manifest.RunManifest, name string, value *manifest.SemanticConfigFingerprint) {
|
||||||
|
if m == nil || m.Stages == nil || m.Stages[name] == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.Stages[name].SemanticConfig = cloneSemanticConfig(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSemanticConfig(value *manifest.SemanticConfigFingerprint) *manifest.SemanticConfigFingerprint {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := *value
|
||||||
|
return ©
|
||||||
|
}
|
||||||
577
internal/app/semantic_resume_test.go
Normal file
577
internal/app/semantic_resume_test.go
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type semanticStage struct {
|
||||||
|
name string
|
||||||
|
fingerprint manifest.SemanticConfigFingerprint
|
||||||
|
fingerprintErr error
|
||||||
|
result *stage.StageResult
|
||||||
|
runErr error
|
||||||
|
runs *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s semanticStage) Name() string { return s.name }
|
||||||
|
func (s semanticStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
if s.runs != nil {
|
||||||
|
*s.runs++
|
||||||
|
}
|
||||||
|
return s.result, s.runErr
|
||||||
|
}
|
||||||
|
func (s semanticStage) SemanticConfigFingerprint(_ *stage.Env) (manifest.SemanticConfigFingerprint, error) {
|
||||||
|
return s.fingerprint, s.fingerprintErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type semanticResumeCheckingStage struct {
|
||||||
|
semanticStage
|
||||||
|
validation stage.ResumeValidation
|
||||||
|
validationCalls *int
|
||||||
|
}
|
||||||
|
|
||||||
|
type semanticContractRunStub struct {
|
||||||
|
name string
|
||||||
|
provider stage.SemanticConfigFingerprinter
|
||||||
|
runs *int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s semanticContractRunStub) Name() string { return s.name }
|
||||||
|
func (s semanticContractRunStub) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
*s.runs++
|
||||||
|
return &stage.StageResult{}, nil
|
||||||
|
}
|
||||||
|
func (s semanticContractRunStub) SemanticConfigFingerprint(env *stage.Env) (manifest.SemanticConfigFingerprint, error) {
|
||||||
|
return s.provider.SemanticConfigFingerprint(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s semanticResumeCheckingStage) ValidateResume(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (stage.ResumeValidation, error) {
|
||||||
|
if s.validationCalls != nil {
|
||||||
|
*s.validationCalls++
|
||||||
|
}
|
||||||
|
return s.validation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemanticResumeComparison(t *testing.T) {
|
||||||
|
current := semanticFingerprint(1, "a")
|
||||||
|
selected := semanticStage{name: "render", fingerprint: current}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
persisted *manifest.SemanticConfigFingerprint
|
||||||
|
resumable bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "matching", persisted: ¤t, resumable: true},
|
||||||
|
{name: "missing", want: "missing"},
|
||||||
|
{name: "version", persisted: fingerprintPointer(semanticFingerprint(2, "a")), want: "version"},
|
||||||
|
{name: "digest", persisted: fingerprintPointer(semanticFingerprint(1, "b")), want: "configuration changed"},
|
||||||
|
{name: "malformed", persisted: &manifest.SemanticConfigFingerprint{Version: 1, Digest: "bad"}, want: "malformed"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
model := manifest.New("session", time.Now().UTC())
|
||||||
|
model.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||||
|
model.Stages["render"].SemanticConfig = test.persisted
|
||||||
|
got := validateStageSemanticResume(selected, model, ¤t)
|
||||||
|
if got.Resumable != test.resumable || (test.want != "" && !strings.Contains(got.Reason, test.want)) {
|
||||||
|
t.Fatalf("validation = %#v, want resumable=%v reason %q", got, test.resumable, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesSemanticEvidenceLifecycle(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
persisted *manifest.SemanticConfigFingerprint
|
||||||
|
force bool
|
||||||
|
wantRuns int
|
||||||
|
wantSkipped int
|
||||||
|
}{
|
||||||
|
{name: "matching skips", persisted: fingerprintPointer(semanticFingerprint(1, "current")), wantSkipped: 1},
|
||||||
|
{name: "legacy missing reruns", wantRuns: 1},
|
||||||
|
{name: "version reruns", persisted: fingerprintPointer(semanticFingerprint(2, "current")), wantRuns: 1},
|
||||||
|
{name: "digest reruns", persisted: fingerprintPointer(semanticFingerprint(1, "old")), wantRuns: 1},
|
||||||
|
{name: "force reruns matching", persisted: fingerprintPointer(semanticFingerprint(1, "current")), force: true, wantRuns: 1},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||||
|
seed.Stages["render"].SemanticConfig = test.persisted
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runs := 0
|
||||||
|
current := semanticFingerprint(1, "current")
|
||||||
|
candidate := semanticStage{name: "render", fingerprint: current, result: &stage.StageResult{}, runs: &runs}
|
||||||
|
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{Force: test.force})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runs != test.wantRuns || len(summary.Skipped) != test.wantSkipped {
|
||||||
|
t.Fatalf("runs=%d summary=%#v", runs, summary)
|
||||||
|
}
|
||||||
|
loaded, err := store.Load(context.Background(), summary.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loaded.Stages["render"].SemanticConfig == nil || !loaded.Stages["render"].SemanticConfig.Equal(current) {
|
||||||
|
t.Fatalf("session evidence = %#v", loaded.Stages["render"].SemanticConfig)
|
||||||
|
}
|
||||||
|
run, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if run.Stages["render"].SemanticConfig == nil || !run.Stages["render"].SemanticConfig.Equal(current) {
|
||||||
|
t.Fatalf("run evidence = %#v", run.Stages["render"].SemanticConfig)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemanticEvidenceRequiresBothChecksAndNeverPromotesFailure(t *testing.T) {
|
||||||
|
t.Run("semantic mismatch precedes resume validator", func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||||
|
seed.Stages["render"].SemanticConfig = fingerprintPointer(semanticFingerprint(1, "old"))
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runs, validations := 0, 0
|
||||||
|
candidate := semanticResumeCheckingStage{
|
||||||
|
semanticStage: semanticStage{name: "render", fingerprint: semanticFingerprint(1, "new"), result: &stage.StageResult{}, runs: &runs},
|
||||||
|
validation: stage.Resumable(), validationCalls: &validations,
|
||||||
|
}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runs != 1 || validations != 0 {
|
||||||
|
t.Fatalf("runs=%d validations=%d", runs, validations)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("matching semantic evidence still requires validator", func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
current := semanticFingerprint(1, "same")
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||||
|
seed.Stages["render"].SemanticConfig = ¤t
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runs, validations := 0, 0
|
||||||
|
candidate := semanticResumeCheckingStage{
|
||||||
|
semanticStage: semanticStage{name: "render", fingerprint: current, result: &stage.StageResult{}, runs: &runs},
|
||||||
|
validation: stage.NonResumable("output changed"), validationCalls: &validations,
|
||||||
|
}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runs != 1 || validations != 1 {
|
||||||
|
t.Fatalf("runs=%d validations=%d", runs, validations)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("failed execution has no promoted evidence", func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
runs := 0
|
||||||
|
candidate := semanticStage{
|
||||||
|
name: "render", fingerprint: semanticFingerprint(1, "new"), runErr: errors.New("render failed"), runs: &runs,
|
||||||
|
}
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("executeStages() error = nil")
|
||||||
|
}
|
||||||
|
loaded, loadErr := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatal(loadErr)
|
||||||
|
}
|
||||||
|
if loaded.Stages["render"].SemanticConfig != nil || runs != 1 {
|
||||||
|
t.Fatalf("failed evidence=%#v runs=%d", loaded.Stages["render"].SemanticConfig, runs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntentionalStageSkipPersistsSemanticEvidence(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
current := semanticFingerprint(1, "disabled")
|
||||||
|
runs := 0
|
||||||
|
candidate := semanticStage{
|
||||||
|
name: "render", fingerprint: current, runs: &runs,
|
||||||
|
result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "render disabled"},
|
||||||
|
}
|
||||||
|
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
session, err := store.Load(context.Background(), summary.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runs != 1 || session.Stages["render"].SemanticConfig == nil || run.Stages["render"].SemanticConfig == nil ||
|
||||||
|
!session.Stages["render"].SemanticConfig.Equal(current) || !run.Stages["render"].SemanticConfig.Equal(current) {
|
||||||
|
t.Fatalf("runs=%d session=%#v run=%#v", runs, session.Stages["render"], run.Stages["render"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidCurrentSemanticEvidenceStopsBeforeExecution(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
runs := 0
|
||||||
|
candidate := semanticStage{
|
||||||
|
name: "render", fingerprint: manifest.SemanticConfigFingerprint{Version: 1, Digest: "invalid"}, runs: &runs,
|
||||||
|
}
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{Force: true})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid current semantic configuration fingerprint") || runs != 0 {
|
||||||
|
t.Fatalf("error=%v runs=%d", err, runs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadOnlySemanticResumeDecisionMutatesOnlyPlanModel(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
original := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
original.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||||
|
original.MarkStageSucceeded("extract", time.Now().UTC(), nil)
|
||||||
|
original.MarkStageSucceeded("analyze", time.Now().UTC(), nil)
|
||||||
|
original.Stages["render"].SemanticConfig = fingerprintPointer(semanticFingerprint(1, "old"))
|
||||||
|
model, err := cloneManifestForPlan(original, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selected := semanticStage{name: "render", fingerprint: semanticFingerprint(1, "new")}
|
||||||
|
current, err := currentStageSemanticConfig(selected, &stage.Env{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
validation, err := evaluateStageResume(context.Background(), selected, &stage.Env{Config: cfg}, model, current)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if validation == nil || validation.Resumable {
|
||||||
|
t.Fatalf("validation = %#v, want planned rerun", validation)
|
||||||
|
}
|
||||||
|
at := time.Now().UTC()
|
||||||
|
model.MarkStageStale("render", at, validation.Reason)
|
||||||
|
if _, err := invalidateDependentSucceededStagesWithReason(model, "render", at, staleReasonNotResumable); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if model.Stages["render"].Status != manifest.StatusStale || model.Stages["analyze"].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("model render=%q analyze=%q", model.Stages["render"].Status, model.Stages["analyze"].Status)
|
||||||
|
}
|
||||||
|
if original.Stages["render"].Status != manifest.StatusSucceeded || original.Stages["analyze"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("authoritative manifest mutated: render=%q analyze=%q", original.Stages["render"].Status, original.Stages["analyze"].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSemanticMismatchInvalidatesOnlyFixedDependents(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
for _, name := range []string{"render", "extract", "analyze", "publish", "notify"} {
|
||||||
|
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
seed.Stages["render"].SemanticConfig = fingerprintPointer(semanticFingerprint(1, "old"))
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
runs := 0
|
||||||
|
candidate := semanticStage{name: "render", fingerprint: semanticFingerprint(1, "new"), result: &stage.StageResult{}, runs: &runs}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loaded.Stages["render"].Status != manifest.StatusSucceeded || loaded.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("render=%q extract=%q", loaded.Stages["render"].Status, loaded.Stages["extract"].Status)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"analyze", "publish", "notify"} {
|
||||||
|
if loaded.Stages[name].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("%s status = %q, want stale", name, loaded.Stages[name].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitialPipelineSemanticChangesRerunOnlyAffectedLineage(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*config.Config)
|
||||||
|
wantRuns [3]int
|
||||||
|
}{
|
||||||
|
{name: "prepare selection", mutate: func(cfg *config.Config) {
|
||||||
|
cfg.Session.PreviousSessionID = "2026-04-26"
|
||||||
|
}, wantRuns: [3]int{1, 1, 1}},
|
||||||
|
{name: "transcribe language", mutate: func(cfg *config.Config) {
|
||||||
|
cfg.Pipeline.WhisperX.Language = "fr"
|
||||||
|
}, wantRuns: [3]int{0, 1, 1}},
|
||||||
|
{name: "merge transformation", mutate: func(cfg *config.Config) {
|
||||||
|
value := 1.75
|
||||||
|
cfg.Pipeline.Seriatim.CoalesceGap = &value
|
||||||
|
}, wantRuns: [3]int{0, 0, 1}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
names := []string{"prepare", "transcribe", "merge"}
|
||||||
|
providers := make([]stage.SemanticConfigFingerprinter, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
providers[index] = canonicalSemanticProvider(t, name)
|
||||||
|
}
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
for _, candidate := range stage.All() {
|
||||||
|
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
for index, name := range names {
|
||||||
|
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seed.Stages[name].SemanticConfig = &fingerprint
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
test.mutate(cfg)
|
||||||
|
|
||||||
|
runs := [3]int{}
|
||||||
|
selected := make([]stage.Stage, 0, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
selected = append(selected, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
|
||||||
|
}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runs != test.wantRuns {
|
||||||
|
t.Fatalf("runs = %v, want %v", runs, test.wantRuns)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefinementSemanticChangesRespectDependencyBranches(t *testing.T) {
|
||||||
|
names := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*config.Config)
|
||||||
|
wantRuns [7]int
|
||||||
|
wantExtractStatus manifest.StageStatus
|
||||||
|
}{
|
||||||
|
{name: "polish model", mutate: func(cfg *config.Config) {
|
||||||
|
cfg.Pipeline.Audita.Model = "production"
|
||||||
|
}, wantRuns: [7]int{0, 0, 0, 1, 1, 1, 1}, wantExtractStatus: manifest.StatusStale},
|
||||||
|
{name: "normalize schema", mutate: func(cfg *config.Config) {
|
||||||
|
cfg.Pipeline.Normalize = &config.NormalizeConfig{OutputSchema: "seriatim.transcript.v2"}
|
||||||
|
}, wantRuns: [7]int{0, 0, 0, 0, 1, 1, 1}, wantExtractStatus: manifest.StatusStale},
|
||||||
|
{name: "trim prompt", mutate: func(cfg *config.Config) {
|
||||||
|
enabled := true
|
||||||
|
cfg.Pipeline.Trim = &config.TrimConfig{
|
||||||
|
Enabled: &enabled,
|
||||||
|
Bounds: config.TrimBoundsConfig{PromptID: "session-bounds-v2"},
|
||||||
|
}
|
||||||
|
}, wantRuns: [7]int{0, 0, 0, 0, 0, 1, 1}, wantExtractStatus: manifest.StatusStale},
|
||||||
|
{name: "render format", mutate: func(cfg *config.Config) {
|
||||||
|
cfg.Pipeline.Render = &config.RenderConfig{Format: "html"}
|
||||||
|
}, wantRuns: [7]int{0, 0, 0, 0, 0, 0, 1}, wantExtractStatus: manifest.StatusSucceeded},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
providers := make([]stage.SemanticConfigFingerprinter, len(names))
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
for _, candidate := range stage.All() {
|
||||||
|
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
for index, name := range names {
|
||||||
|
providers[index] = canonicalSemanticProvider(t, name)
|
||||||
|
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seed.Stages[name].SemanticConfig = &fingerprint
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
test.mutate(cfg)
|
||||||
|
|
||||||
|
runs := [7]int{}
|
||||||
|
selected := make([]stage.Stage, 0, len(names))
|
||||||
|
for index, name := range names {
|
||||||
|
selected = append(selected, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
|
||||||
|
}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runs != test.wantRuns {
|
||||||
|
t.Fatalf("runs = %v, want %v", runs, test.wantRuns)
|
||||||
|
}
|
||||||
|
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := loaded.Stages["extract"].Status; got != test.wantExtractStatus {
|
||||||
|
t.Fatalf("extract status = %q, want %q", got, test.wantExtractStatus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverySemanticChangesInvalidateOnlyTheirFixedDependents(t *testing.T) {
|
||||||
|
t.Run("extract contract", func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
configureDeliverySemanticConfig(cfg)
|
||||||
|
provider := canonicalSemanticProvider(t, "extract")
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
for _, name := range []string{"render", "extract", "analyze", "publish", "notify"} {
|
||||||
|
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
fingerprint, err := provider.SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seed.Stages["extract"].SemanticConfig = &fingerprint
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg.Pipeline.Notarius.PipelineID = "session-v2"
|
||||||
|
runs := 0
|
||||||
|
selected := semanticContractRunStub{name: "extract", provider: provider, runs: &runs}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{selected}, RunOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runs != 1 || loaded.Stages["render"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("runs=%d render=%q", runs, loaded.Stages["render"].Status)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"analyze", "publish", "notify"} {
|
||||||
|
if loaded.Stages[name].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("%s status = %q, want stale", name, loaded.Stages[name].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*config.Config)
|
||||||
|
wantRuns [2]int
|
||||||
|
}{
|
||||||
|
{name: "publish destination", mutate: func(cfg *config.Config) {
|
||||||
|
cfg.Pipeline.Publish.Outputs[0].Dest = "published/alternate.json"
|
||||||
|
}, wantRuns: [2]int{1, 1}},
|
||||||
|
{name: "publish credentials", mutate: func(cfg *config.Config) {
|
||||||
|
cfg.Pipeline.Storage.S3.AccessKeyIDEnv = "OTHER_ACCESS_KEY"
|
||||||
|
cfg.Pipeline.Storage.S3.SecretKeyEnv = "OTHER_SECRET_KEY"
|
||||||
|
}, wantRuns: [2]int{0, 0}},
|
||||||
|
{name: "notify mode", mutate: func(cfg *config.Config) {
|
||||||
|
cfg.Pipeline.Notification.Mode = "webhook"
|
||||||
|
}, wantRuns: [2]int{0, 1}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
configureDeliverySemanticConfig(cfg)
|
||||||
|
names := []string{"publish", "notify"}
|
||||||
|
providers := []stage.SemanticConfigFingerprinter{
|
||||||
|
canonicalSemanticProvider(t, names[0]), canonicalSemanticProvider(t, names[1]),
|
||||||
|
}
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
for index, name := range names {
|
||||||
|
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
seed.Stages[name].SemanticConfig = &fingerprint
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
test.mutate(cfg)
|
||||||
|
runs := [2]int{}
|
||||||
|
selected := []stage.Stage{
|
||||||
|
semanticContractRunStub{name: names[0], provider: providers[0], runs: &runs[0]},
|
||||||
|
semanticContractRunStub{name: names[1], provider: providers[1], runs: &runs[1]},
|
||||||
|
}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if runs != test.wantRuns {
|
||||||
|
t.Fatalf("runs = %v, want %v", runs, test.wantRuns)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureDeliverySemanticConfig(cfg *config.Config) {
|
||||||
|
enabled := true
|
||||||
|
disabled := false
|
||||||
|
cfg.Pipeline.Notarius = &config.NotariusConfig{
|
||||||
|
Enabled: true, PipelineID: "session",
|
||||||
|
References: map[string]string{"party": "narratio.input.party"},
|
||||||
|
Outputs: map[string]config.NotariusOutputConfig{
|
||||||
|
"encounters": {LaneID: "encounters", MediaType: "application/json", SchemaID: "encounters", SchemaVersion: "1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg.Pipeline.Publish = &config.PublishConfig{
|
||||||
|
Enabled: &disabled, UploadRun: &enabled,
|
||||||
|
Outputs: []config.PublishOutputRule{{
|
||||||
|
Source: "narratio.transcript.final_trimmed", Dest: "transcripts/final.trimmed.json", Required: &enabled,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
cfg.Pipeline.Storage = config.StorageConfig{
|
||||||
|
Backend: config.StorageBackendLocal,
|
||||||
|
S3: &config.StorageS3Config{
|
||||||
|
Bucket: "campaign", RootPrefix: "narratio", Region: "us-east-1",
|
||||||
|
Endpoint: "https://objects.example", AccessKeyIDEnv: "ACCESS_KEY", SecretKeyEnv: "SECRET_KEY",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg.Pipeline.Notification.Mode = config.DefaultNotificationMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalSemanticProvider(t *testing.T, name string) stage.SemanticConfigFingerprinter {
|
||||||
|
t.Helper()
|
||||||
|
for _, candidate := range stage.All() {
|
||||||
|
if candidate.Name() != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
provider, ok := candidate.(stage.SemanticConfigFingerprinter)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("canonical stage %q does not implement semantic fingerprinting", name)
|
||||||
|
}
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
t.Fatalf("canonical stage %q not found", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func semanticFingerprint(version int, seed string) manifest.SemanticConfigFingerprint {
|
||||||
|
digest := sha256.Sum256([]byte(seed))
|
||||||
|
return manifest.SemanticConfigFingerprint{Version: version, Digest: hex.EncodeToString(digest[:])}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintPointer(value manifest.SemanticConfigFingerprint) *manifest.SemanticConfigFingerprint {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
||||||
@@ -21,7 +20,7 @@ func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
|||||||
var capturedSessionID string
|
var capturedSessionID string
|
||||||
origExecuteStagesFn := executeStagesFn
|
origExecuteStagesFn := executeStagesFn
|
||||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||||
capturedSessionID = cfg.Session.SessionID
|
capturedSessionID = cfg.Session.SessionID
|
||||||
return &RunSummary{
|
return &RunSummary{
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
@@ -117,7 +116,7 @@ inputs:
|
|||||||
`)
|
`)
|
||||||
origExecuteStagesFn := executeStagesFn
|
origExecuteStagesFn := executeStagesFn
|
||||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||||
return &RunSummary{
|
return &RunSummary{
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
||||||
@@ -194,8 +193,8 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
|
|||||||
var capturedArtifacts []string
|
var capturedArtifacts []string
|
||||||
origExecuteStagesFn := executeStagesFn
|
origExecuteStagesFn := executeStagesFn
|
||||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
@@ -257,7 +256,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "plan",
|
name: "plan",
|
||||||
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
want: "narratio session plan: workdir prepared",
|
want: "narratio session plan: read-only workdir",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "artifacts",
|
name: "artifacts",
|
||||||
|
|||||||
17
internal/app/version.go
Normal file
17
internal/app/version.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/buildinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version prints the version embedded in the current Narratio binary.
|
||||||
|
func Version(args []string, out io.Writer) error {
|
||||||
|
if len(args) != 0 {
|
||||||
|
return fmt.Errorf("version: unexpected arguments")
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(out, "narratio %s\n", buildinfo.Version)
|
||||||
|
return err
|
||||||
|
}
|
||||||
38
internal/app/version_test.go
Normal file
38
internal/app/version_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/buildinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteVersionReportsEmbeddedBuildVersion(t *testing.T) {
|
||||||
|
original := buildinfo.Version
|
||||||
|
buildinfo.Version = "v1.5.0-test"
|
||||||
|
t.Cleanup(func() { buildinfo.Version = original })
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute([]string{"version"}, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("Execute() code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if got, want := stdout.String(), "narratio v1.5.0-test\n"; got != want {
|
||||||
|
t.Fatalf("stdout = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteVersionRejectsArguments(t *testing.T) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute([]string{"version", "extra"}, io.Discard, &stderr); code == 0 {
|
||||||
|
t.Fatal("Execute() code = 0, want failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "version: unexpected arguments") {
|
||||||
|
t.Fatalf("stderr = %q, want argument error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
144
internal/artifacts/analyze_evidence.go
Normal file
144
internal/artifacts/analyze_evidence.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnalyzeEvidenceState distinguishes a verified current configured artifact
|
||||||
|
// from every form of unavailable evidence.
|
||||||
|
type AnalyzeEvidenceState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AnalyzeEvidenceCurrent AnalyzeEvidenceState = "current"
|
||||||
|
AnalyzeEvidenceNonCurrent AnalyzeEvidenceState = "non_current"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnalyzeEvidence is the read-only result of inspecting one configured
|
||||||
|
// artifact's manifest record and durable output.
|
||||||
|
type AnalyzeEvidence struct {
|
||||||
|
State AnalyzeEvidenceState
|
||||||
|
Reason string
|
||||||
|
SourceID string
|
||||||
|
Path string
|
||||||
|
ProducerRunID string
|
||||||
|
Contract *artifactmodel.ContractMetadata
|
||||||
|
Checksum string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectAnalyzeEvidence verifies that one configured artifact has supported,
|
||||||
|
// current manifest evidence for the exact canonical bytes on disk.
|
||||||
|
func InspectAnalyzeEvidence(
|
||||||
|
paths SessionPaths,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
key string,
|
||||||
|
configured ConfiguredArtifactDefinition,
|
||||||
|
) AnalyzeEvidence {
|
||||||
|
normalizedKey := strings.TrimSpace(key)
|
||||||
|
sourceID := ConfiguredArtifactSourceID(normalizedKey)
|
||||||
|
nonCurrent := func(reason string) AnalyzeEvidence {
|
||||||
|
return AnalyzeEvidence{State: AnalyzeEvidenceNonCurrent, Reason: reason, SourceID: sourceID}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m == nil {
|
||||||
|
return nonCurrent("analyze manifest evidence is absent; regenerate the artifact")
|
||||||
|
}
|
||||||
|
stageRecord := m.Stages["analyze"]
|
||||||
|
if stageRecord == nil || stageRecord.Name != "analyze" {
|
||||||
|
return nonCurrent("analyze manifest evidence is absent; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if !stageRecord.HasVersionedAnalyzeState() {
|
||||||
|
return nonCurrent("analyze manifest evidence is legacy or unsupported; regenerate the artifact")
|
||||||
|
}
|
||||||
|
record, ok := stageRecord.AnalyzeArtifacts[normalizedKey]
|
||||||
|
if !ok {
|
||||||
|
return nonCurrent("configured artifact has no analyze manifest record; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if record.Status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
return nonCurrent(fmt.Sprintf("configured artifact manifest status is %q; regenerate the artifact", record.Status))
|
||||||
|
}
|
||||||
|
if err := manifest.ValidateAnalyzeArtifactCollection(
|
||||||
|
stageRecord.AnalyzeStateVersion,
|
||||||
|
map[string]manifest.AnalyzeArtifactRecord{normalizedKey: record},
|
||||||
|
); err != nil {
|
||||||
|
return nonCurrent("configured artifact manifest evidence is malformed; regenerate the artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredPath, err := pathsafe.NormalizeRelativeDestination(strings.TrimSpace(configured.OutputPath))
|
||||||
|
if err != nil {
|
||||||
|
return nonCurrent("configured artifact output path is unsafe; correct the configuration")
|
||||||
|
}
|
||||||
|
if record.Output.LocalPath != configuredPath {
|
||||||
|
return nonCurrent("configured artifact manifest path differs from current configuration; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if record.Output.SourceID != sourceID || record.Output.Kind != "scriptorium_artifact" {
|
||||||
|
return nonCurrent("configured artifact manifest identity is incompatible; regenerate the artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := fileops.OpenConfinedRegularFile(paths.Root, configuredPath)
|
||||||
|
if err != nil {
|
||||||
|
return nonCurrent("configured artifact output is missing or unsafe; regenerate the artifact")
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil || !info.Mode().IsRegular() {
|
||||||
|
return nonCurrent("configured artifact output is not a safe regular file; regenerate the artifact")
|
||||||
|
}
|
||||||
|
hash := sha256.New()
|
||||||
|
size, err := io.Copy(hash, file)
|
||||||
|
if err != nil {
|
||||||
|
return nonCurrent("configured artifact output could not be verified; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if size != info.Size() || size != record.OutputSize {
|
||||||
|
return nonCurrent("configured artifact output size differs from manifest evidence; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if hex.EncodeToString(hash.Sum(nil)) != record.Output.Checksum {
|
||||||
|
return nonCurrent("configured artifact output checksum differs from manifest evidence; regenerate the artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnalyzeEvidence{
|
||||||
|
State: AnalyzeEvidenceCurrent,
|
||||||
|
SourceID: sourceID,
|
||||||
|
Path: filepath.Join(paths.Root, filepath.FromSlash(configuredPath)),
|
||||||
|
ProducerRunID: record.ProducerRunID,
|
||||||
|
Contract: cloneArtifactContract(record.Output.Contract),
|
||||||
|
Checksum: record.Output.Checksum,
|
||||||
|
Size: record.OutputSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HydrateAnalyzeArtifacts makes configured sources available only from
|
||||||
|
// validated current manifest evidence. It never mutates manifest state.
|
||||||
|
func (c *ArtifactCatalog) HydrateAnalyzeArtifacts(
|
||||||
|
paths SessionPaths,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
configured map[string]ConfiguredArtifactDefinition,
|
||||||
|
) {
|
||||||
|
if c == nil || len(configured) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range c.ListConfigured() {
|
||||||
|
definition, ok := configured[entry.ConfiguredKey]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
evidence := InspectAnalyzeEvidence(paths, m, entry.ConfiguredKey, definition)
|
||||||
|
if evidence.State != AnalyzeEvidenceCurrent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = c.markAvailableFromAnalyzeManifest(
|
||||||
|
evidence.SourceID, evidence.Path, evidence.ProducerRunID,
|
||||||
|
evidence.Checksum, evidence.Size, evidence.Contract,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
234
internal/artifacts/analyze_evidence_test.go
Normal file
234
internal/artifacts/analyze_evidence_test.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInspectAnalyzeEvidenceAcceptsCurrentCanonicalOutput(t *testing.T) {
|
||||||
|
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||||
|
body := []byte("recap\n")
|
||||||
|
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", body)
|
||||||
|
|
||||||
|
got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"})
|
||||||
|
if got.State != AnalyzeEvidenceCurrent {
|
||||||
|
t.Fatalf("State = %q, reason = %q", got.State, got.Reason)
|
||||||
|
}
|
||||||
|
if got.SourceID != ConfiguredArtifactSourceID("session_recap") || got.Path != filepath.Join(paths.ArtifactsDir, "session_recap.md") || got.ProducerRunID != "run-1" {
|
||||||
|
t.Fatalf("evidence = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectAnalyzeEvidenceRejectsNonCurrentAndInvalidEvidence(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*manifest.Manifest)
|
||||||
|
config ConfiguredArtifactDefinition
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{name: "absent manifest", mutate: func(m *manifest.Manifest) { *m = manifest.Manifest{} }, reason: "absent"},
|
||||||
|
{name: "legacy", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeStateVersion = 0
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts = nil
|
||||||
|
}, reason: "legacy"},
|
||||||
|
{name: "unsupported version", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeStateVersion++ }, reason: "legacy or unsupported"},
|
||||||
|
{name: "missing record", mutate: func(m *manifest.Manifest) { delete(m.Stages["analyze"].AnalyzeArtifacts, "session_recap") }, reason: "no analyze manifest record"},
|
||||||
|
{name: "stale", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactStale), reason: `status is "stale"`},
|
||||||
|
{name: "missing", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactMissing), reason: `status is "missing"`},
|
||||||
|
{name: "failed", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactFailed), reason: `status is "failed"`},
|
||||||
|
{name: "unselected", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactUnselected), reason: `status is "unselected"`},
|
||||||
|
{name: "fingerprint version", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.FingerprintVersion++ })
|
||||||
|
}, reason: "malformed"},
|
||||||
|
{name: "key mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Key = "other" })
|
||||||
|
}, reason: "malformed"},
|
||||||
|
{name: "source mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.SourceID = ConfiguredArtifactSourceID("other") })
|
||||||
|
}, reason: "malformed"},
|
||||||
|
{name: "missing contract", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Contract = nil })
|
||||||
|
}, reason: "malformed"},
|
||||||
|
{name: "configured path mismatch", config: ConfiguredArtifactDefinition{OutputPath: "artifacts/renamed.md"}, reason: "differs from current configuration"},
|
||||||
|
{name: "record path mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.LocalPath = "artifacts/other.md" })
|
||||||
|
}, reason: "differs from current configuration"},
|
||||||
|
{name: "size mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.OutputSize++ })
|
||||||
|
}, reason: "size differs"},
|
||||||
|
{name: "checksum mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("0", 64) })
|
||||||
|
}, reason: "checksum differs"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||||
|
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
|
||||||
|
if test.mutate != nil {
|
||||||
|
test.mutate(m)
|
||||||
|
}
|
||||||
|
definition := test.config
|
||||||
|
if definition.OutputPath == "" {
|
||||||
|
definition.OutputPath = "artifacts/session_recap.md"
|
||||||
|
}
|
||||||
|
got := InspectAnalyzeEvidence(paths, m, "session_recap", definition)
|
||||||
|
if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, test.reason) {
|
||||||
|
t.Fatalf("evidence = %#v, want non-current reason containing %q", got, test.reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectAnalyzeEvidenceRejectsMissingAndUnsafeFiles(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
alter func(t *testing.T, paths SessionPaths, outputPath string)
|
||||||
|
}{
|
||||||
|
{name: "missing", alter: func(t *testing.T, _ SessionPaths, outputPath string) {
|
||||||
|
if err := os.Remove(outputPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "directory", alter: func(t *testing.T, _ SessionPaths, outputPath string) {
|
||||||
|
if err := os.Remove(outputPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(outputPath, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "symlink leaf", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
|
||||||
|
if err := os.Remove(outputPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(paths.Root, "target.md")
|
||||||
|
if err := os.WriteFile(target, []byte("recap\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, outputPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "symlink ancestor", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
|
||||||
|
if err := os.RemoveAll(paths.ArtifactsDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outside := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(outside, "session_recap.md"), []byte("recap\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, paths.ArtifactsDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||||
|
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
|
||||||
|
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
|
||||||
|
test.alter(t, paths, outputPath)
|
||||||
|
got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"})
|
||||||
|
if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, "missing or unsafe") {
|
||||||
|
t.Fatalf("evidence = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrateAnalyzeArtifactsUsesOnlyCurrentConfiguredKeys(t *testing.T) {
|
||||||
|
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||||
|
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["removed"] = analyzeEvidenceRecord(t, paths, "removed", "artifacts/removed.md", []byte("old\n"))
|
||||||
|
configured := map[string]ConfiguredArtifactDefinition{"session_recap": {OutputPath: "artifacts/session_recap.md"}}
|
||||||
|
catalog := NewArtifactCatalog()
|
||||||
|
if err := catalog.RegisterConfiguredArtifacts(configured, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
|
entry, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap"))
|
||||||
|
if !entry.Available || entry.Provenance != ArtifactProvenanceCurrentAnalyzeManifest || entry.ProducerRunID != "run-1" {
|
||||||
|
t.Fatalf("entry = %#v", entry)
|
||||||
|
}
|
||||||
|
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
|
||||||
|
if entry.Checksum != record.Output.Checksum || entry.Size != record.OutputSize {
|
||||||
|
t.Fatalf("entry content identity = (%q, %d), want (%q, %d)", entry.Checksum, entry.Size, record.Output.Checksum, record.OutputSize)
|
||||||
|
}
|
||||||
|
if entry.Contract == nil || *entry.Contract != *record.Output.Contract {
|
||||||
|
t.Fatalf("entry contract = %#v, want %#v", entry.Contract, record.Output.Contract)
|
||||||
|
}
|
||||||
|
entry.Contract.SchemaVersion = "mutated"
|
||||||
|
again, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap"))
|
||||||
|
if again.Contract == nil || again.Contract.SchemaVersion != "1" {
|
||||||
|
t.Fatalf("catalog contract was mutated through lookup: %#v", again.Contract)
|
||||||
|
}
|
||||||
|
if _, ok := catalog.Lookup(ConfiguredArtifactSourceID("removed")); ok {
|
||||||
|
t.Fatal("removed manifest record was advertised in current catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeEvidenceFixture(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) *manifest.Manifest {
|
||||||
|
t.Helper()
|
||||||
|
record := analyzeEvidenceRecord(t, paths, key, relativePath, body)
|
||||||
|
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
m := manifest.New(paths.SessionID, now)
|
||||||
|
m.Stages["analyze"] = &manifest.StageRecord{
|
||||||
|
Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||||
|
AnalyzeStateVersion: manifest.AnalyzeStateContractVersion,
|
||||||
|
AnalyzeArtifacts: map[string]manifest.AnalyzeArtifactRecord{key: record},
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeEvidenceRecord(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) manifest.AnalyzeArtifactRecord {
|
||||||
|
t.Helper()
|
||||||
|
outputPath := filepath.Join(paths.Root, filepath.FromSlash(relativePath))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(outputPath, body, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checksum, err := SHA256File(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
return manifest.AnalyzeArtifactRecord{
|
||||||
|
Key: key, Status: manifest.AnalyzeArtifactCurrent,
|
||||||
|
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
|
||||||
|
Fingerprint: strings.Repeat("1", 64),
|
||||||
|
Output: &manifest.ArtifactRecord{
|
||||||
|
Kind: "scriptorium_artifact", SourceID: ConfiguredArtifactSourceID(key), LocalPath: relativePath,
|
||||||
|
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
|
||||||
|
ProducerRunID: "run-1", Checksum: checksum,
|
||||||
|
},
|
||||||
|
OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeEvidenceStatus(status manifest.AnalyzeArtifactStatus) func(*manifest.Manifest) {
|
||||||
|
return func(m *manifest.Manifest) {
|
||||||
|
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
|
||||||
|
record.Status = status
|
||||||
|
record.Output = nil
|
||||||
|
record.OutputSize = 0
|
||||||
|
if status == manifest.AnalyzeArtifactFailed {
|
||||||
|
record.Error = "generation failed"
|
||||||
|
}
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutateAnalyzeEvidenceRecord(m *manifest.Manifest, mutate func(*manifest.AnalyzeArtifactRecord)) manifest.AnalyzeArtifactRecord {
|
||||||
|
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
|
||||||
|
mutate(&record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
@@ -107,6 +107,9 @@ type ResolvedSessionArtifact struct {
|
|||||||
OutputKind string
|
OutputKind string
|
||||||
ProducerRunID string
|
ProducerRunID string
|
||||||
Provenance string
|
Provenance string
|
||||||
|
Contract *artifactmodel.ContractMetadata
|
||||||
|
Checksum string
|
||||||
|
Size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
|
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
|
||||||
@@ -259,6 +262,9 @@ func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest,
|
|||||||
OutputKind: entry.OutputKind,
|
OutputKind: entry.OutputKind,
|
||||||
ProducerRunID: entry.ProducerRunID,
|
ProducerRunID: entry.ProducerRunID,
|
||||||
Provenance: entry.Provenance,
|
Provenance: entry.Provenance,
|
||||||
|
Contract: cloneArtifactContract(entry.Contract),
|
||||||
|
Checksum: entry.Checksum,
|
||||||
|
Size: entry.Size,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) {
|
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromManifest(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
||||||
@@ -385,16 +385,17 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing
|
|||||||
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
||||||
}
|
}
|
||||||
sourceID := ConfiguredArtifactSourceID("player_handout")
|
sourceID := ConfiguredArtifactSourceID("player_handout")
|
||||||
if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil {
|
m := analyzeEvidenceFixture(t, paths, "player_handout", "artifacts/player_handout.md", []byte("handout\n"))
|
||||||
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
|
catalog.HydrateAnalyzeArtifacts(paths, m, map[string]ConfiguredArtifactDefinition{
|
||||||
}
|
"player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"},
|
||||||
|
})
|
||||||
|
|
||||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog)
|
resolved, err := ResolveSessionArtifactWithCatalog(paths, m, sourceID, catalog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||||
}
|
}
|
||||||
if resolved.Provenance != ArtifactProvenanceDisabledFromDisk {
|
if resolved.Provenance != ArtifactProvenanceCurrentAnalyzeManifest {
|
||||||
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk)
|
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceCurrentAnalyzeManifest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
|
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
|
||||||
ArtifactProvenanceDisabledFromDisk = "filesystem.disabled_artifact_output"
|
ArtifactProvenanceCurrentAnalyzeManifest = "manifest.current_analyze_artifact"
|
||||||
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
|
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +65,9 @@ type CatalogEntry struct {
|
|||||||
Path string
|
Path string
|
||||||
Provenance string
|
Provenance string
|
||||||
ProducerRunID string
|
ProducerRunID string
|
||||||
|
Contract *artifactmodel.ContractMetadata
|
||||||
|
Checksum string
|
||||||
|
Size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactCatalog tracks built-in, configured, and extraction artifact definitions and runtime state.
|
// ArtifactCatalog tracks built-in, configured, and extraction artifact definitions and runtime state.
|
||||||
@@ -98,6 +102,7 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
|
|||||||
if _, exists := c.extractionIndex[trimmed]; exists {
|
if _, exists := c.extractionIndex[trimmed]; exists {
|
||||||
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
|
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
|
||||||
}
|
}
|
||||||
|
def := configured[key]
|
||||||
sourceID := ExtractionArtifactSourceID(trimmed)
|
sourceID := ExtractionArtifactSourceID(trimmed)
|
||||||
if err := c.addEntry(CatalogEntry{
|
if err := c.addEntry(CatalogEntry{
|
||||||
SourceID: sourceID,
|
SourceID: sourceID,
|
||||||
@@ -105,6 +110,10 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
|
|||||||
ProducerStage: "extract",
|
ProducerStage: "extract",
|
||||||
OutputKind: "notarius_lane",
|
OutputKind: "notarius_lane",
|
||||||
Planned: true,
|
Planned: true,
|
||||||
|
Contract: &artifactmodel.ContractMetadata{
|
||||||
|
MediaType: def.MediaType, SchemaID: def.SchemaID,
|
||||||
|
SchemaVersion: def.SchemaVersion, ModuleKey: def.ModuleKey,
|
||||||
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("register extraction artifact %q: %w", trimmed, err)
|
return fmt.Errorf("register extraction artifact %q: %w", trimmed, err)
|
||||||
}
|
}
|
||||||
@@ -221,7 +230,7 @@ func (c *ArtifactCatalog) Lookup(sourceID string) (CatalogEntry, bool) {
|
|||||||
return CatalogEntry{}, false
|
return CatalogEntry{}, false
|
||||||
}
|
}
|
||||||
entry, ok := c.entries[strings.TrimSpace(sourceID)]
|
entry, ok := c.entries[strings.TrimSpace(sourceID)]
|
||||||
return entry, ok
|
return cloneCatalogEntry(entry), ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceIDForConfiguredKey returns canonical source ID for one configured key.
|
// SourceIDForConfiguredKey returns canonical source ID for one configured key.
|
||||||
@@ -255,7 +264,7 @@ func (c *ArtifactCatalog) ListConfigured() []CatalogEntry {
|
|||||||
out := make([]CatalogEntry, 0, len(keys))
|
out := make([]CatalogEntry, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
sourceID := c.configuredIndex[key]
|
sourceID := c.configuredIndex[key]
|
||||||
out = append(out, c.entries[sourceID])
|
out = append(out, cloneCatalogEntry(c.entries[sourceID]))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -272,7 +281,7 @@ func (c *ArtifactCatalog) ListExtraction() []CatalogEntry {
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
out := make([]CatalogEntry, 0, len(keys))
|
out := make([]CatalogEntry, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
out = append(out, c.entries[c.extractionIndex[key]])
|
out = append(out, cloneCatalogEntry(c.entries[c.extractionIndex[key]]))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -282,17 +291,71 @@ func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error {
|
|||||||
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkAvailableFromDisk marks one source as available from disabled artifact on disk.
|
// MarkAvailableGeneratedEvidence marks one source as available from the
|
||||||
func (c *ArtifactCatalog) MarkAvailableFromDisk(sourceID, path string) error {
|
// current analyze invocation and retains the semantic output identity needed
|
||||||
return c.markAvailable(sourceID, path, ArtifactProvenanceDisabledFromDisk)
|
// by later scheduled dependents.
|
||||||
|
func (c *ArtifactCatalog) MarkAvailableGeneratedEvidence(
|
||||||
|
sourceID, path, producerRunID, checksum string,
|
||||||
|
size int64,
|
||||||
|
contract *artifactmodel.ContractMetadata,
|
||||||
|
) error {
|
||||||
|
producerRunID = strings.TrimSpace(producerRunID)
|
||||||
|
checksum = strings.TrimSpace(checksum)
|
||||||
|
if err := ValidateRunIdentity(producerRunID); err != nil {
|
||||||
|
return fmt.Errorf("generated artifact producer run id: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateSHA256(checksum); err != nil {
|
||||||
|
return fmt.Errorf("generated artifact checksum: %w", err)
|
||||||
|
}
|
||||||
|
if size <= 0 {
|
||||||
|
return fmt.Errorf("generated artifact size must be positive")
|
||||||
|
}
|
||||||
|
if contract == nil || strings.TrimSpace(contract.MediaType) == "" ||
|
||||||
|
strings.TrimSpace(contract.SchemaID) == "" || strings.TrimSpace(contract.SchemaVersion) == "" {
|
||||||
|
return fmt.Errorf("generated artifact contract is incomplete")
|
||||||
|
}
|
||||||
|
if err := c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||||
|
entry.ProducerRunID = producerRunID
|
||||||
|
entry.Checksum = checksum
|
||||||
|
entry.Size = size
|
||||||
|
entry.Contract = cloneArtifactContract(contract)
|
||||||
|
c.entries[entry.SourceID] = entry
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, producerRunID string) error {
|
func (c *ArtifactCatalog) markAvailableFromExtractManifest(
|
||||||
|
sourceID, path, producerRunID, checksum string,
|
||||||
|
size int64,
|
||||||
|
contract *artifactmodel.ContractMetadata,
|
||||||
|
) error {
|
||||||
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
|
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
entry := c.entries[strings.TrimSpace(sourceID)]
|
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||||
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
||||||
|
entry.Checksum = strings.TrimSpace(checksum)
|
||||||
|
entry.Size = size
|
||||||
|
entry.Contract = cloneArtifactContract(contract)
|
||||||
|
c.entries[entry.SourceID] = entry
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ArtifactCatalog) markAvailableFromAnalyzeManifest(
|
||||||
|
sourceID, path, producerRunID, checksum string,
|
||||||
|
size int64,
|
||||||
|
contract *artifactmodel.ContractMetadata,
|
||||||
|
) error {
|
||||||
|
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentAnalyzeManifest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||||
|
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
||||||
|
entry.Checksum = strings.TrimSpace(checksum)
|
||||||
|
entry.Size = size
|
||||||
|
entry.Contract = cloneArtifactContract(contract)
|
||||||
c.entries[entry.SourceID] = entry
|
c.entries[entry.SourceID] = entry
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -313,10 +376,29 @@ func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error
|
|||||||
entry.Available = true
|
entry.Available = true
|
||||||
entry.Path = trimmedPath
|
entry.Path = trimmedPath
|
||||||
entry.Provenance = provenance
|
entry.Provenance = provenance
|
||||||
|
entry.ProducerRunID = ""
|
||||||
|
entry.Checksum = ""
|
||||||
|
entry.Size = 0
|
||||||
|
if provenance == ArtifactProvenanceGeneratedCurrentAnalyzeRun {
|
||||||
|
entry.Contract = nil
|
||||||
|
}
|
||||||
c.entries[normalizedID] = entry
|
c.entries[normalizedID] = entry
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneCatalogEntry(entry CatalogEntry) CatalogEntry {
|
||||||
|
entry.Contract = cloneArtifactContract(entry.Contract)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneArtifactContract(contract *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
|
||||||
|
if contract == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := *contract
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("artifact catalog is nil")
|
return fmt.Errorf("artifact catalog is nil")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user