Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 792cbd0677 | |||
| 5dca34e0e0 | |||
| 54d335c820 | |||
| 96b4e73f53 | |||
| 6e6054f744 | |||
| dc3288d15a | |||
| d872edca94 | |||
| 2ae63dbc2b | |||
| 26aa0cce29 | |||
| 7b0bf99ed1 | |||
| 89ada1b269 | |||
| 3438fe82c9 | |||
| a51a513804 | |||
| 995354f1a5 | |||
| 931c9591cd | |||
| 46096fc4ba | |||
| d3ecd907ee | |||
| 442469b9f5 | |||
| c45e042462 | |||
| 207da1b6ef | |||
| 9a1609435e | |||
| 13b83d7ee2 | |||
| a0312ed034 | |||
| 8ff74e1251 | |||
| 1b480d4ed5 | |||
| e13d87a077 | |||
| 5202f16972 | |||
| 5086596f94 | |||
| f7fc64db10 | |||
| 6b0778b420 | |||
| 5b92b3e790 | |||
| cc43906ae9 | |||
| dcaa2ad9c6 | |||
| 9d68cfcf2f | |||
| 2d346733d6 | |||
| cd75bf6d5a | |||
| e08de98ae6 | |||
| df213bc2ba | |||
| 869e68e834 |
13
docs/cli.md
13
docs/cli.md
@@ -98,6 +98,8 @@ a session nor create a workspace, manifest, run, lock, adapter, or remote
|
||||
connection. Like every pipeline-loading command, they bootstrap the selected or
|
||||
conventional secrets directory before resolving configuration; `config show`
|
||||
never includes bootstrap reports or secret values in its YAML output.
|
||||
Typed credential references, including `notarius.llm_api_key`, must therefore
|
||||
resolve even though these inspection commands never construct an adapter.
|
||||
|
||||
Campaign selection is optional for a pipeline without party-driven artifact
|
||||
families. A pipeline with `scriptorium.artifact_families` needs a selected or
|
||||
@@ -175,6 +177,9 @@ 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;
|
||||
- resolves every selected typed credential during the initial configuration
|
||||
load, including a Notarius credential when extract is disabled, outside the
|
||||
selected range, or later skipped as resumable;
|
||||
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
||||
resume check finds its durable result obsolete;
|
||||
- applies `--force` only to stages in the selected range;
|
||||
@@ -207,6 +212,10 @@ artifacts and any prerequisites required by those targets; without
|
||||
never run. Common session/configuration options and repeatable artifact values
|
||||
pass through unchanged.
|
||||
|
||||
The forced extraction rebuilds the complete configured Notarius bundle,
|
||||
disables Notarius checkpoint loading, and requests chunk-plan refresh.
|
||||
`--artifacts` narrows only the analysis work; it never selects Notarius lanes.
|
||||
|
||||
Because the expansion owns `--force`, `--from`, and `--through`, callers cannot
|
||||
supply those options. The shared `run` parser reports them as duplicate
|
||||
singleton flags. The alias has no private execution options or behavior, and
|
||||
@@ -249,6 +258,10 @@ Equivalent to:
|
||||
narratio run-stage analyze <session_id> --force [...common config flags]
|
||||
```
|
||||
|
||||
This selects only `analyze`: it does not invoke Notarius, replace extraction,
|
||||
or remove existing extract outputs. `--artifacts` narrows the forced analysis
|
||||
targets and their required analysis prerequisites.
|
||||
|
||||
### `publish`
|
||||
|
||||
```bash
|
||||
|
||||
@@ -227,8 +227,12 @@ inputs:
|
||||
- Do not place raw secrets in YAML.
|
||||
- Credential fields contain environment-variable names, represented internally
|
||||
as ConfigKit typed secret references (for example
|
||||
`pipeline.audita.llm_api_key: AUDITA_LLM_API_KEY`). A configured reference
|
||||
`pipeline.notarius.llm_api_key: NOTARIUS_LLM_API_KEY`). A configured reference
|
||||
must resolve to a nonempty environment value during load.
|
||||
- Every surviving reference is resolved during ordinary pipeline loading,
|
||||
regardless of whether its integration is disabled, outside the selected
|
||||
stage range, or eligible for resume. Omit the field or set it to `null` when
|
||||
that deployment should not require the credential.
|
||||
- Every command that loads a pipeline accepts `--secrets-dir <path>`. When the
|
||||
flag is omitted, Narratio uses a present `secrets/` directory beside the
|
||||
resolved root `pipeline.yml`; an absent conventional directory is ignored.
|
||||
@@ -236,8 +240,10 @@ inputs:
|
||||
and a missing or empty explicit selection is an error.
|
||||
- Each immediate regular file whose name is a valid environment-variable name
|
||||
supplies one value. ConfigKit removes trailing CR/LF, permits a symlink as the
|
||||
selected directory, rejects symlink entries, and limits input to 1 MiB per
|
||||
file, 8 MiB total, and 1024 entries.
|
||||
selected directory, rejects non-hidden symlink entries, and limits input to
|
||||
1 MiB per file, 8 MiB total, and 1024 entries. Dot-prefixed entries (including
|
||||
files, directories, and symlinks) are ignored and omitted from bootstrap
|
||||
reports, but still count toward the directory entry limit.
|
||||
- An existing process environment value takes precedence over a credential file.
|
||||
- On POSIX, operators must provision the directory with no group/other access
|
||||
(normally `0700`) and files with no group/other access (normally `0600`);
|
||||
@@ -353,10 +359,14 @@ Rules:
|
||||
| `pipeline.trim.seriatim.report` | bool | No | `false` |
|
||||
| `pipeline.notarius.enabled` | bool | No | `false` |
|
||||
| `pipeline.notarius.binary` | string | No | `notarius` |
|
||||
| `pipeline.notarius.llm_api_key` | secret reference | No | unset; exact environment-variable name forwarded to Notarius |
|
||||
| `pipeline.notarius.config_profile` | string | No | unset; exact Notarius ConfigKit profile selector |
|
||||
| `pipeline.notarius.config_path` | string | Conditional | required when enabled; relative paths resolve from the pipeline file directory |
|
||||
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
|
||||
| `pipeline.notarius.timeout` | duration | No | `3h`; must be positive |
|
||||
| `pipeline.notarius.working_directory` | string | No | directory containing resolved `config_path`; relative paths resolve from the pipeline file directory |
|
||||
| `pipeline.notarius.resume` | bool | No | `false`; request compatible Notarius checkpoint reuse during ordinary executed extraction |
|
||||
| `pipeline.notarius.chunk_cache` | string | No | empty; when set, exactly `auto`, `bypass`, or `refresh` |
|
||||
| `pipeline.notarius.references` | map[string]string | No | empty; maps normalized Notarius selectors to supported prepared Narratio source IDs; maximum 256 entries |
|
||||
| `pipeline.notarius.outputs` | map | Conditional | at least one entry when enabled |
|
||||
| `pipeline.render.enabled` | bool | No | `true` |
|
||||
@@ -373,6 +383,73 @@ Rules:
|
||||
| `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 |
|
||||
|
||||
### Notarius Configuration Profile
|
||||
|
||||
`pipeline.notarius.config_profile` optionally selects a Notarius ConfigKit
|
||||
configuration profile when extraction executes:
|
||||
|
||||
```yaml
|
||||
notarius:
|
||||
config_profile: downstream-economy
|
||||
```
|
||||
|
||||
When the field is omitted or `null`, Narratio supplies no downstream profile
|
||||
selector and preserves Notarius's existing configuration-selection behavior.
|
||||
When present, Narratio forwards the exact string to Notarius, including an
|
||||
empty string or `default`, which explicitly select Notarius's composed base
|
||||
configuration. Notarius owns selector interpretation and validation; Narratio
|
||||
does not restrict profile names, resolve profile files, or rewrite the value.
|
||||
|
||||
This selection is independent of Narratio's own `--profile` selection. A
|
||||
Narratio overlay may explicitly select a differently named Notarius overlay,
|
||||
but Narratio never forwards its own profile name automatically. It is also
|
||||
independent of `pipeline.notarius.llm_api_key`: an overlay may change either or
|
||||
both fields according to the deployed Notarius configuration. See the
|
||||
[Notarius integration contract](./integrations/notarius.md) for invocation and
|
||||
failure behavior.
|
||||
|
||||
### Notarius Credential
|
||||
|
||||
`pipeline.notarius.llm_api_key` optionally names the one environment variable
|
||||
to forward to the Notarius child process:
|
||||
|
||||
```yaml
|
||||
notarius:
|
||||
llm_api_key: OPENROUTER_API_KEY
|
||||
```
|
||||
|
||||
The field contains the variable name, not a credential value. That name must
|
||||
match `api_key_env` in the Notarius/PromptKit profile selected by the deployed
|
||||
Notarius pipeline; Narratio does not infer or remap provider names. Omission or
|
||||
`null` forwards nothing. A non-null selected reference must resolve at pipeline
|
||||
load time through the process environment or existing secrets-directory
|
||||
bootstrap, even when Notarius is disabled, extract is outside the requested
|
||||
range, or a completed extraction can be reused.
|
||||
|
||||
The Notarius child receives only Narratio's minimal execution environment plus
|
||||
this exact selected name and loaded value. Other credentials are not forwarded.
|
||||
Changing the value, name, presence, or absence does not invalidate extract's
|
||||
semantic resume evidence. The public name remains part of safe effective
|
||||
inspection, so renaming it may appear in `config diff` and change the effective
|
||||
configuration digest; rotating only its value does neither.
|
||||
|
||||
See the [Notarius integration contract](./integrations/notarius.md) for the
|
||||
delivery and redaction boundary.
|
||||
|
||||
### Notarius Cache Controls
|
||||
|
||||
`pipeline.notarius.resume` defaults to `false`. When true, an executed ordinary
|
||||
extraction passes `--resume`; the deployed Notarius configuration must already
|
||||
enable checkpoint recording and should select an explicit persistent checkpoint
|
||||
root. Narratio does not inspect or generate that configuration.
|
||||
|
||||
`pipeline.notarius.chunk_cache` is empty by default, leaving Notarius's
|
||||
configured mode in control. A nonempty value must be `auto`, `bypass`, or
|
||||
`refresh` and is passed as `--chunk_cache`. Forced extraction takes precedence
|
||||
over both fields: it omits `--resume` and passes `--chunk_cache refresh`.
|
||||
Neither field changes Narratio's semantic extraction fingerprint, so changing
|
||||
them alone does not rerun an otherwise complete durable extraction.
|
||||
|
||||
### Notarius Reference Bindings
|
||||
|
||||
`pipeline.notarius.references` maps a Notarius CLI selector to a prepared
|
||||
@@ -402,8 +479,8 @@ It validates only selector structure and the prepared source vocabulary;
|
||||
Notarius owns target-slot declarations and media compatibility.
|
||||
|
||||
Before extraction, Narratio resolves every binding from the current prepared
|
||||
session manifest and streams it into a verified invocation-local snapshot whose
|
||||
absolute path is passed to Notarius. Missing, unsafe, empty,
|
||||
session manifest and streams it into a verified, content-addressed session
|
||||
snapshot whose absolute path is passed to Notarius. Missing, unsafe, empty,
|
||||
changed-during-copy, or checksum-inconsistent prepared evidence fails with
|
||||
guidance to force `prepare`. Bindings are sorted by normalized selector and are
|
||||
part of extraction fingerprint and resume identity. See the
|
||||
|
||||
@@ -7,14 +7,14 @@ lanes from the final trimmed Seriatim transcript. Narratio owns invocation,
|
||||
safe bundle discovery, lane selection, and its own artifact metadata. Notarius
|
||||
owns pipeline definitions, lane schemas, the receipt, and bundle formats.
|
||||
|
||||
Canonical Notarius v0.6.0 references:
|
||||
Canonical Notarius v0.7.0 references:
|
||||
|
||||
- [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/cli.md)
|
||||
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/subprocess.md)
|
||||
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/dnd-pipeline.md)
|
||||
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/run-result.md)
|
||||
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/json-output.md)
|
||||
- [D&D spell-catalog overlay](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/dnd-spell-catalog-overlays.md)
|
||||
- [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/cli.md)
|
||||
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/consumers/subprocess.md)
|
||||
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/consumers/dnd-pipeline.md)
|
||||
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/run-result.md)
|
||||
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/json-output.md)
|
||||
- [D&D spell-catalog overlay](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/integrations/dnd-spell-catalog-overlays.md)
|
||||
|
||||
The [complete Narratio example](../../examples/pipeline.full.annotated.yml)
|
||||
records the exact current constraints for all ten D&D lanes. Treat the linked
|
||||
@@ -25,19 +25,38 @@ duplicate the complete schemas.
|
||||
|
||||
When `pipeline.notarius.enabled` is true, Narratio resolves the executable,
|
||||
configuration path, input path, output directory, and working directory to
|
||||
absolute paths. Narratio requires the Notarius v0.6.0 CLI contract when
|
||||
references are configured and invokes each binding as a separate argument
|
||||
before `--json`:
|
||||
absolute paths. Configured references require support for the `--reference`
|
||||
contract introduced in Notarius v0.6.0. Cache controls require support for the
|
||||
selected `--resume` and `--chunk_cache` flags. Notarius v0.7.0 is the tested
|
||||
cache integration baseline, not an unconditional minimum version; Narratio
|
||||
does not impose a runtime version gate. Each reference binding is a separate
|
||||
argument before `--json`. When `pipeline.notarius.config_profile` is present,
|
||||
its exact value is another argument pair before `--json`:
|
||||
|
||||
```text
|
||||
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--reference <selector>=<verified_snapshot_path>]... --json
|
||||
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--resume] [--chunk_cache <mode>] [--reference <selector>=<verified_snapshot_path>]... [--config-profile <name>] --json
|
||||
```
|
||||
|
||||
Reference paths are absolute invocation-local snapshots streamed from the
|
||||
manifest-verified canonical files prepared inside the current Narratio session
|
||||
workspace. Narratio verifies snapshot checksum and size before and after the
|
||||
subprocess, and passes only configured bindings, ordered lexically by normalized
|
||||
selector, as direct argument-vector entries without shell interpretation. A CLI
|
||||
Notarius v0.7.0 is a known supporting version for `--config-profile`; using the
|
||||
option requires a compatible Notarius executable, but deployments that omit the
|
||||
field acquire no additional version requirement. Omitted or null configuration
|
||||
adds no flag. Supplied empty, `default`, and named selectors are forwarded
|
||||
without normalization so Notarius can apply its own ConfigKit semantics. This
|
||||
option selects a Notarius configuration overlay, not a PromptKit profile through
|
||||
`--llm-profile`, and Narratio never substitutes its own selected profile name.
|
||||
A missing or invalid downstream overlay is an ordinary Notarius subprocess
|
||||
failure: Narratio does not retry without the selector or fall back to another
|
||||
profile.
|
||||
|
||||
Reference paths are absolute stable snapshots streamed from manifest-verified
|
||||
canonical files prepared inside the current Narratio session workspace. They
|
||||
live at `cache/notarius/references/<sha256>/<prepared-basename>` and retain the
|
||||
full lowercase content digest. Narratio verifies snapshot checksum and size
|
||||
before and after the subprocess, reconstructs missing or corrupt regular files
|
||||
from a still-valid prepared source, and passes only configured bindings,
|
||||
ordered lexically by normalized selector, as direct argument-vector entries
|
||||
without shell interpretation. Symlinks, directories, other non-regular
|
||||
conflicts, and prepared-source mismatches fail rather than being deleted. A CLI
|
||||
binding takes precedence over a matching external path in Notarius
|
||||
configuration. Narratio never emits `--without-reference`.
|
||||
|
||||
@@ -75,9 +94,41 @@ It does not pass a Narratio session ID or run `notarius config validate`
|
||||
automatically; the configured working directory and Narratio's minimal child
|
||||
environment apply to the subprocess.
|
||||
|
||||
Ordinary extraction maps `pipeline.notarius.resume: true` to `--resume` and a
|
||||
nonempty `pipeline.notarius.chunk_cache` to `--chunk_cache <mode>`. Forced
|
||||
extraction always omits `--resume` and supplies `--chunk_cache refresh`.
|
||||
Notarius owns checkpoint enablement, recording, identity, and both cache roots;
|
||||
Narratio neither reads nor synthesizes its configuration. Use explicit
|
||||
persistent roots in Notarius when reuse must span subprocess invocations.
|
||||
Changing a reference path/digest or other upstream identity input correctly
|
||||
causes a cold miss. Chunk refresh bypasses lookup and publishes the accepted
|
||||
replacement for the active source; it does not delete unrelated plans or
|
||||
checkpoint state.
|
||||
|
||||
When `pipeline.notarius.llm_api_key` is absent or null, Narratio adds no
|
||||
credential to that environment. When configured, the field names an environment
|
||||
variable whose already-loaded nonempty value is forwarded under that exact
|
||||
name. Operators must use the same name as the `api_key_env` in the selected
|
||||
Notarius/PromptKit profile. Narratio does not inspect the Notarius configuration
|
||||
to infer a provider or destination name, and it does not forward unrelated
|
||||
credentials from its parent environment or secrets directory.
|
||||
|
||||
The selected name is registered with Narratio's shared streaming redactor.
|
||||
Credential bytes written by the child to captured stdout, captured stderr, or
|
||||
stderr-derived failure diagnostics are replaced with `<redacted>`, including
|
||||
custom names not known to the built-in sensitive-name list. Narratio does not
|
||||
serialize the credential into arguments, stage metadata, manifests, or
|
||||
generated configuration.
|
||||
|
||||
This captured-stream protection is not a filesystem sandbox. Notarius remains
|
||||
responsible for keeping credentials out of files it writes directly, including
|
||||
bundle artifacts and its own diagnostics. Narratio does not scan or rewrite
|
||||
arbitrary downstream files and cannot promise redaction of transformed or
|
||||
encoded credentials.
|
||||
|
||||
## Accepted Result
|
||||
|
||||
Narratio's supported invocation baseline is Notarius v0.6.0. The accepted
|
||||
Narratio's tested invocation baseline is Notarius v0.7.0. The accepted
|
||||
receipt remains `notarius.run-result.v2`; reference flags do not change the
|
||||
receipt or ten-lane output contract. The receipt
|
||||
must identify the configured pipeline, and its `index_file` must be exactly
|
||||
@@ -102,6 +153,8 @@ version, and, when configured, module key. Missing, duplicate, rejected, or
|
||||
incompatible required lanes fail extraction even if Notarius exited zero. A
|
||||
configured lane whose v2 validation summary is `rejected` or `incomplete` also
|
||||
fails extraction.
|
||||
An accepted reused producer may report `producer_attempt_count: 0`; Narratio
|
||||
preserves that valid summary rather than treating it as a missing field.
|
||||
Unconfigured lanes may remain in the preserved bundle but do not become
|
||||
selectable Narratio sources.
|
||||
|
||||
@@ -130,3 +183,18 @@ Configuration fields and defaults are in [Configuration](../config.md).
|
||||
Operator paths, rerun procedures, and bundle retention are in
|
||||
[Operations](../operations.md). See [Troubleshooting](../troubleshooting.md)
|
||||
for failure recovery.
|
||||
|
||||
## Opt-in Integration Test
|
||||
|
||||
The production-boundary cache test requires an absolute path to a locally
|
||||
built Notarius v0.7.0-compatible executable. It uses only a local
|
||||
OpenAI-compatible `httptest` provider and temporary cache/config/output roots:
|
||||
|
||||
```sh
|
||||
NARRATIO_TEST_NOTARIUS_BINARY=/absolute/path/to/notarius \
|
||||
GOWORK=off go test ./internal/stage \
|
||||
-run '^TestNotariusCacheReuseIntegration$' -count=1 -v
|
||||
```
|
||||
|
||||
When the variable is unset, the normal test suite skips this test and never
|
||||
searches `PATH`, a sibling checkout, the network, or a real provider.
|
||||
|
||||
@@ -17,11 +17,20 @@ Narratio sends an HTTP `POST` to the configured transcription URL using
|
||||
The server must return a `2xx` response whose body is valid JSON. Narratio does
|
||||
not currently require a more specific response schema at this boundary.
|
||||
|
||||
The transcription URL must be an absolute `http` or `https` URL. The audio body
|
||||
is streamed through a fresh multipart writer for every attempt, so its memory
|
||||
use is bounded by the transport buffer rather than by the complete audio file.
|
||||
The transcription URL must be an absolute `http` or `https` URL. Each
|
||||
application attempt creates one bounded multipart framing and streams the audio
|
||||
from an open file between that framing; it does not buffer the complete audio
|
||||
or spool a multipart copy. The file field precedes the language field.
|
||||
WhisperX response acquisition is capped at 10 MiB.
|
||||
|
||||
The initial request body and any transport-requested replay use the same
|
||||
multipart boundary and framing. Every body opens an independent audio handle
|
||||
and replay starts at the beginning. Narratio closes all bodies created for the
|
||||
attempt on response, failure, timeout, or cancellation, including when a
|
||||
transport abandons an earlier body. Genuine source read/close failures remain
|
||||
request failures; deliberate early closure of an abandoned original does not
|
||||
poison a valid replay.
|
||||
|
||||
## Request And Result Contract
|
||||
|
||||
Each adapter request identifies a speaker, a readable audio file, and the
|
||||
@@ -43,6 +52,11 @@ transcript output.
|
||||
- Narratio waits the configured retry delay between attempts and aborts that
|
||||
wait when the parent context is canceled.
|
||||
|
||||
Transport replay occurs inside one application attempt and does not consume an
|
||||
entry from the configured retry budget or increment the reported attempt
|
||||
count. The standard HTTP redirect policy remains in effect; body replay can
|
||||
also support body-preserving redirects.
|
||||
|
||||
## Validation And Failure Semantics
|
||||
|
||||
Client construction rejects a missing or non-HTTP(S) absolute transcription URL,
|
||||
@@ -55,12 +69,19 @@ failure to install the output causes the transcription to fail. Errors include
|
||||
attempt context, and the result retains attempts, final status when available,
|
||||
and elapsed duration for diagnostics.
|
||||
|
||||
Replay makes a fresh request body available to the HTTP transport; it does not
|
||||
provide an exactly-once server guarantee and does not prove recovery from every
|
||||
proxy or protocol-level failure. A server may have received work before the
|
||||
client learns that a request failed. WhisperX operations should therefore be
|
||||
safe to retry for the same input when practical.
|
||||
|
||||
## Determinism And Concurrency
|
||||
|
||||
Each audio request has stable multipart field names, and successful bytes are
|
||||
installed atomically. The transcribe stage may process speaker files in
|
||||
parallel, bounded by the configured concurrency. It records results in stable
|
||||
speaker order after all work completes; any speaker failure fails the stage.
|
||||
parallel, bounded by the configured concurrency. Each validated speaker is
|
||||
checkpointed independently; any speaker failure still fails the aggregate
|
||||
stage, while committed earlier speakers remain reusable on an ordinary retry.
|
||||
|
||||
## Related Canonical Docs
|
||||
|
||||
|
||||
@@ -65,6 +65,14 @@ Notarius is composed only when extraction is enabled; the extract stage owns
|
||||
prepared reference resolution, receipt, bundle, and configured-lane policy.
|
||||
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
||||
owner of serializing them as repeated `--reference` arguments before `--json`.
|
||||
It also serializes an effective resume request as `--resume` and a nonempty
|
||||
validated chunk mode as `--chunk_cache <mode>`. The extract stage, not the
|
||||
adapter, resolves force precedence before constructing the request.
|
||||
Its request may also carry one already-resolved opaque ConfigKit secret
|
||||
reference. Only the Notarius adapter reads that value, rejects an unresolved or
|
||||
empty programmatic reference, and maps the exact configured name/value into the
|
||||
shared subprocess override and sensitive-name fields. It never rereads the
|
||||
parent environment. A nil reference preserves the previous invocation.
|
||||
|
||||
Application-owned object-store construction loads configured filesystem
|
||||
secrets before invoking the invocation's storage factory. Production commands
|
||||
|
||||
@@ -168,6 +168,10 @@ Configuration stores credentials as ConfigKit typed references. A successful
|
||||
load resolves their values inside the opaque typed boundary, while this safe
|
||||
projection serializes only `EnvName()` identities. Credential-value changes are
|
||||
therefore never hashed; reference-name changes remain ordinary provenance.
|
||||
The optional Notarius credential follows the same path even for disabled or
|
||||
resumable extraction; normalization copies the opaque reference without reading
|
||||
or resolving it again. Direct YAML serialization has a configuration-owned safe
|
||||
projection so only the reference name can leave this boundary.
|
||||
`recomputePipelineEffectiveDigest` is the single package-owned refresh point
|
||||
for later runtime expansion.
|
||||
|
||||
|
||||
@@ -50,6 +50,45 @@ The model admits these stage states:
|
||||
- `stale`
|
||||
- `interrupted`
|
||||
|
||||
### Transcribe-owned speaker state
|
||||
|
||||
A `transcribe` stage record may carry `transcribe_state_version: 1` and a
|
||||
`transcribe_speakers` map keyed by the existing derived speaker ID. The version
|
||||
is the authority marker: a versioned empty map means that no earlier speaker
|
||||
completion is reusable. Absence of both fields is accepted as legacy
|
||||
aggregate-only state so current Narratio can read older manifests.
|
||||
|
||||
Each map entry represents one completed speaker only. It binds a versioned
|
||||
fingerprint to the session-relative audio path, audio SHA-256 and size, one
|
||||
canonical `transcript_raw` output with its path, SHA-256 and size, and the
|
||||
producer run ID. Paths must be canonical and session-relative, checksums must
|
||||
be lowercase SHA-256 values, and the record and output producer identities
|
||||
must agree. Other stages cannot carry this transcribe-owned state.
|
||||
Audio size is nonnegative; WhisperX determines whether that audio can be
|
||||
transcribed. Output size remains positive and the transcript must be valid JSON.
|
||||
|
||||
Transcribe reconciles this collection against the current prepared audio set
|
||||
and observable WhisperX semantics. Missing or corrupt regular outputs become
|
||||
work; unsafe or non-regular evidence is rejected. It saves the reconciled
|
||||
collection before HTTP dispatch and saves a detached candidate after each
|
||||
validated completion. Only after a save succeeds is the candidate installed as
|
||||
live authority. Canonical bytes produced before an unsuccessful save are not a
|
||||
completion record.
|
||||
|
||||
Aggregate transcribe status still describes the whole invocation. A failed,
|
||||
running, stale, or interrupted aggregate may therefore retain reusable speaker
|
||||
records, while aggregate outputs are declared only after the current
|
||||
collection is complete. Ordinary retry reconciles and reuses those records.
|
||||
Force first checkpoints an empty current collection, preventing later retries
|
||||
from reviving pre-force results.
|
||||
|
||||
Older aggregate-only transcribe success is conservatively regenerated once
|
||||
when transcribe is selected because it does not bind individual outputs to the
|
||||
audio bytes used. The session-manifest schema identifier remains
|
||||
`narratio.session-manifest.v1`; this additive state is accepted by current
|
||||
Narratio, but older strict-decoding binaries may reject manifests after these
|
||||
fields have been written.
|
||||
|
||||
### Analyze-owned artifact state
|
||||
|
||||
Every `analyze` stage record carries `analyze_state_version: 1` and an
|
||||
@@ -156,10 +195,10 @@ establish a current immutable snapshot.
|
||||
|
||||
Manifest replacement provides atomic visibility, not a crash- or power-loss
|
||||
durability guarantee. Callers reload after a returned save error before retrying.
|
||||
An analyze checkpoint error leaves the prior manifest as per-artifact authority.
|
||||
The runner then attempts to persist the bounded failed/stale retry collection;
|
||||
if that terminal save also fails, its error is joined with the originating
|
||||
failure and the next invocation must reconcile from the last readable manifest.
|
||||
A transcribe or analyze checkpoint error leaves the prior manifest collection
|
||||
as authority. The runner then attempts to persist the aggregate failure; if
|
||||
that terminal save also fails, its error is joined with the originating failure
|
||||
and the next invocation must reconcile from the last readable manifest.
|
||||
|
||||
## Execution Semantics
|
||||
|
||||
@@ -193,11 +232,11 @@ 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
|
||||
before execution begins.
|
||||
|
||||
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.
|
||||
Aggregate lifecycle clearing deliberately preserves both the transcribe-owned
|
||||
speaker collection and the analyze-owned per-artifact collection. This lets
|
||||
later reconciliation replace only non-current entries without erasing durable
|
||||
completions. Other stages retain aggregate-only lifecycle behavior and are
|
||||
forbidden from carrying either collection.
|
||||
|
||||
A stage may explicitly return a skipped disposition and stable reason. The
|
||||
runner persists that outcome in the session manifest, clears older outputs,
|
||||
@@ -228,7 +267,7 @@ 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) |
|
||||
| transcribe speakers | aggregate semantic fingerprint plus per-speaker input/output reconciliation | [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) |
|
||||
@@ -310,6 +349,7 @@ where the last session transition can require operator interpretation.
|
||||
- Remote commit model and readers: `internal/artifacts/remote_commit.go`,
|
||||
`internal/artifacts/current_state_commit.go`,
|
||||
- Persistence and validation: `internal/manifest/store.go`
|
||||
- Transcribe speaker state: `internal/manifest/transcribe_state.go`
|
||||
- Package tests: `internal/manifest/*_test.go`
|
||||
- Assembled execution behavior: `internal/app/runner_test.go`,
|
||||
`internal/app/run_stage_test.go`
|
||||
|
||||
@@ -53,11 +53,12 @@ adapters. The normative rules for these relationships remain in
|
||||
[Architecture](../policy/architecture.md).
|
||||
|
||||
Command execution normalizes one private dependency value per invocation.
|
||||
Configuration reads, secret-directory reads, remote downloads, object-store
|
||||
construction, and restore phase functions use that value throughout the
|
||||
command. Public command entry points supply production defaults, while focused
|
||||
tests replace only the external or sequencing boundary they exercise without
|
||||
mutating package state.
|
||||
Pipeline configuration-path discovery, configuration reads, secret-directory
|
||||
reads, remote downloads, object-store construction, and restore phase functions
|
||||
use that value throughout the command. Public command entry points supply the
|
||||
canonical discovery and production defaults, while focused tests may provide
|
||||
invocation-owned discovery candidates or replace only the external or sequencing
|
||||
boundary they exercise without mutating package state.
|
||||
|
||||
Pipeline execution and `session plan` share the same inclusive contiguous-range
|
||||
model and the same pure pre-execution decision over recorded status, force, and
|
||||
|
||||
@@ -19,12 +19,17 @@ procedures belong in [Operations](../operations.md).
|
||||
1. resolves the final trimmed transcript from the shared artifact catalog;
|
||||
2. resolves every configured prepared reference through the shared
|
||||
manifest-authoritative identity resolver before creating run-local output;
|
||||
3. streams each verified reference into an invocation-local snapshot and
|
||||
rejects any source change observed while copying;
|
||||
3. resolves each reference to a stable content-addressed session snapshot,
|
||||
reusing verified bytes or reconstructing a missing/corrupt regular entry,
|
||||
and rejects any source change observed while copying;
|
||||
4. fingerprints the byte- and provenance-bearing Notarius invocation evidence,
|
||||
including sorted reference identities;
|
||||
including sorted reference identities and the explicit downstream
|
||||
configuration-profile selection;
|
||||
5. creates a run-local staging directory and invokes the injected
|
||||
`notarius.Runner`;
|
||||
`notarius.Runner`, carrying the optional already-resolved credential
|
||||
reference without exposing its value, the optional exact configuration
|
||||
profile, plus effective resume and chunk-cache controls after force
|
||||
precedence;
|
||||
6. revalidates the reference snapshots, then validates the v2 successful
|
||||
receipt, confined index, management documents, configured required lane
|
||||
descriptors, validation summaries, and regular payload files;
|
||||
@@ -41,14 +46,22 @@ Narratio run ID, the resolved trimmed-input identity, and invocation
|
||||
fingerprint. The input identity binds the exact transcript bytes, canonical
|
||||
source ID, producer stage/output/run identity, and resolution provenance.
|
||||
Reference metadata contains only selector, source ID, canonical session-relative
|
||||
path, checksum, and size; adapter requests receive selector and absolute
|
||||
invocation-local snapshot path, never payload contents. Snapshot bytes must
|
||||
path, checksum, and size; adapter requests receive selector and absolute stable
|
||||
snapshot path, never payload contents. Snapshot bytes must
|
||||
match the prepared identity both before and after Notarius runs, so a concurrent
|
||||
prepared-file replacement cannot make recorded provenance describe different
|
||||
bytes from those supplied to Notarius.
|
||||
Validation completes before the single durable rename, so a rejected result
|
||||
cannot expose a partial durable bundle.
|
||||
|
||||
Snapshot destinations are owned by `internal/artifacts` under
|
||||
`cache/notarius/references/<sha256>/<prepared-basename>`. They contain no run ID
|
||||
or timestamp and can be reused across invocations. Identical bytes and basename
|
||||
share a verified file; changed bytes select a different digest directory.
|
||||
Unsafe path components and non-regular conflicts fail without recursive
|
||||
deletion. Snapshot paths are delivery details and never become manifest output
|
||||
evidence or prerequisites for reuse of an already complete extract bundle.
|
||||
|
||||
Any executed extraction outcome that replaces a different effective outcome
|
||||
marks succeeded analysis and delivery dependents stale. Render is an independent
|
||||
sibling and remains current. Repeating the same disabled self-skip with no
|
||||
@@ -59,17 +72,28 @@ outputs is stable and does not repeatedly invalidate dependent stages.
|
||||
Before the focused validator runs, the application compares extract's versioned
|
||||
semantic fingerprint. It covers enablement, Notarius pipeline identity, sorted
|
||||
reference selector/source mappings, sorted declared output contracts, and each
|
||||
canonical `narratio.extraction.<key>` output identity. It excludes executable,
|
||||
canonical `narratio.extraction.<key>` output identity. It also covers the
|
||||
explicit Notarius configuration-profile selection; omitted, empty, and exact
|
||||
`default` selectors share the base-selection identity. It excludes executable,
|
||||
timeout, working directory, config path, and private Notarius config contents.
|
||||
Credential presence, reference name, and resolved value are likewise
|
||||
operational-only and excluded. Ordinary command loading still resolves a
|
||||
selected reference before resume evaluation, but a credential-only change does
|
||||
not rerun a valid completed extraction or stale its dependents.
|
||||
|
||||
`internal/stage/extract_resume.go` then permits a skip only when the existing
|
||||
stage record still matches the current byte- and provenance-bearing invocation
|
||||
evidence. That evidence covers the current direct trimmed-transcript identity,
|
||||
sorted prepared-reference identities, pipeline identity, and configured output
|
||||
contracts. The same reference helper and transcript identity are resolved again
|
||||
for artifact evidence, so changing current transcript bytes, reference bytes,
|
||||
or producer identity makes the prior extraction obsolete. Operational runner
|
||||
settings do not invalidate otherwise current durable evidence.
|
||||
contracts, plus the same downstream profile-selection identity. The same
|
||||
reference helper and transcript identity are resolved again for artifact
|
||||
evidence, so changing current transcript bytes, reference bytes, producer
|
||||
identity, or a named downstream selection makes the prior extraction obsolete.
|
||||
Operational runner settings do not invalidate otherwise current durable
|
||||
evidence. This includes `resume` and `chunk_cache`; changing them alone cannot
|
||||
make a complete extraction non-resumable. If extraction executes, ordinary
|
||||
runs forward the configured values, while force disables resume and selects
|
||||
`refresh`.
|
||||
|
||||
A valid prepared-reference change makes extraction non-resumable. Missing,
|
||||
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
|
||||
@@ -82,10 +106,12 @@ contracts and provenance, regular-file status, and stored checksums. Missing or
|
||||
obsolete results are non-resumable and run again; unsafe filesystem conditions
|
||||
return an error rather than silently accepting or replacing data.
|
||||
|
||||
Neither contract can observe files imported by Notarius configuration, profile
|
||||
contents, prompt/module definitions, or other transitive inputs. Operators must
|
||||
force extraction after changing any such private input behind a stable
|
||||
identifier.
|
||||
This sensitivity is limited to the explicit downstream selector. Narratio's
|
||||
own profile label and whole effective-configuration digest are not blanket
|
||||
extraction invalidators. Neither contract can observe files imported by
|
||||
Notarius configuration, profile contents, prompt/module definitions, or other
|
||||
transitive inputs. Operators must force extraction after changing any such
|
||||
private input behind a stable identifier.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
@@ -110,9 +136,13 @@ recovery.
|
||||
`internal/stage/extract_resume.go`,
|
||||
`internal/stage/extract_test.go`,
|
||||
`internal/stage/semantic_contracts_delivery.go`
|
||||
- Stable snapshot materialization and verification:
|
||||
`internal/stage/extract_references.go`
|
||||
- Subprocess boundary: `internal/adapters/notarius/subprocess.go`,
|
||||
`internal/adapters/notarius/subprocess_test.go`
|
||||
- Catalog hydration: `internal/artifacts/extraction_catalog.go`,
|
||||
`internal/artifacts/extraction_catalog_test.go`
|
||||
- Composition and downstream behavior: `internal/app/runner_test.go`,
|
||||
`internal/stage/analyze_test.go`, `internal/stage/publish_test.go`
|
||||
- Opt-in Notarius v0.7.0 production-boundary coverage:
|
||||
`internal/stage/notarius_cache_integration_test.go`
|
||||
|
||||
@@ -17,7 +17,11 @@ Normalize raw transcript inputs and merge into base transcript via Seriatim.
|
||||
|
||||
## Key Behavior
|
||||
|
||||
- discovers and validates raw transcript inputs.
|
||||
- discovers and validates raw transcript inputs. For versioned transcribe
|
||||
state, discovery requires aggregate transcribe success and consumes only the
|
||||
aggregate outputs that exactly match the current per-speaker collection.
|
||||
Partial checkpoints and orphaned files are not merge inputs. Legacy/manual
|
||||
workflows without versioned speaker state retain directory discovery.
|
||||
- normalizes each raw transcript (`seriatim.Normalize`) into run-local scratch output.
|
||||
- merges normalized inputs (`seriatim.Run`) into base transcript.
|
||||
- validates merged transcript and optional report JSON.
|
||||
@@ -26,6 +30,8 @@ Normalize raw transcript inputs and merge into base transcript via Seriatim.
|
||||
## Invariants
|
||||
|
||||
- merge always consumes normalized forms of raw inputs.
|
||||
- a versioned transcribe collection is usable only through its complete,
|
||||
successful aggregate output declaration.
|
||||
- base transcript must validate before stage success.
|
||||
- report output is config-gated.
|
||||
|
||||
|
||||
@@ -14,12 +14,19 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
|
||||
|
||||
## Key Behavior
|
||||
|
||||
- discovers prepared audio from manifest inputs or canonical audio directory.
|
||||
- derives the transcript identity from the prepared `.flac` filename.
|
||||
- dispatches one WhisperX request per prepared file through a bounded errgroup.
|
||||
- validates each output as JSON.
|
||||
- writes run-local outputs then materializes canonical transcript outputs only
|
||||
after every planned request succeeds.
|
||||
- discovers prepared audio from manifest inputs or the canonical audio
|
||||
directory and derives each speaker identity from its `.flac` filename.
|
||||
- hashes the current audio and reconciles it with the versioned per-speaker
|
||||
completion collection before starting HTTP work.
|
||||
- reuses only records whose audio identity, observable WhisperX semantics,
|
||||
canonical output location, JSON, checksum, and size remain current.
|
||||
- durably checkpoints the reconciled collection before dispatch, then sends
|
||||
only missing or obsolete speakers through the bounded worker group.
|
||||
- validates each successful run-local response, rechecks the audio bytes,
|
||||
materializes the canonical raw transcript, and checkpoints that speaker
|
||||
before accepting another completion.
|
||||
- rebuilds aggregate stage outputs in stable speaker order from the complete
|
||||
current collection, including records produced by earlier invocations.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -28,21 +35,48 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
|
||||
- output path returned by adapter must match requested output path.
|
||||
- an empty adapter result path means the requested path; adapters cannot select
|
||||
an alternate destination.
|
||||
- each successful output is validated before stage success, and cancellation or
|
||||
incomplete dispatch cannot be reported as a successful result.
|
||||
- candidate collection construction, manifest save, and installation into live
|
||||
state are serialized. Expensive hashing and HTTP work remain outside that
|
||||
critical section.
|
||||
- a canonical file has no completion authority until its manifest checkpoint
|
||||
succeeds. A save failure preserves the last successfully committed
|
||||
collection for ordinary retry.
|
||||
- each successful output is validated before its checkpoint, and cancellation,
|
||||
incomplete dispatch, or a failed aggregate save cannot be reported as
|
||||
aggregate stage success.
|
||||
- removed audio inputs are omitted from the current collection and aggregate
|
||||
output list; reconciliation does not delete arbitrary transcript files.
|
||||
- merge cannot consume a partial versioned collection: it requires transcribe
|
||||
aggregate success and an exact match between current speaker records and
|
||||
declared aggregate outputs.
|
||||
|
||||
## 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.
|
||||
Each completed speaker record binds the speaker ID, session-relative audio
|
||||
path, streamed audio checksum and size, the Narratio-visible WhisperX service
|
||||
URL and recognition language, and the canonical transcript path, checksum,
|
||||
size, and producer run. Changed audio bytes select that speaker for replacement;
|
||||
a changed URL or language selects every speaker. Either case stales fixed
|
||||
descendants through the normal runner decision. Retry count/delay, concurrency,
|
||||
timeout, credentials, and diagnostic locations are operational and do not
|
||||
change this evidence.
|
||||
|
||||
An ordinary retry reuses every valid speaker checkpoint even when aggregate
|
||||
transcribe status is failed, running, or stale. A succeeded stage is skipped only
|
||||
when its stored speaker set and aggregate output evidence also match the current
|
||||
collection. Otherwise, execution repairs those records without retranscribing
|
||||
reusable speakers and stales dependents through the normal runner path.
|
||||
`--force` first durably replaces
|
||||
the collection with an empty versioned collection, so a later ordinary retry
|
||||
cannot resurrect pre-force results; speakers checkpointed by that forced
|
||||
attempt remain reusable.
|
||||
|
||||
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.
|
||||
An older aggregate-only transcribe record has no trustworthy audio-to-output
|
||||
binding. When transcribe is next selected, every current speaker is therefore
|
||||
generated once and recorded in the versioned collection; later invocations can
|
||||
reuse it normally.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
@@ -51,6 +85,9 @@ selected.
|
||||
- [Configuration](../config.md#pipeline) owns concurrency and other
|
||||
operator-selected values.
|
||||
- Implementation and tests: `internal/stage/transcribe.go`,
|
||||
`internal/stage/transcribe_reconciliation.go`,
|
||||
`internal/stage/transcribe_execution_state.go`,
|
||||
`internal/stage/transcribe_resume.go`,
|
||||
`internal/stage/transcribe_test.go`,
|
||||
`internal/stage/semantic_contracts_initial.go`, and
|
||||
`internal/stage/semantic_contracts_initial_test.go`
|
||||
`internal/stage/transcribe_reconciliation_test.go`, and
|
||||
`internal/stage/transcribe_checkpoint_test.go`
|
||||
|
||||
@@ -37,6 +37,12 @@ validates and renames the external bundle to the unique immutable Notarius
|
||||
bundle path supplied by `internal/artifacts`. Exact physical paths belong in
|
||||
[Operations](../operations.md#extraction-workflow).
|
||||
|
||||
Prepared references delivered to Notarius use session-durable paths owned by
|
||||
`internal/artifacts` beneath
|
||||
`cache/notarius/references/<sha256>/<prepared-basename>`. These verified
|
||||
content-addressed snapshots are outside run-local layout, survive ordinary
|
||||
published-run cleanup, and are excluded from publish and restore enumeration.
|
||||
|
||||
## Locking
|
||||
|
||||
`artifacts.LocalStore` enforces the single-writer session lock via an
|
||||
|
||||
@@ -116,8 +116,8 @@ Execution rules:
|
||||
perpetually rerun dependent work.
|
||||
|
||||
Every aggregate stage except analyze currently provides semantic-configuration
|
||||
evidence; analyze retains its more precise per-artifact fingerprints and
|
||||
validator.
|
||||
evidence; transcribe additionally reconciles per-speaker completion evidence,
|
||||
while 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
|
||||
@@ -187,7 +187,68 @@ 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:
|
||||
### Transcription continuation and replacement
|
||||
|
||||
Transcribe checkpoints each validated speaker independently. If a later
|
||||
speaker exhausts HTTP retries, the process is canceled, or the aggregate stage
|
||||
save fails, the aggregate stage is not successful and downstream merge cannot
|
||||
consume the partial set. The last successfully saved speaker records and their
|
||||
canonical `transcripts/raw/<speaker>.json` files remain durable, however.
|
||||
|
||||
After correcting the underlying problem, retry normally:
|
||||
|
||||
```bash
|
||||
narratio run-stage transcribe 2026-04-04
|
||||
```
|
||||
|
||||
Narratio hashes the current prepared audio, validates recorded transcript JSON,
|
||||
checksum, size, and location, and calls WhisperX only for missing or obsolete
|
||||
speakers. A changed audio file replaces only its speaker; a changed WhisperX
|
||||
URL or language makes every current speaker non-reusable. Retry, timeout, and
|
||||
concurrency tuning do not invalidate completed speakers.
|
||||
|
||||
Use force only when every current transcript must be replaced, including after
|
||||
a WhisperX model or private server-side configuration changes behind the same
|
||||
URL and language:
|
||||
|
||||
```bash
|
||||
narratio run-stage transcribe 2026-04-04 --force
|
||||
```
|
||||
|
||||
Force durably clears the reusable collection before sending requests. If that
|
||||
forced attempt fails, a later ordinary retry reuses only replacement speakers
|
||||
checkpointed after the reset; it does not revive pre-force results. Do not edit
|
||||
speaker checksums or copy older JSON into the canonical directory to influence
|
||||
reuse.
|
||||
|
||||
The first current Narratio invocation that selects transcribe for an older
|
||||
aggregate-only manifest retranscribes every current speaker once because the
|
||||
old record cannot prove which audio bytes produced each file. Subsequent runs
|
||||
reuse the new versioned evidence normally. The overall session-manifest schema
|
||||
identifier is unchanged, but older strict-decoding Narratio binaries may reject
|
||||
a manifest after current Narratio writes the additive speaker fields; retain a
|
||||
backup or complete the upgrade before relying on downgrade.
|
||||
|
||||
Restoring a manifest does not implicitly publish or restore every raw speaker
|
||||
file. If a recorded canonical transcript is absent locally, ordinary
|
||||
reconciliation schedules that speaker again. Removing run directories does not
|
||||
remove canonical checkpointed transcripts and does not by itself invalidate
|
||||
them.
|
||||
|
||||
## Artifact Development Workflows
|
||||
|
||||
Choose the narrowest workflow that matches the intended replacement:
|
||||
|
||||
| Intent | Command | Preserved and rebuilt state |
|
||||
| --- | --- | --- |
|
||||
| Rebuild analysis only | [`narratio analyze 2026-04-04`](./cli.md#analyze) | Preserves transcripts and the current extraction bundle; force-runs selected analysis artifacts and their required analysis prerequisites. |
|
||||
| Rebuild extraction and analysis | [`narratio regenerate-artifacts 2026-04-04`](./cli.md#regenerate-artifacts) | Preserves transcripts; force-runs the complete configured extraction bundle, then selected analysis. `--artifacts` narrows analysis only. |
|
||||
| Continue bounded work normally | [`narratio run 2026-04-04 --from extract --through analyze`](./cli.md#run) | Uses ordinary resume decisions: current extraction and analysis can be skipped, while incomplete or obsolete work runs. |
|
||||
|
||||
All three stop before publication. Inspect the results and run
|
||||
[`narratio publish`](./cli.md#publish) separately when delivery is intended.
|
||||
|
||||
For the extraction-plus-analysis development loop, use:
|
||||
|
||||
```bash
|
||||
narratio regenerate-artifacts 2026-04-04
|
||||
@@ -220,6 +281,30 @@ earlier checkpoints are reused and execution resumes with failed, stale, or
|
||||
missing work in dependency order. `--force` is unnecessary unless the operator
|
||||
intends to replace otherwise-current artifacts.
|
||||
|
||||
### Developing With A Notarius Configuration Profile
|
||||
|
||||
A Narratio development overlay can explicitly select a Notarius overlay without
|
||||
requiring the names to match. For example, `profiles/testing.yml` may contain:
|
||||
|
||||
```yaml
|
||||
notarius:
|
||||
config_profile: downstream-economy
|
||||
```
|
||||
|
||||
Selecting Narratio's `testing` profile then supplies `downstream-economy` only
|
||||
when extraction executes. Switching that explicit selector makes a recorded
|
||||
extraction non-current and reruns it when `extract` is in the selected stage
|
||||
range; the usual dependent invalidation then applies. Selecting a profile does
|
||||
not create a separate workspace or retain a separate current extraction result.
|
||||
|
||||
Narratio cannot observe edits inside Notarius configuration behind an unchanged
|
||||
selector. Force extraction after such an edit when a replacement is intended.
|
||||
Use `regenerate-artifacts` when both extraction and analysis must be rebuilt;
|
||||
use `analyze` when the existing extraction bundle should be preserved. Field
|
||||
semantics belong in [Configuration](./config.md#notarius-configuration-profile),
|
||||
and the exact downstream invocation belongs in the
|
||||
[Notarius integration contract](./integrations/notarius.md#invocation).
|
||||
|
||||
## Artifact Selection
|
||||
|
||||
For a configured artifact family, selecting its family key expands to every
|
||||
@@ -260,9 +345,14 @@ are not selectable or published implicitly.
|
||||
Configured Notarius references resolve only from the current manifest-backed
|
||||
prepared inputs. Their canonical locations are `inputs/party.yml`,
|
||||
`inputs/players.yml`, `inputs/glossary.yml`, and, when configured,
|
||||
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
|
||||
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
|
||||
prepared files cannot change the bytes consumed by an in-flight invocation.
|
||||
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified,
|
||||
content-addressed copies under
|
||||
`cache/notarius/references/<sha256>/<prepared-basename>`. The full lowercase
|
||||
SHA-256 digest identifies the reference bytes, so retries and later invocations
|
||||
reuse the same path while changed bytes select a new path. Older snapshots are
|
||||
retained and are not current artifact evidence. A concurrent refresh of
|
||||
canonical prepared files therefore cannot change the bytes consumed by an
|
||||
in-flight invocation.
|
||||
For a canonical party, preparation retains the validated authored party bytes
|
||||
at `inputs/party.yml` and generates `inputs/players.yml` from that roster.
|
||||
The manifest records their checksums separately, with the players input marked
|
||||
@@ -297,6 +387,28 @@ Notarius extraction validates its run-local bundle and moves it once to the
|
||||
unique durable bundle path. A pre-existing durable destination is a collision;
|
||||
no duplicate promotion tree is created.
|
||||
|
||||
`pipeline.notarius.resume` and `pipeline.notarius.chunk_cache` control work
|
||||
inside an executed Notarius invocation. Ordinary extraction forwards their
|
||||
configured values. Forced extraction always disables checkpoint loading and
|
||||
requests chunk-plan `refresh`, even when the configured mode is `bypass`.
|
||||
Refresh regenerates and replaces only the active input's accepted chunk plan;
|
||||
it is not cache deletion.
|
||||
|
||||
Notarius checkpoint recording must be enabled in the deployed Notarius
|
||||
configuration. Narratio neither creates a Notarius pipeline/configuration nor
|
||||
reads it to infer checkpoint availability. Configure explicit persistent
|
||||
Notarius chunk-plan and checkpoint roots when reuse must survive separate
|
||||
Narratio subprocesses. A changed reference, transcript, selected profile,
|
||||
profile content, endpoint, or other Notarius checkpoint identity input can
|
||||
produce a legitimate cold miss.
|
||||
|
||||
There are two independent resume boundaries: Narratio may skip a completed
|
||||
`extract` stage without invoking Notarius, while `--resume` allows Notarius to
|
||||
reuse compatible accepted work only after Narratio has decided extraction must
|
||||
execute. Deleting Narratio's local reference snapshots does not invalidate a
|
||||
complete durable extraction; missing snapshots are reconstructed only when a
|
||||
later extraction actually runs.
|
||||
|
||||
## External Command Lifecycle
|
||||
|
||||
When an external command is cancelled or times out, Narratio terminates its
|
||||
@@ -337,13 +449,13 @@ narratio run-stage extract 2026-04-04 --force
|
||||
Narratio automatically reruns extraction when its recorded invocation contract,
|
||||
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
|
||||
reference mapping, output contracts, and the explicit Notarius configuration
|
||||
profile selector. Executable, timeout, working directory, and private
|
||||
config-file paths are operational and do not invalidate a current result.
|
||||
It cannot fingerprint configuration or profile contents, prompts, modules, or
|
||||
other references loaded transitively by Notarius itself. Force extraction after
|
||||
changing any of those inputs, even when the top-level Narratio and Notarius
|
||||
config paths remain the same. A forced extract
|
||||
changing any of those inputs behind an unchanged selector, even when the
|
||||
top-level Narratio and Notarius config paths remain the same. A forced extract
|
||||
marks successful downstream stages stale. Ordinary extraction failures or
|
||||
outcome changes also stale affected downstream stages, while an identical
|
||||
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
|
||||
@@ -509,6 +621,7 @@ Durable session paths:
|
||||
- `logs/**`
|
||||
- `config/**`
|
||||
- `runs/**`
|
||||
- `cache/notarius/references/<sha256>/<prepared-basename>`
|
||||
|
||||
`manifest.json` is the versioned session-progress document. Narratio accepts
|
||||
only `narratio.session-manifest.v1`. Do not edit unsupported session state to
|
||||
@@ -519,6 +632,13 @@ relevant workflow requires.
|
||||
Validated Notarius bundles live below `artifacts/notarius/{run_id}/`; receipt,
|
||||
stderr, and pre-promotion output remain in the producing run's `extract`
|
||||
directory as described in [Extraction Workflow](#extraction-workflow).
|
||||
Stable reference snapshots live in the session cache path above. Ordinary
|
||||
published-run cleanup removes its selected run-local target but retains this
|
||||
cache, and publish/restore never enumerate it as an artifact. Remove a snapshot
|
||||
or digest directory manually only while no command is active for the session;
|
||||
regular missing or corrupt content is reconstructed on the next executed
|
||||
extraction, while symlinks, directories, and other unsafe conflicts must be
|
||||
investigated and removed deliberately.
|
||||
|
||||
Run-local layout:
|
||||
|
||||
|
||||
70
docs/releases/v2.1.0.md
Normal file
70
docs/releases/v2.1.0.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Narratio v2.1.0
|
||||
|
||||
This release adds explicit Notarius credential forwarding and cache reuse,
|
||||
with predictable forced regeneration and clearer artifact-development guidance.
|
||||
|
||||
## Summary
|
||||
|
||||
Narratio can pass one typed API-key reference to Notarius and opt into reuse of
|
||||
its accepted execution checkpoints. Stable, verified reference snapshots allow
|
||||
unchanged references to retain their cache identity across Narratio invocations.
|
||||
Configuration loading now uses ConfigKit v0.3.0.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The new `notarius.llm_api_key`, `notarius.resume`, and `notarius.chunk_cache`
|
||||
fields are optional. Existing v2 configuration needs no schema migration.
|
||||
Configured credential references must resolve during configuration loading,
|
||||
even when extraction will not execute; omitted references add no requirement.
|
||||
|
||||
Forced extraction now omits Notarius checkpoint loading and requests chunk-plan
|
||||
refresh. This includes `regenerate-artifacts`, which remains an alias for a
|
||||
forced run from extract through analyze. `analyze` continues to force analysis
|
||||
only, preserving extraction results and transcripts.
|
||||
|
||||
Notarius v0.7.0 is the tested cache integration baseline, not an unconditional
|
||||
minimum-version gate. The executable must support the flags selected for the
|
||||
invocation, including `--chunk_cache refresh` for forced extraction. Reference
|
||||
bindings retain the contract introduced in Notarius v0.6.0. Receipt and artifact
|
||||
schemas are unchanged; reused producers may report zero production attempts.
|
||||
|
||||
Supported release platforms remain Linux amd64/arm64 and best-effort macOS
|
||||
arm64. macOS validation is cross-compilation, not native runtime testing.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No special migration is required for existing v2 configuration. To use the new
|
||||
features:
|
||||
|
||||
- Declare `notarius.llm_api_key` with the environment-variable name expected by
|
||||
the deployed Notarius profile, supplying its value through the existing
|
||||
environment or secrets-directory mechanism.
|
||||
- Enable checkpoint recording in Notarius configuration before setting
|
||||
Narratio's `notarius.resume: true`. Prefer explicit persistent Notarius cache
|
||||
roots for service deployments. An optional `notarius.chunk_cache` overrides
|
||||
its ordinary chunk-plan policy.
|
||||
- Expect an initial checkpoint miss when moving from old run-local reference
|
||||
paths to stable session snapshots. No migration or deletion of old caches is
|
||||
required. Force refreshes the current input's plan, not the whole cache.
|
||||
- Use `narratio analyze SESSION` for analysis-only iteration, and
|
||||
`narratio regenerate-artifacts SESSION` when extraction should also be rebuilt.
|
||||
|
||||
See [Configuration](../config.md), the
|
||||
[Notarius integration contract](../integrations/notarius.md), and
|
||||
[Artifact Development Workflows](../operations.md#artifact-development-workflows)
|
||||
for current details.
|
||||
|
||||
## Changes
|
||||
|
||||
- Upgraded ConfigKit from v0.1.2 to v0.3.0 and isolated pipeline-discovery tests
|
||||
from machine configuration.
|
||||
- Added optional typed Notarius credentials, exact-name environment delivery,
|
||||
safe configuration projections, and captured-stream redaction for custom names.
|
||||
- Added Notarius resume and chunk-cache controls with force taking precedence.
|
||||
- Added verified, content-addressed session reference snapshots that survive
|
||||
ordinary run cleanup without becoming published artifacts.
|
||||
- Preserved relative-workspace support for snapshot delivery and confinement.
|
||||
- Kept credentials and cache-policy controls outside extract's semantic resume
|
||||
identity, so operational changes alone do not invalidate completed results.
|
||||
- Added an opt-in real Notarius integration test using a local provider to
|
||||
verify reuse, partial-failure recovery, and forced refresh without paid calls.
|
||||
33
docs/releases/v2.1.1.md
Normal file
33
docs/releases/v2.1.1.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Narratio v2.1.1
|
||||
|
||||
This release fixes configuration loading for publish rules that explicitly set
|
||||
requiredness while relying on a derived destination.
|
||||
|
||||
## Summary
|
||||
|
||||
Publish rules with a derivable source and omitted `dest` now load correctly
|
||||
whether `required` is omitted, explicitly true, or explicitly false. Previously,
|
||||
explicit requiredness could trigger ConfigKit's `resolve.unrecorded_change`
|
||||
diagnostic instead of accepting valid configuration.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is a backward-compatible bookkeeping correction. Publish schemas,
|
||||
destination derivation, requiredness defaults, and YAML syntax support are
|
||||
unchanged. Explicit destinations and explicit false values remain intact.
|
||||
No ConfigKit upgrade or publication/storage behavior change is involved.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No special upgrade action or configuration migration is required. Operators
|
||||
need not add a derivable `dest`, remove explicit `required` values, or change
|
||||
flow-style YAML to block-style YAML. See the
|
||||
[configuration reference](../config.md#publish-configuration-summary).
|
||||
|
||||
## Changes
|
||||
|
||||
- Recorded derived publish destinations at ConfigKit's atomic sequence boundary
|
||||
while retaining field-level resolution records.
|
||||
- Added real load/validation and safe effective-configuration regression coverage
|
||||
for built-in and configured-artifact sources, explicit requiredness, and an
|
||||
explicit destination that must remain unchanged.
|
||||
26
docs/releases/v2.1.2.md
Normal file
26
docs/releases/v2.1.2.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Narratio v2.1.2
|
||||
|
||||
This release allows secrets directories to contain hidden metadata without
|
||||
preventing credential loading.
|
||||
|
||||
## Summary
|
||||
|
||||
ConfigKit v0.3.1 ignores dot-prefixed entries such as `.git`, `.gitignore`, and
|
||||
`.DS_Store` during secrets bootstrap. These entries no longer block otherwise
|
||||
valid credentials from loading and are omitted from bootstrap reports.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is a backward-compatible dependency bug fix. Configuration schemas,
|
||||
existing-environment precedence, and validation of non-hidden entries are
|
||||
unchanged. Hidden entries still count toward the directory entry limit.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No special upgrade action or configuration migration is required. See the
|
||||
[credential configuration reference](../config.md) for secrets-directory usage.
|
||||
|
||||
## Changes
|
||||
|
||||
- Updated ConfigKit from v0.3.0 to v0.3.1.
|
||||
- Clarified hidden-entry handling in the secrets-directory documentation.
|
||||
53
docs/releases/v2.2.0.md
Normal file
53
docs/releases/v2.2.0.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Narratio v2.2.0
|
||||
|
||||
This release adds resumable per-speaker transcription, replayable WhisperX
|
||||
uploads, and explicit Notarius configuration profile selection.
|
||||
|
||||
## Summary
|
||||
|
||||
Successfully checkpointed speaker transcripts survive later transcription
|
||||
failures. Ordinary retry reuses valid completed speakers and processes only the
|
||||
remaining work. WhisperX uploads can now be replayed by the HTTP transport
|
||||
without buffering whole audio files.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Existing CLI commands, speaker identities, transcript formats, and pipeline
|
||||
ordering are unchanged. Session manifests gain optional per-speaker completion
|
||||
evidence. Older manifests remain readable, but older Narratio binaries may
|
||||
reject manifests containing the new fields; downgrading after transcription
|
||||
is not transparent.
|
||||
|
||||
The optional `pipeline.notarius.config_profile` field selects a downstream
|
||||
Notarius configuration profile independently of Narratio's own profile. Using
|
||||
it requires a Notarius version supporting `--config-profile`; v0.7.0 is a known
|
||||
supporting version. Omitting the field retains downstream default selection.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No configuration migration is required. When transcribe is next selected for
|
||||
an older aggregate-only manifest, all current speakers are transcribed once
|
||||
to establish reusable evidence. Previously unrecorded results from failed runs
|
||||
are not automatically imported.
|
||||
|
||||
Use ordinary run/resume to retain valid checkpoints. Forcing transcribe resets
|
||||
reuse of prior results; an ordinary retry after a failed forced invocation can
|
||||
retain replacements already checkpointed by that attempt. See the
|
||||
[operations guide](../operations.md) and
|
||||
[Notarius profile configuration](../config.md#notarius-configuration-profile).
|
||||
|
||||
## Changes
|
||||
|
||||
- Persist validated speaker completions independently of aggregate stage status,
|
||||
with serialized saves for concurrent work and input/output integrity checks.
|
||||
- Rebuild aggregate transcript references when speakers are removed or those
|
||||
references need repair, without retranscribing otherwise-current speakers.
|
||||
- Keep removed and incomplete speaker results out of versioned merge inputs.
|
||||
- Permit checkpoint evidence for zero-byte audio when WhisperX accepts it;
|
||||
service rejection still fails normally.
|
||||
- Provide fresh streaming multipart bodies for HTTP transport replay while
|
||||
preserving application retries, cancellation, and resource cleanup. This
|
||||
addresses the missing-body-replay limitation, not persistent upstream
|
||||
protocol failures or exactly-once service execution.
|
||||
- Pass explicit Notarius configuration profiles through the CLI and include
|
||||
their selection in extraction reuse decisions.
|
||||
@@ -1,375 +0,0 @@
|
||||
# ConfigKit Adoption and Platform Simplification
|
||||
|
||||
**Status:** Accepted; implementation pending
|
||||
|
||||
## Purpose
|
||||
|
||||
Narratio currently owns a substantial YAML configuration subsystem for strict
|
||||
decoding, explicit imports, additive composition, profile overlays, presence
|
||||
tracking, provenance, and effective-value inspection. ConfigKit was extracted
|
||||
from this implementation and now provides those generic responsibilities as a
|
||||
maintained library.
|
||||
|
||||
Narratio 2.0 will adopt ConfigKit as its configuration foundation, remove the
|
||||
superseded generic infrastructure, and retain only Narratio-specific configuration
|
||||
policy and domain resolution. The same major release will narrow the supported
|
||||
platform set to the systems that Narratio is expected to run on in practice.
|
||||
|
||||
This roadmap defines the desired contracts and end state. The
|
||||
[implementation plan](implementation.md) owns sequencing and completion gates.
|
||||
|
||||
## Goals
|
||||
|
||||
- Reduce Narratio-owned configuration code and tests by delegating generic YAML
|
||||
mechanics to ConfigKit.
|
||||
- Establish one strict and explainable configuration lifecycle rather than
|
||||
retaining parallel or compatibility loaders.
|
||||
- Preserve Narratio's application-specific defaults, validation, campaign/session
|
||||
resolution, party handling, artifact-family expansion, digests, and CLI policy.
|
||||
- Make individual artifact definitions easy to organize, identify, inspect, and
|
||||
reuse as dependencies of later artifacts.
|
||||
- Keep raw credentials out of YAML and adopt a predictable secrets-directory
|
||||
convention that works before configuration loading.
|
||||
- Remove unsupported platform code and release obligations that provide no known
|
||||
user value.
|
||||
- Deliver the resulting contract changes as Narratio v2.0.0 with a clear migration
|
||||
guide.
|
||||
|
||||
## Platform Contract
|
||||
|
||||
Narratio's v2 platform matrix is:
|
||||
|
||||
| Platform | Status | Required evidence and deliverables |
|
||||
| --- | --- | --- |
|
||||
| Linux amd64 | Primary, supported | Default tests, race checks, static analysis, build validation, and a release asset |
|
||||
| Linux arm64 | Primary, supported | Cross-build validation and a release asset; native validation is used when an appropriate runner is available |
|
||||
| macOS arm64 | Best effort | Cross-build validation and a release asset; native CI and platform-equivalent runtime guarantees are not required release gates |
|
||||
| macOS amd64 | Unsupported | No build gate, release asset, compatibility promise, or platform-specific maintenance |
|
||||
| Windows | Unsupported | No build gate, release asset, compatibility promise, or platform-specific maintenance |
|
||||
|
||||
Windows- and Intel-Mac-specific code, tests, CI entries, release packaging, and
|
||||
documentation are removed when they no longer serve a supported build. Portable
|
||||
code should remain portable where doing so is natural, but accidental compilation
|
||||
on an unsupported target does not create a support commitment.
|
||||
|
||||
Best-effort macOS arm64 support means Narratio intends to keep the target buildable
|
||||
and will consider reported defects when practical. Lack of a native runner or an
|
||||
unverified platform-specific behavior does not block a release. A target for which
|
||||
Narratio publishes an asset must still pass its configured cross-build gate.
|
||||
|
||||
## ConfigKit Dependency and Ownership
|
||||
|
||||
Narratio will depend on the tagged ConfigKit module at an exact reviewed version,
|
||||
initially `gitea.maximumdirect.net/eric/ConfigKit@v0.1.1`. ConfigKit is pre-1.0 and
|
||||
may make compatibility changes in later minor versions, so dependency upgrades are
|
||||
intentional changes accompanied by contract review and focused Narratio integration
|
||||
tests; Narratio does not follow an unpinned branch or automatically accept a newer
|
||||
minor release.
|
||||
|
||||
ConfigKit owns:
|
||||
|
||||
- bounded file, byte, and reader input;
|
||||
- the accepted YAML syntax and strict typed decoding;
|
||||
- import declarations, traversal, containment, and resource limits;
|
||||
- additive composition and conflict attribution;
|
||||
- selected-profile overlay semantics;
|
||||
- explicit input presence and source provenance;
|
||||
- application-default and recorded-resolution coordination;
|
||||
- structured configuration diagnostics;
|
||||
- secret-reference parsing and resolution;
|
||||
- secrets-directory loading and its safe report; and
|
||||
- detached effective configuration inspection supplied by its public result.
|
||||
|
||||
Narratio owns:
|
||||
|
||||
- pipeline, campaign, session, party, and runtime domain types;
|
||||
- configuration-file discovery and CLI selection;
|
||||
- concrete defaults, normalization, and semantic validation;
|
||||
- campaign/session identity and stable-input resolution;
|
||||
- canonical party parsing requirements and authored-byte retention;
|
||||
- character artifact-family expansion and generated ownership attribution;
|
||||
- root-relative interpretation of Narratio-owned runtime paths;
|
||||
- effective pipeline digests and semantic profile diffs;
|
||||
- command output and exit behavior; and
|
||||
- adapter and runtime initialization after configuration succeeds.
|
||||
|
||||
Narratio must use ConfigKit through its public package. It must not import
|
||||
ConfigKit's private YAML implementation or reproduce ConfigKit's parser,
|
||||
composition, profile, provenance, or secret-bootstrap mechanics locally.
|
||||
|
||||
## Configuration Format
|
||||
|
||||
Narratio adopts ConfigKit's configuration conventions directly. The old Narratio
|
||||
`composition:` envelope, declared profile catalog, arbitrary overlay paths, and
|
||||
`composition.default_profile` are removed without a compatibility parser.
|
||||
|
||||
The root pipeline uses `configkit.imports` declarations. Import paths, optional
|
||||
imports, globs, recursion, ordering, composition conflicts, containment, and limits
|
||||
have the meanings defined by the pinned ConfigKit contract. Narratio does not add a
|
||||
second import syntax or narrower application-specific traversal implementation.
|
||||
|
||||
The unoverlaid root and its imports are the production configuration. A named
|
||||
profile is a ConfigKit overlay at `profiles/<name>.yml`; in particular,
|
||||
`profiles/testing.yml` contains inexpensive-model overrides and testing-only
|
||||
artifacts. Omitting `--profile`, or selecting ConfigKit's `default` profile, uses the
|
||||
production base. `--profile testing` applies the testing overlay. Narratio does not
|
||||
maintain a separate configured default-profile mechanism or validate unselected
|
||||
profile files.
|
||||
|
||||
ConfigKit's rules for explicit false, zero, empty, and null values replace
|
||||
Narratio's custom presence tracking. Application defaults and normalization use a
|
||||
ConfigKit definition and recorded resolution where the final value depends on
|
||||
whether input was present. `NormalizeConfig.UnmarshalYAML` and equivalent
|
||||
parser-coupled application hooks are removed.
|
||||
|
||||
Pipeline, campaign, session, and party inputs use the common ConfigKit loading
|
||||
boundary where its schema model can represent their types. Narratio may perform
|
||||
narrow pre- or post-load domain work, such as retaining authored party bytes,
|
||||
rejecting unresolved session-template placeholders, resolving stable inputs, and
|
||||
expanding artifact families. Such work must not recreate a second generic YAML
|
||||
loader.
|
||||
|
||||
## Artifact Definition Ergonomics
|
||||
|
||||
The canonical v2 configuration layout defines each ordinary Scriptorium artifact
|
||||
in its own file under `artifacts/`. The root pipeline imports the directory through
|
||||
ConfigKit, for example:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
imports:
|
||||
- glob: artifacts/*.yml
|
||||
```
|
||||
|
||||
Each file contributes one ordinary entry at
|
||||
`scriptorium.artifacts.<artifact_key>`. The map key is the authoritative Narratio
|
||||
artifact key and therefore makes the artifact's internal identity explicit in the
|
||||
definition itself:
|
||||
|
||||
```yaml
|
||||
# artifacts/session_recap.yml
|
||||
scriptorium:
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
```
|
||||
|
||||
The canonical reusable source ID is derived without another configured identifier:
|
||||
`session_recap` becomes `narratio.artifact.session_recap`. The recommended filename
|
||||
is `<artifact_key>.yml`, but the filename is organizational rather than an identity
|
||||
source. Renaming or moving a file without changing its map key does not change the
|
||||
artifact contract. ConfigKit additive composition rejects duplicate artifact keys.
|
||||
|
||||
The one-artifact-per-file layout is the documented happy path and is used by
|
||||
maintained examples. Narratio does not prohibit an inline artifact or another
|
||||
ConfigKit-compatible source organization merely because it differs from that
|
||||
layout. Artifact identity always comes from the effective map key, never implicitly
|
||||
from a filename or import position.
|
||||
|
||||
Artifact families use the corresponding `artifact-families/*.yml` convention, with
|
||||
one `scriptorium.artifact_families.<family_key>` entry per file. The family key is
|
||||
explicit. Concrete member keys and source IDs continue to use Narratio's existing
|
||||
deterministic family/member naming rule after canonical party resolution.
|
||||
|
||||
Narratio adds a read-only `narratio config artifacts` command using the ordinary
|
||||
pipeline, profile, and campaign selectors. It reports every effective ordinary
|
||||
artifact's key, canonical `narratio.artifact.<key>` source ID, defining source,
|
||||
enabled state, output path, and effective dependencies. Generated family members
|
||||
also report their family origin. Output ordering is deterministic by artifact key,
|
||||
and the command does not require a session or initialize runtime adapters.
|
||||
|
||||
A configured-artifact input creates an implicit dependency on the referenced
|
||||
artifact. For example:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session_recap
|
||||
```
|
||||
|
||||
The effective dependency graph is the union of explicit `depends_on` entries and
|
||||
dependencies inferred from `narratio.artifact.<key>` inputs. Inferred and explicit
|
||||
duplicates collapse to one dependency. `depends_on` remains available for an
|
||||
ordering requirement when the upstream output is not supplied as an input.
|
||||
Validation applies the same existence, self-reference, and cycle rules to both
|
||||
kinds of dependency, and diagnostics identify the declaration or input that
|
||||
introduced an invalid edge. Operators no longer need to repeat an artifact input in
|
||||
`depends_on` merely to make that input valid.
|
||||
|
||||
## Effective Inspection, Digests, and Profile Diff
|
||||
|
||||
ConfigKit results are the authority for composed input presence, base/profile
|
||||
origins, diagnostics, and the initial typed effective configuration. Narratio's
|
||||
configuration commands adapt those results to existing application-facing command
|
||||
formats rather than exposing ConfigKit implementation types directly.
|
||||
|
||||
Party-dependent artifact families are expanded only after the selected campaign and
|
||||
canonical party are known. That derived state is intentionally outside ConfigKit's
|
||||
completed load snapshot. Narratio retains a narrow application-owned layer that:
|
||||
|
||||
- records generated family/member and publish-rule attribution;
|
||||
- projects the final post-expansion pipeline for operator inspection;
|
||||
- recomputes the effective pipeline digest after expansion; and
|
||||
- ensures stages and adapters receive only the final concrete configuration.
|
||||
|
||||
Narratio continues to own semantic `config diff` behavior because ConfigKit does
|
||||
not provide configuration diffs or fingerprints. Each side of a profile comparison
|
||||
may be loaded independently through ConfigKit. The former guarantee that both sides
|
||||
come from one parsed source snapshot is removed; operators must not rely on a
|
||||
meaningful comparison while concurrently modifying its source files. Equality is
|
||||
still based on normalized effective values rather than YAML formatting or source
|
||||
layout.
|
||||
|
||||
## Credential and Secrets Convention
|
||||
|
||||
Raw API keys and other credential values remain prohibited in pipeline, campaign,
|
||||
session, generated configuration, manifests, diagnostics, examples, and logs.
|
||||
Credential-bearing configuration fields use ConfigKit secret references: YAML
|
||||
contains an environment-variable name, while the resolved value remains available
|
||||
only through ConfigKit's trusted typed boundary. Fields should use credential names
|
||||
rather than legacy names ending in `_env` when the v2 schema is updated.
|
||||
|
||||
The `pipeline.secrets.env_dir` stanza is removed. Narratio selects a secrets
|
||||
directory before parsing the pipeline using this precedence:
|
||||
|
||||
1. an explicitly supplied `--secrets-dir PATH`; otherwise
|
||||
2. the `secrets` subdirectory beside the selected root pipeline file.
|
||||
|
||||
The conventional directory is derived from the resolved root pipeline path, never
|
||||
from the current working directory, an imported file, or a selected profile. A
|
||||
relative explicit CLI path is resolved from the caller's current working directory,
|
||||
consistent with an ordinary command-line path argument.
|
||||
|
||||
If an explicitly selected directory is missing or invalid, the command fails with a
|
||||
clear bootstrap diagnostic. If the conventional directory is absent, Narratio
|
||||
performs no directory bootstrap and continues; pre-existing environment variables
|
||||
may satisfy all references. If the conventional directory exists but ConfigKit
|
||||
rejects or cannot read it, the command fails. Already-set environment variables,
|
||||
including empty values, retain ConfigKit's documented precedence.
|
||||
|
||||
Narratio explicitly calls `configkit.LoadSecretsDir` at most once for a command,
|
||||
before the first ConfigKit load that may resolve secret references and before any
|
||||
concurrent work or runtime client initialization. This application-level convention
|
||||
does not make ConfigKit's ordinary `Load` mutate process-global state. The bootstrap
|
||||
report is rendered or summarized through a safe Narratio command boundary without
|
||||
secret values.
|
||||
|
||||
Narratio adopts ConfigKit's secret-directory rules and limits without restoring its
|
||||
old permission-mode, selected-directory-symlink, invalid-entry, byte-limit, or
|
||||
newline-trimming behavior through compatibility wrappers. Configuration inspection
|
||||
may require the referenced environment variables or the selected secrets directory
|
||||
because an ordinary successful ConfigKit load resolves effective typed secret
|
||||
references. Inspection output may show non-secret reference names where appropriate,
|
||||
but must never expose resolved values.
|
||||
|
||||
## Validation and Diagnostics
|
||||
|
||||
Narratio's semantic rules remain recognizable application code registered with the
|
||||
appropriate ConfigKit definition. Validation continues to reject invalid pipeline,
|
||||
campaign, session, party, adapter, publish, and artifact relationships before
|
||||
runtime work where practical. Validation order and exact prose are not compatibility
|
||||
contracts.
|
||||
|
||||
ConfigKit diagnostic codes, paths, and source locations should be preserved through
|
||||
the application error boundary where useful. Narratio adds command context and exit
|
||||
classification but does not parse ConfigKit error strings or expose raw parser
|
||||
errors. Secret values must remain absent from success and failure diagnostics.
|
||||
|
||||
## Compatibility and Migration
|
||||
|
||||
This work intentionally changes supported platforms, pipeline composition syntax,
|
||||
profile layout and selection, secret configuration, YAML semantics, diagnostics,
|
||||
and some inspection prerequisites. It is released as Narratio v2.0.0.
|
||||
|
||||
There is no runtime compatibility layer for the old `composition` or
|
||||
`pipeline.secrets` syntax. Maintained examples are converted to the ConfigKit
|
||||
format, and the v2 release documentation provides a concise migration guide that
|
||||
covers:
|
||||
|
||||
- converting imports to `configkit.imports` declarations;
|
||||
- folding the production overlay into the base configuration;
|
||||
- placing testing overrides in `profiles/testing.yml`;
|
||||
- moving ordinary artifacts and artifact families into the recommended individual
|
||||
definition files and using their map keys as canonical identities;
|
||||
- removing redundant `depends_on` entries when a configured-artifact input now
|
||||
supplies the same dependency;
|
||||
- moving secret files to the conventional sibling `secrets/` directory or using
|
||||
`--secrets-dir`;
|
||||
- renaming credential-reference fields where applicable;
|
||||
- ConfigKit's stricter YAML and explicit-value semantics;
|
||||
- the independent-load behavior of `config diff`; and
|
||||
- the removal of Windows and macOS amd64 artifacts.
|
||||
|
||||
Obsolete application implementation, tests, documentation, examples, CI paths, and
|
||||
release machinery are removed rather than left deprecated inside the v2 codebase.
|
||||
|
||||
## Required Changes
|
||||
|
||||
- Add and pin the ConfigKit module dependency and establish a small application
|
||||
integration boundary for definitions, result adaptation, and diagnostics.
|
||||
- Replace Narratio's generic YAML parsing, composition, import, profile, presence,
|
||||
and provenance implementation with ConfigKit public APIs.
|
||||
- Express existing defaults, presence-sensitive normalization, and semantic
|
||||
validation through ConfigKit definitions without moving Narratio domain policy
|
||||
into the library.
|
||||
- Adapt pipeline/campaign/session/party loading and preserve the existing
|
||||
application resolution steps that occur after their independent typed loads.
|
||||
- Rework effective configuration, sources, and diff commands around ConfigKit
|
||||
results plus Narratio's post-party generated-attribution layer.
|
||||
- Add the maintained per-artifact and per-family file layouts, the read-only
|
||||
`config artifacts` catalog, and effective dependency inference from configured
|
||||
artifact inputs.
|
||||
- Convert credential configuration to typed references, add the common
|
||||
`--secrets-dir` option, implement conventional-directory selection, and replace
|
||||
the custom API-key directory loader with `configkit.LoadSecretsDir`.
|
||||
- Update adapters to consume resolved credentials or safe environment reference
|
||||
names from typed configuration without independently rediscovering secret values.
|
||||
- Remove Windows and macOS amd64 build targets, release assets, CI entries,
|
||||
platform-specific implementation, and obsolete tests and documentation.
|
||||
- Convert maintained examples and add v2 migration and release documentation.
|
||||
- Retain focused Narratio tests for domain policy and assembled application
|
||||
behavior while deleting parser/composer/bootstrap mechanism tests now owned by
|
||||
ConfigKit. Tests remain deterministic, offline, credential-free, and aligned with
|
||||
the repository testing policy.
|
||||
- Update canonical architecture, configuration, CLI, operations, development,
|
||||
internal, troubleshooting, and release documentation only as the corresponding
|
||||
behavior is implemented.
|
||||
|
||||
## Target End State
|
||||
|
||||
Narratio v2.0.0 has one configuration infrastructure dependency and no duplicate
|
||||
YAML composition subsystem. Operators maintain a production base configuration,
|
||||
optional explicit imports, and fixed named overlays such as `testing`. ConfigKit
|
||||
provides strict parsing, composition, typed loading, provenance, diagnostics, and
|
||||
credential resolution; Narratio supplies domain meaning and its post-campaign
|
||||
artifact expansion.
|
||||
|
||||
Operators can add an artifact by creating one discoverable YAML file whose map key
|
||||
is its stable Narratio identity. `config artifacts` exposes that key, its canonical
|
||||
source ID, origin, output, and effective dependency set. A later artifact can reuse
|
||||
the output through `narratio.artifact.<key>` without repeating the same edge in
|
||||
`depends_on`, while explicit ordering-only dependencies remain supported.
|
||||
|
||||
Secrets normally live in a predictable `secrets/` directory beside the selected
|
||||
pipeline, with `--secrets-dir` available for deployment-specific placement. Raw
|
||||
credential values never enter YAML or inspection output. The bootstrap is explicit
|
||||
in application startup and ordinary ConfigKit loading remains free of process-global
|
||||
mutation.
|
||||
|
||||
Release and CI maintenance covers Linux amd64 and arm64 as the primary platforms
|
||||
and macOS arm64 on a best-effort basis. Windows and macOS amd64 carry no remaining
|
||||
support or release burden. The repository contains no legacy configuration parser,
|
||||
profile compatibility layer, obsolete platform implementation, or redundant tests
|
||||
for behavior owned by ConfigKit.
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- A ConfigKit reference-only inspection or typed prepare/finalize lifecycle may be
|
||||
considered upstream if credential-free inspection becomes a demonstrated shared
|
||||
requirement. Narratio does not implement a private substitute in this migration.
|
||||
- ConfigKit hot reload, remote providers, automatic configuration migration, and
|
||||
application command frameworks remain outside Narratio's scope.
|
||||
- Native macOS arm64 CI may be added later when a reliable runner is available; it
|
||||
is not required for v2.0.0.
|
||||
102
docs/roadmap/feature-request-notarius-config-profile.yml
Normal file
102
docs/roadmap/feature-request-notarius-config-profile.yml
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Select a Notarius configuration profile through Narratio
|
||||
status: proposed
|
||||
consumer: rakestraw.dnd Ansible collection and its pipeline role
|
||||
|
||||
use_case: >-
|
||||
The Ansible pipeline role deploys Narratio and Notarius as independently
|
||||
configured applications. Operators select Narratio's testing profile to
|
||||
develop prompts and other pipeline assets with inexpensive models while
|
||||
retaining production settings in the base configuration. That selection
|
||||
currently cannot select a Notarius configuration profile, leaving extraction
|
||||
on its production settings.
|
||||
|
||||
desired_outcome: >-
|
||||
Narratio's testing overlay can explicitly select Notarius's testing overlay.
|
||||
Notarius owns the resulting model choices and any other testing-specific
|
||||
settings. The operator selects testing once when invoking Narratio, without
|
||||
editing production files or separately invoking Notarius.
|
||||
|
||||
rationale: >-
|
||||
Both applications already use ConfigKit configuration overlays. Selecting
|
||||
the downstream overlay preserves each application's configuration ownership
|
||||
and supports future Notarius testing adjustments without adding a Narratio
|
||||
configuration field for every downstream setting. This is explicit profile
|
||||
composition, not a requirement for arbitrary recursive orchestration.
|
||||
|
||||
requested_interface:
|
||||
field: pipeline.notarius.config_profile
|
||||
value: Optional Notarius ConfigKit configuration profile name
|
||||
supplied: >-
|
||||
Invoke Notarius with --config-profile and the selected name when extraction
|
||||
executes. The setting is available in Narratio's base configuration and
|
||||
its ordinary configuration overlays.
|
||||
omitted_or_null: >-
|
||||
Do not supply a profile-selection flag. Preserve existing Notarius
|
||||
configuration selection behavior.
|
||||
downstream_semantics: >-
|
||||
Retain Notarius's existing selector semantics, including the default
|
||||
selector for its base configuration. Notarius resolves profiles relative
|
||||
to its own configuration root, not Narratio's root.
|
||||
explicit_selection: >-
|
||||
Do not automatically forward Narratio's profile name. The selected
|
||||
Notarius profile may have the same name or a different name.
|
||||
distinction: >-
|
||||
This selects a ConfigKit configuration overlay through --config-profile,
|
||||
not a PromptKit LLM profile through --llm-profile. The Notarius overlay
|
||||
can itself select the desired LLM profiles using native Notarius settings.
|
||||
|
||||
illustrative_consumer_configuration:
|
||||
note: >-
|
||||
These are separate files managed by the Ansible suite, not a proposed
|
||||
combined configuration document. The Notarius pipeline and LLM profile
|
||||
names are illustrative; the referenced LLM profile must be available to
|
||||
Notarius.
|
||||
narratio_profiles_testing_yml: |
|
||||
notarius:
|
||||
config_profile: testing
|
||||
notarius_profiles_testing_yml: |
|
||||
pipelines:
|
||||
dnd-session:
|
||||
llm_profile: gemini-flash-latest
|
||||
operator_workflow: >-
|
||||
Select --profile testing on the ordinary Narratio invocation. Narratio
|
||||
selects Notarius's testing configuration overlay when it runs extraction;
|
||||
other Narratio testing overrides continue to apply normally.
|
||||
|
||||
observable_behavior:
|
||||
- >-
|
||||
Notarius owns overlay discovery, composition, validation, and the effective
|
||||
downstream settings. A missing or invalid selected overlay must not silently
|
||||
fall back to production or be reported as successful extraction.
|
||||
- >-
|
||||
Changing the selected Notarius configuration profile must not silently
|
||||
reuse Narratio's completed extraction from the previous selection.
|
||||
Existing downstream invalidation behavior applies when extraction changes.
|
||||
- >-
|
||||
Editing Notarius overlay contents behind an unchanged selector retains
|
||||
the existing operator responsibility to force extraction when needed.
|
||||
This request does not require inspecting or tracking private Notarius files.
|
||||
- >-
|
||||
Existing credential forwarding remains independently configurable through
|
||||
notarius.llm_api_key, including in the Narratio testing overlay when the
|
||||
selected Notarius settings require a different credential reference.
|
||||
|
||||
consumer_follow_up: >-
|
||||
After Narratio releases this interface, the standalone Narratio Ansible role
|
||||
can expose an optional variable and omit the field when unset. The suite role
|
||||
can deploy Notarius's testing overlay and select it from Narratio's testing
|
||||
overlay, then distribute the changes through the collection. These consumer
|
||||
updates are separate from this upstream feature request.
|
||||
|
||||
scope_boundaries:
|
||||
- No Notarius change is currently required; v0.7.0 supports --config-profile.
|
||||
- No separate Narratio-side Notarius LLM-profile selector is requested.
|
||||
- No automatic profile-name propagation or downstream configuration rewriting.
|
||||
- No new credential discovery, copying, or provider-name remapping.
|
||||
- This request specifies consumer behavior, not internal Narratio code changes.
|
||||
|
||||
references:
|
||||
narratio_configuration: ../config.md
|
||||
narratio_notarius_integration: ../integrations/notarius.md
|
||||
notarius_cli: https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.7.0/docs/cli.md
|
||||
171
docs/roadmap/fix-publish-output-destination-resolution.md
Normal file
171
docs/roadmap/fix-publish-output-destination-resolution.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Bugfix Request: Preserve Publish Destination Derivation with Explicit Rules
|
||||
|
||||
Status: Implemented; release target v2.1.1
|
||||
|
||||
## Summary
|
||||
|
||||
Narratio should successfully derive a publish destination when a valid
|
||||
`publish.outputs` rule omits `dest`, regardless of whether that rule explicitly
|
||||
sets `required`. Narratio v2.1.0 currently rejects this valid configuration in
|
||||
some cases with a ConfigKit `resolve.unrecorded_change` diagnostic.
|
||||
|
||||
This is a configuration-resolution bookkeeping defect, not a YAML formatting
|
||||
restriction. The fix should preserve the existing public configuration
|
||||
contract: operators may omit a derivable destination and may independently set
|
||||
`required` to either `true` or `false`.
|
||||
|
||||
## Observed Behavior
|
||||
|
||||
A pipeline containing a rule equivalent to the following fails during native
|
||||
configuration loading:
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
outputs: [{"source": "narratio.transcript.final", "required": true}]
|
||||
```
|
||||
|
||||
Narratio reports:
|
||||
|
||||
```text
|
||||
error [resolve.unrecorded_change] /publish/outputs: application resolution changed a value without a covering record
|
||||
```
|
||||
|
||||
The same failure is possible with ordinary block-style YAML when `required` is
|
||||
explicit and `dest` is omitted. Flow-style YAML is valid input and is not the
|
||||
cause of the defect.
|
||||
|
||||
The existing destination-derivation tests do not expose this case because
|
||||
their rules also omit `required`. Narratio then supplies the default value for
|
||||
`required` and records a change covering the complete `publish.outputs`
|
||||
sequence. That broader record happens to cover the destination mutation as
|
||||
well, masking the missing resolution record that occurs when `required` is
|
||||
already explicit.
|
||||
|
||||
## Expected Contract
|
||||
|
||||
The current configuration contract remains unchanged:
|
||||
|
||||
- `publish.outputs[].source` is required;
|
||||
- `publish.outputs[].dest` may be omitted when Narratio can derive it from the
|
||||
selected built-in or configured artifact source;
|
||||
- `publish.outputs[].required` defaults to `true`, but an operator may set it
|
||||
explicitly to either `true` or `false`; and
|
||||
- equivalent valid YAML representations have equivalent configuration
|
||||
semantics.
|
||||
|
||||
For example, both rules below should load and validate, retain their explicit
|
||||
requiredness, and receive the canonical derived destination:
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
outputs:
|
||||
- source: narratio.transcript.final
|
||||
required: true
|
||||
- source: narratio.transcript.final_markdown
|
||||
required: false
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
ConfigKit intentionally treats sequences as atomic when comparing values
|
||||
before and after application resolution. Changing a field within one
|
||||
`publish.outputs` entry is therefore observed as a change to the
|
||||
`/publish/outputs` sequence.
|
||||
|
||||
Narratio currently derives each missing destination by recording a
|
||||
normalization at the individual `publish.outputs[index].dest` path. That child
|
||||
record does not cover the atomic parent-sequence change. A separate
|
||||
container-level record is currently created only when Narratio also defaults
|
||||
an omitted `required` value, so configurations with explicit `required` values
|
||||
lose that incidental coverage.
|
||||
|
||||
## Recommended Fix
|
||||
|
||||
Make Narratio's publish-output resolution bookkeeping conform to ConfigKit's
|
||||
atomic-sequence contract. Whenever resolution derives one or more destinations
|
||||
inside `publish.outputs`, the recorded resolution changes must cover the
|
||||
`/publish/outputs` container. Destination derivation must not depend on an
|
||||
unrelated `required` default being applied in the same list.
|
||||
|
||||
The implementation may retain more specific provenance where useful, but it
|
||||
must provide valid container-level coverage for the sequence mutation. Do not
|
||||
change ConfigKit's sequence comparison semantics merely to accommodate this
|
||||
call site.
|
||||
|
||||
## Scope
|
||||
|
||||
- Correct destination derivation for publish rules that explicitly set
|
||||
`required` and omit a derivable `dest`.
|
||||
- Preserve explicit `required: true` and `required: false` values.
|
||||
- Preserve canonical destination derivation for built-in transcript sources
|
||||
and configured artifact sources.
|
||||
- Add focused regression coverage through Narratio's real configuration load
|
||||
and validation boundary.
|
||||
- Prepare a backward-compatible patch release after the fix passes the normal
|
||||
repository validation.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing the `publish.outputs` schema, defaults, source vocabulary, or
|
||||
destination derivation rules.
|
||||
- Requiring operators or configuration generators to supply `dest` when it is
|
||||
derivable.
|
||||
- Requiring operators to omit explicit `required: true` values.
|
||||
- Treating flow-style YAML or JSON-compatible YAML values as unsupported.
|
||||
- Changing ConfigKit's documented atomic treatment of sequences.
|
||||
- Adding storage, publication, or network integration behavior.
|
||||
|
||||
## Regression Coverage
|
||||
|
||||
Add one focused package-level behavioral test that loads and validates a
|
||||
pipeline with omitted destinations and explicit requiredness. A representative
|
||||
list should include both `required: true` and `required: false`, then assert
|
||||
that:
|
||||
|
||||
- loading and validation succeed;
|
||||
- each destination is derived from its source; and
|
||||
- each explicit `required` value is preserved.
|
||||
|
||||
Using the compact flow-style representation that exposed the integration
|
||||
failure is reasonable, while the existing block-style cases continue to cover
|
||||
omitted `required`. The test should exercise the public configuration behavior,
|
||||
not ConfigKit call choreography or Narratio's private recording mechanism.
|
||||
|
||||
No network service, object store, credential, or test double is required.
|
||||
|
||||
## Target End State
|
||||
|
||||
All valid publish rules with derivable sources behave consistently across
|
||||
these cases:
|
||||
|
||||
| `dest` | `required` | Result |
|
||||
| --- | --- | --- |
|
||||
| omitted | omitted | destination derived; required defaults to `true` |
|
||||
| omitted | `true` | destination derived; explicit `true` retained |
|
||||
| omitted | `false` | destination derived; explicit `false` retained |
|
||||
| explicit | either value | explicit destination and requiredness retained |
|
||||
|
||||
`narratio config validate`, `narratio config show`, and ordinary pipeline
|
||||
loading accept the corrected cases without an unrecorded-change diagnostic.
|
||||
No operator configuration migration or workaround is required.
|
||||
|
||||
## Release
|
||||
|
||||
This is a backward-compatible correction to documented configuration behavior
|
||||
and should be released as Narratio `v2.1.1`. The release note should identify
|
||||
the corrected explicit-required/derived-destination interaction and state that
|
||||
no special upgrade action is required.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- The focused regression test fails against v2.1.0 behavior and passes after
|
||||
the fix.
|
||||
- Publish rules with explicit `required: true` or `required: false` and omitted
|
||||
derivable destinations load and validate successfully.
|
||||
- Derived destinations and explicit requiredness appear correctly in the safe
|
||||
effective configuration.
|
||||
- Existing destination derivation, publish validation, imports, profiles, and
|
||||
configuration inspection tests remain green.
|
||||
- The complete repository validation and release-candidate checks pass.
|
||||
- The correction is published as the immutable `v2.1.1` tag with the required
|
||||
release note.
|
||||
@@ -1,522 +1,431 @@
|
||||
# ConfigKit Adoption Implementation Plan
|
||||
|
||||
Status: Complete
|
||||
|
||||
Implement the [ConfigKit adoption roadmap](configkit-adoption.md) in the order
|
||||
below. The target is Narratio v2.0.0: ConfigKit v0.1.1, the reduced platform
|
||||
matrix, and the artifact-authoring workflow described there. Each stage is a
|
||||
bounded implementation assignment for a gpt-5.6-terra coding agent. Mark a stage
|
||||
complete only after its changes and acceptance checks succeed; record a short
|
||||
completion and verification note beside its status.
|
||||
|
||||
## Instructions for every stage
|
||||
|
||||
- Read `docs/development.md` and all three policies under `docs/policy/`.
|
||||
Read the relevant roadmap section and the stage-specific sources listed below.
|
||||
Paths identify starting points, not an exhaustive edit allowlist; inspect callers
|
||||
and tests before changing a package boundary.
|
||||
- Use the tagged ConfigKit public API. The local `../ConfigKit` checkout is useful
|
||||
reference material, but verify its version before relying on it. The module
|
||||
cache for v0.1.1 is an alternative. Do not require a sibling checkout, a local
|
||||
`replace`, a Go workspace, an upstream patch, or an unpinned revision to build.
|
||||
- Keep Narratio domain policy in `internal/config`, startup and selectors in
|
||||
`internal/app`, and credential delivery in the owning adapters. Do not build
|
||||
a general configuration framework around ConfigKit.
|
||||
- Keep each stage buildable and run focused behavioral tests for its changed
|
||||
boundary. Temporary migration helpers may exist between stages only where
|
||||
named here; they must not introduce runtime format auto-detection or fallback.
|
||||
- Update each canonical current-behavior document when its behavior changes.
|
||||
The roadmap remains the owner of unimplemented work. Preserve historical
|
||||
release notes; their descriptions of old platforms and formats are history.
|
||||
- Tests use real ConfigKit and temporary files, with fake external adapters or
|
||||
storage where needed. Use synthetic credentials and isolate environment
|
||||
mutation; such tests must not run in parallel. Retain domain and security
|
||||
coverage, remove superseded mechanism tests, and avoid exact diagnostic prose
|
||||
or private callback choreography assertions.
|
||||
- Do not tag or publish v2.0.0 as part of this plan. Release preparation belongs
|
||||
here; release authorization and tag publication are a later task. The existing
|
||||
release completion boundary remains successful upstream tag push, with no CI
|
||||
polling or waiting.
|
||||
|
||||
## Decisions that apply across stages
|
||||
|
||||
The roadmap is authoritative for product scope. These implementation choices
|
||||
make that scope concrete:
|
||||
|
||||
- Enable ConfigKit imports and profile selection for pipeline files only.
|
||||
Campaign, session, and party are single-source definitions with imports and
|
||||
profiles disabled. Preserve their domain schemas and source-relative inputs.
|
||||
Omitted or empty profile and the exact name `default` select the base; other
|
||||
names use ConfigKit's fixed `profiles/<name>.yml` convention.
|
||||
- Defaults apply to absent fields. Explicit empty/zero/false/null values follow
|
||||
ConfigKit decoding and then domain validation; do not refill invalid explicit
|
||||
values with legacy defaults. Optional null stage sections remain absent;
|
||||
required sections fail domain validation rather than being resurrected.
|
||||
- Keep ConfigKit metadata/result snapshots separate from runtime-only Narratio
|
||||
state. ConfigKit discards ignored and unexported fields when detaching values;
|
||||
attach root paths, retained party bytes, family catalogs, and digest metadata
|
||||
after loading. No validator may mutate bound configuration.
|
||||
- Use one Narratio-owned safe effective projection for the final expanded
|
||||
pipeline. It contains configuration values and credential reference names,
|
||||
never resolved credential bytes. It feeds show, semantic diff, digest, and
|
||||
catalog data. ConfigKit metadata remains the authority for input provenance;
|
||||
Narratio adds only domain-generated attribution.
|
||||
- Rename `storage.s3.access_key_id_env` to `access_key_id`,
|
||||
`storage.s3.secret_access_key_env` to `secret_access_key`, and
|
||||
`audita.llm_api_key_env` to `llm_api_key`. Each is an optional `*configkit.Secret`.
|
||||
An explicit reference always resolves during loading, even if the associated
|
||||
stage will not execute. Require both S3 fields together or neither. With neither,
|
||||
retain the AWS SDK default credential chain; do not synthesize mandatory secret
|
||||
references that would break role-based authentication or local-only inspection.
|
||||
With no Audita reference, preserve its existing downstream credential discovery.
|
||||
Explicit references replace Narratio's old per-adapter environment lookup.
|
||||
- Keep absent S3 credentials out of defaults; migration examples explicitly name
|
||||
the existing AWS variables when static credentials are intended. A non-nil
|
||||
missing/empty reference fails instead of silently falling back. This preserves
|
||||
provider-managed credentials while making explicit configuration trustworthy.
|
||||
- `config artifacts` uses the existing configuration command selectors and text
|
||||
output convention. No new JSON mode, artifact creation wizard, inferred filename
|
||||
identity, or profile catalog is added.
|
||||
- Preserve the roadmap's unique base artifact definition rule explicitly:
|
||||
ConfigKit rejects conflicting leaves but can compose disjoint children of the
|
||||
same mapping. Use its public source metadata to reject an ordinary artifact key
|
||||
declared by multiple base files, even if their fields are disjoint. Apply the
|
||||
same rule to family keys. A selected profile may override or extend a definition.
|
||||
This is a narrow Narratio declaration rule, not a different merge algorithm.
|
||||
- Relax only the obsolete configuration/secrets filesystem restrictions identified
|
||||
by the roadmap. Do not remove session restore, cleanup, publication, workspace
|
||||
confinement, or ordinary shared-workspace mode guarantees as collateral work.
|
||||
|
||||
## Stage 1 — Reduce the platform and release matrix
|
||||
|
||||
Status: Complete — Reduced CI and release output to Linux amd64/arm64 and macOS
|
||||
arm64, removed Windows-only runtime implementations and accommodations, and
|
||||
verified focused tests, all three cross-builds, exact asset names, and embedded
|
||||
version reporting.
|
||||
|
||||
Read: roadmap “Platform Contract”; `scripts/build-release-assets.sh`,
|
||||
`scripts/check-release-candidate.sh`, `.woodpecker/`, `internal/releasecheck`,
|
||||
`docs/release.md`, and platform-specific files in application, file operations,
|
||||
and subprocess packages.
|
||||
|
||||
1. Make Linux amd64, Linux arm64, and macOS arm64 the only maintained build and
|
||||
release targets. Remove Windows suffix/packaging branches and macOS amd64
|
||||
targets from scripts, workflows, tests, and current documentation.
|
||||
2. Delete Windows-only implementations and tests; simplify paired build-tag
|
||||
files where the remaining implementations can be shared. Preserve portable
|
||||
code when retaining it adds no special support machinery. Preserve functioning
|
||||
Unix subprocess cancellation, locking, and file operations on both supported OSes.
|
||||
3. Retain the local Linux verification gates and cross-build gates for all three
|
||||
published assets. Do not introduce a native macOS runner requirement or claim
|
||||
that cross-compilation verifies runtime behavior.
|
||||
4. Update architecture/operations platform statements and contributor/release
|
||||
guidance. Keep existing release protections and the no-CI-wait boundary intact.
|
||||
|
||||
Acceptance: focused platform/subprocess/fileops/releasecheck tests pass; build all
|
||||
three targets with `CGO_ENABLED=0`; verify the asset builder produces exactly the
|
||||
three expected versioned binaries and the host binary reports the supplied version.
|
||||
Existing release tests still prove guarded publication. No current support promise
|
||||
or asset list retains Windows or macOS amd64.
|
||||
|
||||
## Stage 2 — Separate configuration normalization from validation
|
||||
|
||||
Status: Complete — Added explicit deterministic pipeline normalization before
|
||||
validation, made Notarius validation read-only on accepted and rejected inputs,
|
||||
documented the boundary, and verified config race tests plus app/stage consumers.
|
||||
|
||||
Read: `internal/config/load.go`, `defaults.go`, `normalize.go`, `validate*.go`,
|
||||
`artifact_families.go`, and ConfigKit's “Recorded application resolution”,
|
||||
“Semantic validation”, and “Defaults and typed decoding” contracts.
|
||||
|
||||
1. Separate value-producing normalization from read-only validation, including
|
||||
Notarius reference/output normalization currently performed by
|
||||
`validateNotarius`. Audit all validators for map, slice, pointer, and scalar
|
||||
mutations, including changes that would occur only on error paths.
|
||||
2. Keep reusable domain normalization ordinary package code. Validation checks the
|
||||
already normalized candidate and must work for both loaded and programmatically
|
||||
constructed values without silently fixing them.
|
||||
3. Inventory defaults in `load.go`: static defaults, per-map-entry/list-element
|
||||
defaults, and inherited or presence-sensitive defaults. Retain named constants
|
||||
in `defaults.go`. This inventory should guide code and concise comments, not
|
||||
become a permanent parallel defaults document.
|
||||
4. Keep the current loader calling normalization explicitly before validation
|
||||
during this preparatory stage. The ConfigKit resolver replaces that loading
|
||||
choreography in Stage 4. Preserve family expansion as an explicit domain step.
|
||||
|
||||
Acceptance: focused configuration tests prove normalization is deterministic and
|
||||
validation does not mutate accepted or rejected inputs. Cover representative
|
||||
Notarius normalization/collision handling and existing artifact/publish rules.
|
||||
Do not add a separate mutation test for every trivial validator.
|
||||
|
||||
## Stage 3 — Replace composition-dependent effective projection
|
||||
|
||||
Status: Complete — Introduced one typed safe projection for effective YAML,
|
||||
semantic values, source paths, and the versioned v3 digest; removed composition
|
||||
parsing from those consumers and verified ordering, expansion, relocation,
|
||||
nil/empty, exact-integer, CLI diff, and semantic-resume behavior.
|
||||
|
||||
Read: `internal/config/effective_pipeline.go`, `effective_digest.go`,
|
||||
`pipeline_composition.go`, `internal/app/config_commands.go`, the config-diff
|
||||
implementation, and `docs/internal/configuration.md`.
|
||||
|
||||
1. Introduce one package-owned, schema-specific safe projection of the effective
|
||||
pipeline. Use explicit typed projections with stable configuration field names;
|
||||
preserve null versus empty containers and exact integers. Ordinary nested
|
||||
configuration types may be reused where they contain no runtime state.
|
||||
Credential fields must be projected explicitly as reference names when Stage 7
|
||||
changes their types. Do not introduce a generic reflection schema engine.
|
||||
2. Render this projection as deterministic YAML using the existing YAML encoder;
|
||||
encoding output is not a replacement YAML loader. Produce semantic records
|
||||
directly from the safe projection using standard JSON encoding/decoding with
|
||||
number preservation if a plain value tree is useful. Do not reparse YAML or
|
||||
reconstruct `compositionNode` trees to compare or hash effective values.
|
||||
3. Make show, diff values, and digest use that common projection. Mapping order is
|
||||
immaterial; sequences remain atomic for diff; nil and empty remain distinct.
|
||||
Exclude family declarations once expanded, all runtime metadata, profile names,
|
||||
source locations, and raw credentials. Preserve logical Notarius path handling
|
||||
so moving an equivalent bundle does not change its digest.
|
||||
4. Version the changed digest encoding explicitly using the package's digest
|
||||
version constant. Digest/profile metadata remains provenance, never a blanket
|
||||
resume cache key. Keep stage/artifact semantic evidence behavior intact.
|
||||
5. Decouple enumeration of effective field paths from the legacy composition
|
||||
renderer. Input ownership can still come from the current loader until Stage 4.
|
||||
|
||||
Acceptance: show/diff/digest tests cover equivalent monolithic/split values,
|
||||
different map insertion order, null/empty distinction, family-expanded outputs,
|
||||
and bundle relocation. A meaningful artifact change changes the digest; formatting
|
||||
and profile naming alone do not. Existing semantic resume tests pass. No effective
|
||||
projection consumer needs the custom composition parser/renderer.
|
||||
|
||||
## Stage 4 — Cut pipeline loading over to ConfigKit
|
||||
|
||||
Status: Complete — Replaced pipeline composition with ConfigKit v0.1.1 imports,
|
||||
fixed-path profile selection, fresh typed defaults, recorded resolution, source
|
||||
metadata adaptation, and duplicate base artifact/family enforcement; removed the
|
||||
legacy parser and verified all tests plus the configuration race suite.
|
||||
|
||||
Read: ConfigKit v0.1.1 `docs/config.md`, `docs/consumers/README.md`, and its
|
||||
Narratio-shaped compiled example; Narratio `internal/config/load.go`,
|
||||
`composition.go`, `pipeline_composition.go`, and profile/composition tests.
|
||||
|
||||
1. Pin `gitea.maximumdirect.net/eric/ConfigKit v0.1.1` in `go.mod`/`go.sum`.
|
||||
Implement a small pipeline definition using `Load`, `File`, enabled imports,
|
||||
`Options.Profile`, defaults, recorded resolution, and read-only validation.
|
||||
2. Migrate static defaults to fresh default values. Apply map-entry, list-element,
|
||||
and inherited defaults with `Resolution.Default`/`IsExplicit`; use recorded
|
||||
normalization for transformations from Stage 2. Do not call old post-decode
|
||||
zero-filling defaults. Remove `NormalizeConfig.UnmarshalYAML` and its private
|
||||
presence bit. Include publication list-entry defaults, optional section
|
||||
allocation, and trim's Scriptorium requirement in this conversion.
|
||||
3. Perform file-local semantic checks in the definition. Defer checks needing
|
||||
canonical party expansion or campaign/session context to the existing explicit
|
||||
resolution boundary; do not reject references to future concrete family members
|
||||
merely because the campaign has not loaded yet. Run full checks after expansion.
|
||||
4. Replace pipeline composition and profile loaders. `configkit.imports` is the
|
||||
only directive; no `composition` or `_configkit` alias is accepted. Pass profile
|
||||
selection directly under the agreed convention; unselected overlays are not read.
|
||||
`LoadPipelineProfilePair` may remain a thin pair of independent loads, with no
|
||||
shared parse cache or paired source-snapshot promise.
|
||||
5. Adapt ConfigKit sources and metadata to Narratio's existing reporting records.
|
||||
Translate paths structurally using public path APIs, not error-message parsing.
|
||||
Preserve multiple contributing locations for composite/profile-owned mappings
|
||||
and distinguish defaults/resolution from authored values.
|
||||
Check artifact/family map-entry ownership for duplicate base declarations using
|
||||
those locations; ignore synthetic defaults and allow the selected overlay.
|
||||
6. Attach runtime metadata only after load succeeds. Preserve root-relative runtime
|
||||
path interpretation and capture logical paths before making them absolute.
|
||||
Retain family declarations and original ConfigKit metadata for later expansion.
|
||||
7. Replace generic mechanism tests with a small integration set proving actual
|
||||
Narratio wiring. Convert fixtures affected by this stage. Delete
|
||||
`composition.go`, obsolete pipeline traversal/overlay/presence machinery, and
|
||||
their redundant tests once the callers are migrated.
|
||||
|
||||
Acceptance: a real root with imports and a testing overlay loads; duplicate claims
|
||||
fail with useful source attribution, including two base files contributing disjoint
|
||||
fields to one artifact or family key; base/default selection works; malformed
|
||||
unselected profiles do not fail base loading. Omitted defaults and representative
|
||||
explicit false/zero/empty/null inputs obey the new semantics. Invalid domain values
|
||||
still fail. Commands use the new pipeline loader with no format fallback. Update
|
||||
`docs/config.md` and configuration internals for the implemented boundary.
|
||||
|
||||
## Stage 5 — Migrate campaign, session, and canonical party loading
|
||||
|
||||
Status: Complete — Migrated campaign, session, and canonical-party documents to
|
||||
independent ConfigKit definitions, preserved bounded session inspection, exact
|
||||
party bytes and metadata-derived authored order, and retained identity, source,
|
||||
family-expansion, and validation behavior; verified all tests and focused races.
|
||||
|
||||
Read: `internal/config/load.go`, `party.go`, `party_resolution.go`,
|
||||
`artifact_families.go`, session-template tests, `internal/app/config_loader.go`,
|
||||
and ConfigKit source/schema/version contracts.
|
||||
|
||||
1. Use separate ConfigKit definitions for campaign, session, and party. Replace
|
||||
`decodeStrictYAML*` and the custom party YAML-node parser. Model the party input
|
||||
as the existing string schema version and character-keyed typed map, with the
|
||||
existing `alias` list, ordered class list, and optional positive class level.
|
||||
2. Keep party domain checks: valid IDs, required display names and classes,
|
||||
trimmed/control-free names, case-insensitive name/alias ambiguity, and duplicate
|
||||
class checks. Validate the required version through `VersionRule`. Keep an
|
||||
exact independent copy of authored bytes outside ConfigKit's schema object.
|
||||
3. Preserve authored character order using successful metadata source coordinates
|
||||
for the single party input when constructing `CanonicalParty.Characters`.
|
||||
Preserve class/alias sequence order naturally. Family expansion and players
|
||||
projection continue using their existing deterministic ID sorting. Do not keep
|
||||
a second YAML parse just to recover mapping order.
|
||||
4. Preserve the narrow session placeholder rejection before typed load, expected
|
||||
session/previous-session identity checks, campaign selection, stable-input
|
||||
precedence, and local versus remotely downloaded session provenance.
|
||||
Retain the initially loaded pipeline/campaign during remote session acquisition.
|
||||
5. Where bytes must be retained or inspected before `ConfigKit.Bytes`, bound the
|
||||
initial read too, using the existing fileops bounded-read helper and a named
|
||||
package constant of 8 MiB matching the pinned source limit. Otherwise prefer
|
||||
`ConfigKit.File`. Do not read an unbounded file and rely on later rejection.
|
||||
6. Complete post-party family and publish-rule expansion, then full cross-config
|
||||
validation and digest refresh. Keep generated origins outside the library
|
||||
snapshot. Remove unused decoder plumbing and obsolete party parsing tests.
|
||||
|
||||
Acceptance: retained authored party bytes are identical; domain errors, aliases,
|
||||
class order, family origins, and derived players are correct. Local and remote
|
||||
session loads preserve identity, input path bases, and the original pipeline.
|
||||
Unknown fields and disabled directives fail through ConfigKit. Focused config,
|
||||
prepare/extract, and application loading tests pass.
|
||||
|
||||
## Stage 6 — Move secrets bootstrap before all configuration loads
|
||||
|
||||
Status: Complete — Added invocation-scoped ConfigKit secrets bootstrap before
|
||||
all pipeline loads and adapter construction, exposed consistent explicit and
|
||||
conventional directory selection across the CLI, removed the retired pipeline
|
||||
schema and custom loader, and verified focused, full, and race test suites.
|
||||
|
||||
Read: roadmap “Credential and Secrets Convention”; ConfigKit's bootstrap
|
||||
contract; `internal/app/secrets_env*.go`, `config_loader.go`, `config_commands.go`,
|
||||
`object_store.go`, command dependencies, and command argument owners.
|
||||
|
||||
1. Add a shared `--secrets-dir` selector to every CLI path that loads a pipeline,
|
||||
including configuration inspection/diff, execution aliases, planning, publish,
|
||||
and session/storage operations. Route all through one command-owned bootstrap
|
||||
context. Help/version and commands that do not load configuration do not bootstrap.
|
||||
2. Discover the root pipeline first. An explicit relative directory is based on
|
||||
caller CWD; otherwise choose `secrets/` beside the resolved root pipeline.
|
||||
An explicitly empty flag is invalid. Missing conventional directories are
|
||||
skipped; missing explicit directories and unreadable/invalid existing directories
|
||||
fail. Use ConfigKit's selected-directory symlink behavior without custom rejection.
|
||||
3. Call `LoadSecretsDir` at most once per command before any ConfigKit load or
|
||||
adapter creation, including both loads of config diff and remote session lookup.
|
||||
Use invocation state, not a package-global `sync.Once`. A new command gets its
|
||||
own bootstrap; library config loaders themselves never mutate the environment.
|
||||
4. Present safe bootstrap warnings/failures through stderr or the existing safe
|
||||
diagnostic boundary, never into YAML show output. Preserve the partial report
|
||||
on bootstrap failure. Do not add rollback or reinterpret ConfigKit file rules.
|
||||
5. Remove late bootstrap calls in object-store and runner composition, the custom
|
||||
directory reader and permission platform files, `pipeline.secrets`, its defaults
|
||||
and validators, and their obsolete tests. Convert affected fixtures immediately.
|
||||
|
||||
Acceptance: test absent conventional directory, explicit override from another
|
||||
CWD, invalid explicit directory, already-set empty/nonempty environment values,
|
||||
and bootstrap failure before adapter construction. A diff invocation and a remote
|
||||
session acquisition each bootstrap once. Config inspection requires no session,
|
||||
workspace writes, manifests, or runtime clients. Update CLI, config, operations,
|
||||
architecture, and troubleshooting to the new bootstrap/permission contract.
|
||||
|
||||
## Stage 7 — Resolve typed credentials and protect all output boundaries
|
||||
|
||||
Status: Complete — Migrated S3 and Audita credentials to optional ConfigKit
|
||||
typed references, passed resolved values directly to runtime adapters, preserved
|
||||
the AWS default provider chain when unconfigured, and verified reference-only
|
||||
inspection, provenance, generated configuration, manifest, and diagnostic
|
||||
boundaries with focused, full, and race test suites.
|
||||
|
||||
Read: ConfigKit “Typed secret references” and rendering contracts;
|
||||
`internal/config/config.go`, `defaults.go`, `validate_adapters.go`, the safe
|
||||
projection from Stage 3, `internal/adapters/storage/s3_backend.go`,
|
||||
`internal/adapters/audita/subprocess.go`, and their composition callers.
|
||||
|
||||
1. Implement the three renamed optional typed fields and S3 pair policy above.
|
||||
Reject old `_env` schema keys; remove their default-name and lookup helpers.
|
||||
Convert maintained fixtures with synthetic environment setup where references
|
||||
are present. Do not add credentials to unrelated local pipeline fixtures.
|
||||
2. Pass resolved S3 values to the SDK adapter's explicit static provider only when
|
||||
the pair is configured. With neither, preserve its default provider chain.
|
||||
Pass Audita the resolved value and safe reference name; deliver the value only
|
||||
through the child environment's existing `AUDITA_LLM_API_KEY` override.
|
||||
Do not re-read the environment for an explicitly resolved reference.
|
||||
3. Adapt every serialization, digest, source/diff output, diagnostic, generated
|
||||
invocation config, manifest, and provenance caller to safe reference names.
|
||||
Never YAML-marshal ConfigKit's opaque secret directly or call `Value()` in an
|
||||
inspection path. Secret changes alone must not alter effective digests or
|
||||
invalidate artifact evidence; reference-name changes may change provenance.
|
||||
4. Keep ConfigKit errors structured and safe. Domain validation messages must
|
||||
avoid raw credential values; do not unwrap private parser errors or interpolate
|
||||
whole trusted configuration structs into error messages.
|
||||
5. Preserve downstream tool ownership for Notarius/Scriptorium credentials that
|
||||
Narratio does not configure. Do not add a parallel credential schema for them.
|
||||
|
||||
Acceptance: use distinctive synthetic secrets to prove no leakage in show, sources,
|
||||
diff, catalog-ready projections, success/failure diagnostics, manifests, or generated
|
||||
Audita configuration. Verify the child receives the credential and an environment
|
||||
change after loading does not change explicit delivery. Test S3 paired references,
|
||||
partial-pair rejection, missing effective references, and nil SDK fallback offline.
|
||||
Update adapter and credential documentation and remove obsolete lookup tests.
|
||||
|
||||
## Stage 8 — Infer and normalize artifact dependencies once
|
||||
|
||||
Status: Complete — Finalized one normalized artifact graph from explicit ordering
|
||||
edges and configured-artifact inputs (including rewritten family members), retained
|
||||
edge origins for diagnostics, and verified focused/full tests plus the full race
|
||||
suite and an assembled input-only analyze workflow.
|
||||
|
||||
Read: `internal/config/validate_scriptorium.go`, `artifact_families.go`,
|
||||
`internal/artifactpolicy`, `internal/artifacts`, analyze planning/execution, and
|
||||
their artifact dependency and semantic-evidence tests.
|
||||
|
||||
1. At the final configuration resolution boundary, union explicit `depends_on`
|
||||
with configured-artifact input references. Use the existing source-ID parser;
|
||||
do not infer edges from filenames, arbitrary strings, or non-artifact inputs.
|
||||
2. Infer after family member inputs become concrete `narratio.artifact.<key>`
|
||||
sources. Normalize, deduplicate, and sort the effective dependency list once.
|
||||
Keep explicit ordering-only dependencies valid. Repeated finalization must not
|
||||
accumulate edges or alter ordering. Store source-edge attribution separately
|
||||
from the effective graph for diagnostics and generated provenance.
|
||||
3. Apply existing existence, self-reference, enabled-dependency, output, and cycle
|
||||
validation to the union. Remove the error requiring an input to be repeated in
|
||||
`depends_on`. Retain diagnostics pointing to the explicit list entry or the
|
||||
input that contributed an invalid edge, including family origin where relevant.
|
||||
4. Ensure execution, targeted analysis, planning, prerequisite closure, reuse,
|
||||
forced regeneration, and semantic evidence consume this same concrete graph.
|
||||
No adapter or command may infer a second graph. Refresh final projection/digest
|
||||
after inference; equivalent explicit and inferred declarations compare equally.
|
||||
|
||||
Acceptance: cover input-only dependency ordering, duplicate explicit/inferred edges,
|
||||
ordering-only edges, missing/self/cyclic references, and rewritten family inputs.
|
||||
Use an assembled fake-artifact workflow to prove upstream output is supplied to a
|
||||
dependent artifact and failure/resume retains successful predecessors. Existing
|
||||
force/selection and invalidation tests remain valid. Update config and analyze
|
||||
internals with the implemented inference rule.
|
||||
|
||||
## Stage 9 — Add the effective artifact catalog command
|
||||
|
||||
Status: Complete — Added the read-only `config artifacts` catalog over finalized
|
||||
configuration, including effective dependencies and definition/family/party
|
||||
provenance; documented it and verified focused, full, and race test suites.
|
||||
|
||||
Read: `internal/app/config_commands.go`, config diff/source rendering, the safe
|
||||
projection and family-origin catalogs, and `docs/cli.md`.
|
||||
|
||||
1. Add `narratio config artifacts` to dispatch and help. Reuse ordinary pipeline,
|
||||
profile, campaign, campaign-file, and secrets-directory parsing/resolution.
|
||||
Resolve a campaign when families require it or when explicitly selected; a
|
||||
pipeline with only ordinary artifacts can be inspected without a campaign.
|
||||
2. Render one stable record per effective concrete artifact in key order with
|
||||
key, `narratio.artifact.<key>` source ID, defining source location(s), enabled
|
||||
state, output path, and sorted effective dependencies. Family members additionally
|
||||
identify family key, character ID, defining family source, and party source.
|
||||
3. Derive identity from the map key. A profile may contribute to an existing
|
||||
artifact, so report contributing definition locations instead of inventing
|
||||
a single filename owner. Use a deterministic escaped tabular text rendering;
|
||||
include a header and no records for an empty catalog. Propagate writer errors.
|
||||
4. Use the same finalized state as show/sources/diff and runtime configuration.
|
||||
No session download, adapter initialization, manifest access, or workspace
|
||||
materialization is allowed. Secret bootstrap remains the agreed startup exception.
|
||||
|
||||
Acceptance: CLI tests cover ordinary and generated artifacts, disabled entries,
|
||||
profile-added/overridden artifacts, empty catalog, no-campaign ordinary inspection,
|
||||
required-campaign failure for families, stable ordering, and no runtime side effects.
|
||||
Check identity stays the same after moving an artifact file without changing its key.
|
||||
Document the command in its canonical CLI owner and link to config layout examples.
|
||||
|
||||
## Stage 10 — Finish the operator layout and v2 migration documentation
|
||||
|
||||
Status: Complete
|
||||
|
||||
Completion: Reworked the maintained bundle around an unprofiled production base,
|
||||
one-entry artifact and family imports, and a testing overlay; updated the example
|
||||
harness and inspection workflows; consolidated the v2 configuration guidance; and
|
||||
added the v2.0.0 operator migration checklist. Focused documentation/example tests,
|
||||
the offline inspection walkthrough, and the full normal/race suites pass.
|
||||
|
||||
Read: roadmap “Artifact Definition Ergonomics” and “Compatibility and Migration”;
|
||||
`examples/README.md`, existing example bundles and their loader test,
|
||||
`docs/config.md`, `docs/operations.md`, `docs/release.md`, and internal configuration
|
||||
and artifact documentation.
|
||||
|
||||
1. Convert maintained pipeline examples to a production base with explicit
|
||||
`configkit.imports`, `artifacts/<key>.yml`, `artifact-families/<key>.yml`, and
|
||||
`profiles/testing.yml`. Each recommended artifact/family file defines exactly
|
||||
one explicit map entry. Fold old production overlays into the base.
|
||||
2. Include an input-only dependency example and at least two character-family
|
||||
examples sharing a campaign-owned party roster. Keep supported inline artifacts
|
||||
legal; file layout is guidance, not a filename-based validation requirement.
|
||||
3. Update the example validation harness to load root configurations and selected
|
||||
testing profiles, not attempt to load each imported fragment as a full pipeline.
|
||||
Use synthetic environment setup for explicit references. No example includes
|
||||
real credentials or depends on local tools/cloud services to validate.
|
||||
4. Consolidate canonical documentation updates from earlier stages. Remove current
|
||||
references to old composition/profile catalogs, default-profile configuration,
|
||||
secrets.env_dir, credential `_env` fields, credential-free inspection guarantees,
|
||||
paired diff snapshots, and removed platform support. Explain explicit-value and
|
||||
null changes without promising reloadable inspection output.
|
||||
5. Add `docs/releases/v2.0.0.md` using the release-note structure, with a concrete
|
||||
migration checklist linking to the canonical config/CLI docs and examples.
|
||||
Include static S3-reference migration versus SDK credential discovery, new
|
||||
inspection prerequisites, artifact input dependency inference, and platform
|
||||
removals. Do not claim publication or edit older immutable release notes.
|
||||
|
||||
Acceptance: `go test ./internal/doccheck` and
|
||||
`go test ./internal/config -run '^TestExamplesLoadAndValidate$'` pass. Walk through
|
||||
the new operator examples with config validate/show/sources/diff/artifacts offline.
|
||||
Their identities, family members, origins, and testing overrides agree.
|
||||
|
||||
## Stage 11 — Verify the complete migration and remove remaining scaffolding
|
||||
|
||||
Status: Complete
|
||||
|
||||
Completion: Audited the accepted roadmap and the full change set from `1558c9c`;
|
||||
no legacy configuration loader, profile/composition traversal, presence store,
|
||||
custom secrets loader, or production YAML decoder remains. The migration removes a
|
||||
net 1,235 production and 843 test Go lines; remaining configuration code owns domain
|
||||
defaults, normalization, validation, expansion, provenance, and safe projections.
|
||||
Go 1.26.7 module hygiene, tests, race tests, vet, build, documentation, examples,
|
||||
and the v2.0.0 release-candidate checker pass. Linux amd64 was tested natively;
|
||||
Linux arm64 and macOS arm64 release assets were cross-built, with native macOS
|
||||
arm64 validation remaining best effort and outside the release gate.
|
||||
|
||||
Read: the whole feature roadmap, completed stage notes, and the current test and
|
||||
release procedures. Review the diff from the commit introducing this plan.
|
||||
|
||||
1. Audit every roadmap requirement against current code and executable evidence.
|
||||
Remove temporary migration adapters and any residual generic YAML parser,
|
||||
composer, profile traversal, explicit-presence store, old secrets loader,
|
||||
decoder hooks, or unused tests. `gopkg.in/yaml.v3` may remain for output encoding
|
||||
and unrelated external formats; it must not supply a second configuration loader.
|
||||
2. Confirm result-owned snapshots versus final expanded configuration are handled
|
||||
consistently; every inspection/runtime path sees normalized inferred dependencies
|
||||
and generated publish rules. Recheck secret safety and semantic resume boundaries.
|
||||
3. Inspect current platform conditions and asset lists. Retain historical notes but
|
||||
no active Windows/macOS amd64 obligation. Compare owned production/test code
|
||||
before and after; report removals and any remaining complexity with reasons.
|
||||
Do not chase a LOC quota by removing useful domain checks or regressions.
|
||||
4. Run the repository gates with the maintained Go 1.26 toolchain:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
go test ./internal/doccheck
|
||||
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||
```
|
||||
|
||||
5. Run `scripts/check-release-candidate.sh v2.0.0` after the release note and all
|
||||
candidate changes exist. This also verifies module hygiene and all three
|
||||
release assets with `GOWORK=off`. Resolve failures; do not bypass checks or
|
||||
publish a tag. Record native versus cross-build evidence accurately.
|
||||
|
||||
Acceptance: all gates pass and every roadmap outcome has direct evidence. No
|
||||
dependency on the sibling ConfigKit checkout remains. Record completion and any
|
||||
best-effort platform limitations, then hand off for a separate release decision.
|
||||
# Implementation Plan: Speaker Checkpoints and Replayable Uploads
|
||||
|
||||
Status: Implementation complete; Stages 1-7 complete.
|
||||
|
||||
## Scope and Instructions
|
||||
|
||||
Implement [the feature roadmap](transcribe-checkpoints-and-replay.md). This plan
|
||||
replaces the completed Notarius-profile plan; that work remains in Git history.
|
||||
Complete the numbered stages in order, one bounded implementation prompt per
|
||||
stage for gpt-5.6-terra. Record completion and actual verification results here.
|
||||
Intermediate stages are not a finished feature and must not be released alone.
|
||||
|
||||
Before working, read `AGENTS.md`, `docs/development.md`, all three policies in
|
||||
`docs/policy/`, the roadmap, and the stage-specific preparation below. Use the
|
||||
maintained Go 1.26 toolchain. Preserve operator choices, speaker filenames,
|
||||
bounded concurrency, streaming, and existing credentials/settings. Do not add
|
||||
new commands, dependencies, service requirements, arbitrary speaker limits,
|
||||
strict transcript schemas, or a generic checkpoint framework. No release or
|
||||
upstream service work is part of this plan.
|
||||
|
||||
## Settled Design Decisions
|
||||
|
||||
### Progress Representation and Recovery
|
||||
|
||||
Add optional `transcribe_state_version` (version 1) and `transcribe_speakers`
|
||||
to `manifest.StageRecord`. The latter is a map keyed by the existing derived
|
||||
speaker ID, containing completed results only. Absence means that work must be
|
||||
done; a separate pending/running/failed state machine is unnecessary. Aggregate
|
||||
stage status/error remains authoritative for invocation failure. A versioned
|
||||
empty collection is meaningful: no earlier completions are reusable.
|
||||
|
||||
Use a typed `TranscribeSpeakerRecord` with fingerprint version/digest,
|
||||
session-relative audio path, audio SHA-256 and size, one `ArtifactRecord` output
|
||||
of kind `transcript_raw`, output size, and producer run ID. The output carries
|
||||
its session-relative canonical path and SHA-256. No new transcript source IDs,
|
||||
synthetic schema contracts, timestamps, or historical attempt collections are
|
||||
needed. Existing aggregate diagnostics can retain attempt/status/duration data
|
||||
for work performed in the current invocation; do not fabricate that information
|
||||
for reused outputs.
|
||||
|
||||
Keep the session-manifest schema identifier unchanged for this optional additive
|
||||
state; validate supported per-stage versions and retain strict decoding. New
|
||||
Narratio reads old manifests. Old binaries may reject the added fields under
|
||||
their existing strict decoding; document this downgrade limitation.
|
||||
|
||||
For pre-feature manifests with no speaker evidence, regenerate transcription
|
||||
once when transcribe is next selected, even if the aggregate status succeeded.
|
||||
Old records do not bind individual transcript results to the audio bytes they
|
||||
used. Do not guess that association from current files, old `per_file` metadata,
|
||||
or abandoned run directories. This is the conservative upgrade path, not a new
|
||||
general migration system. No other stage or unselected range is forcibly run.
|
||||
|
||||
### Reuse and Persistence
|
||||
|
||||
Compute a versioned per-speaker fingerprint from speaker ID, session-relative
|
||||
audio identity, actual audio SHA-256/size, and the existing transcribe semantic
|
||||
configuration fingerprint (URL and language). Do not include invocation ID,
|
||||
absolute workspace root, concurrency, retry settings, timeout, or credentials.
|
||||
Stream input hashing with cancellation; do not trust stored input checksums
|
||||
alone. Recheck input bytes before accepting a new result to catch changes
|
||||
observed during transcription; fail that job without checkpointing mismatched
|
||||
evidence. This is not a hostile-filesystem snapshot or concurrent-editor
|
||||
transaction guarantee and requires no extra audio copies or file locks.
|
||||
|
||||
Validate reusable output identity, canonical location, JSON, checksum, and size.
|
||||
Use existing confinement/file primitives. Missing or corrupt regular outputs
|
||||
become work; unsafe paths/non-regular conflicts follow existing safety errors,
|
||||
not recursive deletion. Missing required audio is an input error, not permission
|
||||
to reuse an unverifiable transcript. Removed inputs are removed from the current
|
||||
collection and aggregate outputs without deleting arbitrary files.
|
||||
|
||||
One stage-owned mutex serializes candidate collection construction, manifest
|
||||
save, and installation of committed state. Save a detached candidate and only
|
||||
then update live authority, following analyze's pattern. Workers must not mutate
|
||||
the live manifest outside that serialized section. Keep expensive HTTP work
|
||||
outside the mutex. Stop dispatch/acceptance on checkpoint failure, join workers
|
||||
before return, and preserve the last successfully committed collection.
|
||||
|
||||
Before launching requests, durably checkpoint the reconciled collection. For
|
||||
force, that collection is empty: this prevents later ordinary retries from
|
||||
resurrecting pre-force outputs. If this initial save fails, do not start requests
|
||||
or overwrite outputs. Force again clears the current collection again; ordinary
|
||||
retry retains replacements already checkpointed after the previous force.
|
||||
|
||||
Save each validated result at its canonical raw-transcript location before
|
||||
checkpointing it. Run-local files remain staging/diagnostics, not reuse
|
||||
dependencies. Bytes left by an unsuccessful checkpoint have no completion
|
||||
authority. A worker may checkpoint a completed result using the parent stage
|
||||
context even if a sibling canceled the work context; parent cancellation or a
|
||||
checkpoint failure may prevent that save. Guarantee retention of committed
|
||||
work, not all responses that happened to finish during cancellation.
|
||||
|
||||
### Multipart Replay Mechanism
|
||||
|
||||
Replace the pipe/producer upload mechanism with a small pull-based multipart
|
||||
body backed by the audio reader. Use `mime/multipart.Writer` to encode a bounded
|
||||
metadata prefix and suffix around the file payload: capture the prefix after
|
||||
`CreateFormFile`, then encode the language field and closing boundary as the
|
||||
suffix. Generate this framing once per application attempt. Each body combines
|
||||
fresh readers for prefix, freshly opened audio, and suffix using `io.MultiReader`.
|
||||
No full-audio buffer or temporary multipart spool is needed.
|
||||
|
||||
The initial body and `Request.GetBody` use the same factory/framing. Each owns
|
||||
an independent audio handle. Give bodies idempotent Close and context-triggered
|
||||
closure (for example `context.AfterFunc`), without holding a mutex across a
|
||||
blocking Read. Read/Close coordination must release a blocked source and retain
|
||||
genuine source-read/close errors while not treating deliberate early closure as
|
||||
source failure. The attempt owns cleanup of all created bodies, including
|
||||
replays; reject new body creation after its context expires.
|
||||
|
||||
Remove the old producer `Wait` dependency. Track body-local source errors with
|
||||
synchronization so a swallowed upload read error cannot become a successful
|
||||
transcript, and a deliberately closed original body cannot poison a successful
|
||||
replay. Keep the outer retry policy and per-attempt context. Do not add replay
|
||||
counters to the user retry budget or depend on Go's exact internal retry count.
|
||||
Preserve ordinary HTTP redirect policy; body replay can also enable Go's
|
||||
body-preserving redirects. Do not introduce a new redirect restriction.
|
||||
|
||||
## Stage 1: Add Durable Speaker Completion State
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Completion marker (2026-09-21): Added optional versioned transcribe-owned
|
||||
per-speaker completion records, strict load/save validation, isolated collection
|
||||
cloning, legacy aggregate-only compatibility, and lifecycle-preservation
|
||||
coverage. Verified with `go test ./internal/manifest` and `git diff --check`.
|
||||
|
||||
Preparation: `internal/manifest/manifest.go`, `store.go`, `analyze_state.go`,
|
||||
their tests, and `docs/internal/manifest.md`.
|
||||
|
||||
1. Implement the additive fields, record type, version constants, validation,
|
||||
and deep-copy helper described above in a focused transcribe state file.
|
||||
2. Validate ownership by the transcribe stage, supported versions, required
|
||||
completed-record evidence, output kind, safe relative paths, SHA-256 values,
|
||||
sizes, and producer identity consistency. Use existing speaker IDs as-is;
|
||||
do not apply analysis-artifact key regexes or copy unrelated analysis limits.
|
||||
3. Integrate validation with both load and save. Preserve speaker state through
|
||||
running/failed/stale transitions and aggregate result clearing. Generic stage
|
||||
invalidation does not discard checkpoints; reconciliation checks their data.
|
||||
4. Test round-trip populated and versioned-empty collections, old manifests
|
||||
without fields, malformed evidence, deep-copy isolation, and lifecycle
|
||||
preservation. Avoid imposing JSON formatting or map order as contracts.
|
||||
|
||||
Verify: `go test ./internal/manifest`.
|
||||
|
||||
Done when durable storage can represent partial completion independently of
|
||||
aggregate status, without activating new stage behavior yet.
|
||||
|
||||
## Stage 2: Implement Read-Only Speaker Reconciliation
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Completion marker (2026-09-21): Extracted shared deterministic transcribe job
|
||||
construction and added read-only, cancellation-aware per-speaker input and
|
||||
output reconciliation. Covered unchanged reuse, semantic and operational
|
||||
settings, changed/added/removed inputs, legacy and forced work, missing/corrupt
|
||||
outputs, unsafe evidence, and missing audio without connecting runtime skip or
|
||||
checkpoint behavior. Verified with
|
||||
`go test ./internal/stage ./internal/manifest` and `git diff --check`.
|
||||
|
||||
Preparation: `internal/stage/transcribe.go`, `semantic_contracts_initial.go`,
|
||||
`analyze_reconciliation.go`, `extract_resume.go`, their tests, and the artifact
|
||||
path/integrity helpers they use.
|
||||
|
||||
1. Extract the existing audio discovery/job construction into a shared,
|
||||
transcribe-local reconciliation path. Preserve current input selection and
|
||||
speaker identity behavior. Build sorted jobs and classify completed results
|
||||
against the current input set and evidence defined above.
|
||||
2. Add pure fingerprint construction and streamed input/output inspection.
|
||||
Resolve manifest input paths with existing session-relative/absolute reading
|
||||
helpers; do not change input precedence or assume the current working directory
|
||||
is the session root. Newly written checkpoint paths are always relative.
|
||||
3. Return current records and work-to-do without writing files or manifest state.
|
||||
Force selects all work and an empty starting collection; old unversioned
|
||||
transcribe state supplies no reusable records. Do not connect aggregate skip
|
||||
validation until execution can produce the new evidence in Stage 3.
|
||||
4. Test unchanged reuse; one changed audio among unchanged speakers; URL/language
|
||||
changes; operational-setting exclusions; added/removed speakers; missing and
|
||||
corrupt regular outputs; unsafe record paths; missing audio; and force.
|
||||
Prefer compact tables and real temporary files over mocked hashing.
|
||||
|
||||
Verify: `go test ./internal/stage ./internal/manifest`.
|
||||
|
||||
Done when one read-only decision path describes exactly which speakers need
|
||||
work, without altering existing runtime behavior prematurely.
|
||||
|
||||
## Stage 3: Execute and Checkpoint Speakers Incrementally
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Completion marker (2026-09-21): Connected reconciliation to bounded execution,
|
||||
durably checkpointed the starting and per-speaker collections through detached
|
||||
manifest candidates, preserved committed work across failures and cancellation,
|
||||
rebuilt deterministic aggregate outputs from reusable evidence, and added
|
||||
speaker-aware resume validation. Updated production-style fixtures and covered
|
||||
serialized concurrent saves, force/retry replacement, input rechecks, save
|
||||
failures, partial recovery, and aggregate reconstruction. Verified with
|
||||
`go test ./internal/stage ./internal/app`, `go test -race ./internal/stage`, and
|
||||
`git diff --check`.
|
||||
|
||||
Preparation: `internal/stage/transcribe.go`, `analyze_execution_state.go`,
|
||||
`run_local.go`,
|
||||
`internal/app/runner.go`, and transcribe/checkpoint test fixtures.
|
||||
|
||||
1. Wire reconciliation into Run, require the existing ManifestStore collaborator,
|
||||
and checkpoint the starting collection before launching HTTP work. Implement
|
||||
detached candidate saves and serialized live-state updates. Audit Store.Save's
|
||||
normalization side effects so a failed candidate save cannot mutate shared
|
||||
authority; use detached records/collections wherever it may write.
|
||||
2. Retain bounded errgroup execution and fail-fast cancellation. Check the work
|
||||
context before beginning each queued request. After success, validate adapter
|
||||
output path/JSON, recheck audio evidence, materialize canonical bytes, compute
|
||||
output evidence, and checkpoint the new record. Check a checkpoint-failure
|
||||
latch under the serialization lock before accepting further completions.
|
||||
3. Preserve committed successes on all error/cancellation exits, including
|
||||
starting-save, canonical-write, per-speaker-save, and aggregate-save failures.
|
||||
Return meaningful speaker/error context and let the runner retain its existing
|
||||
terminal failure behavior. Do not report partial aggregate outputs as success.
|
||||
4. Assemble final outputs in deterministic speaker order from the reconciled
|
||||
completed records, including reused speakers, with original producer IDs and
|
||||
checksums. If all records are current but aggregate status is failed/running,
|
||||
finish without WhisperX calls and rebuild the aggregate success.
|
||||
5. Implement `transcribeStage.ValidateResume` using the same read-only
|
||||
reconciliation, so a succeeded stage cannot skip changed audio or invalid
|
||||
transcripts. Retain the existing URL/language semantic check and runner
|
||||
invalidation behavior; no whole-config digest invalidation.
|
||||
6. Adapt existing tests that require no canonical partial files: the new contract
|
||||
permits checkpointed files but forbids aggregate success. Update fixtures to
|
||||
provide real manifest stores and records rather than a production fallback
|
||||
that silently disables checkpointing.
|
||||
7. Test A/B completion followed by C failure and D unattempted (concurrency 1),
|
||||
reload and retry only C/D; concurrently finishing speakers without lost saves;
|
||||
failure and cancellation after a checkpoint; save failure retaining earlier
|
||||
authority; and force failure followed by ordinary retry without old-result reuse.
|
||||
|
||||
Verify: `go test ./internal/stage ./internal/app` and
|
||||
`go test -race ./internal/stage`.
|
||||
|
||||
Done when incremental progress and aggregate success work together safely under
|
||||
ordinary, concurrent, canceled, and forced execution.
|
||||
|
||||
## Stage 4: Verify Runner, Merge, and Durable Lifecycle Integration
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Completion marker (2026-09-22): Added real-persistence application coverage for
|
||||
cross-invocation retry, interrupted running state, failed aggregate saves,
|
||||
force/reset persistence, downstream invalidation, and one-time legacy upgrade.
|
||||
Versioned merge input discovery now requires aggregate success and an exact
|
||||
match between current speaker evidence and declared aggregate outputs, while
|
||||
legacy/manual directory fallback remains available. Restore coverage preserves
|
||||
session-relative speaker paths, leaves unpublished raw transcripts absent, and
|
||||
canonical transcripts remain reusable after run-directory cleanup. Verified
|
||||
with `go test ./internal/app ./internal/stage ./internal/manifest`,
|
||||
`go test -race ./internal/app`, and `git diff --check`.
|
||||
|
||||
Preparation: `internal/app/runner.go`, `runner_runtime.go`,
|
||||
`analyze_checkpoint_recovery_test.go`, `runner_terminal_failure_test.go`,
|
||||
`restore_manifest_paths.go`, `internal/stage/merge.go`, and existing publish,
|
||||
restore, and cleanup tests routed from `docs/internal/overview.md`.
|
||||
|
||||
1. Add application-level tests with real manifest persistence and fake WhisperX:
|
||||
failed stage across separate invocations; all speakers committed before a
|
||||
failed aggregate save; interrupted running state; force/reset persistence;
|
||||
and unchanged upstream/succeeded downstream invalidation behavior. Inject
|
||||
errors at durable boundaries, not every private method call.
|
||||
2. Close `discoverRawTranscripts`' directory-scan fallback for versioned
|
||||
transcribe state: incomplete aggregate state is not usable, and completed
|
||||
state consumes only declared current aggregate outputs. Keep legacy/manual
|
||||
fallback behavior for manifests without the new state rather than banning
|
||||
previously allowed standalone workflows. Test that partial files and removed
|
||||
speaker files cannot leak into a versioned merge, including direct stage calls.
|
||||
3. Audit manifest copies/projections, remote manifest publication, restored path
|
||||
handling, and cleanup. Preserve relative speaker paths on restore and verify
|
||||
they resolve under the destination session, not the producer machine. Reuse
|
||||
existing confinement validation, not a second path-policy layer.
|
||||
4. Add focused round-trip coverage through existing publish/restore fixtures for
|
||||
speaker records. Restored outputs absent from the published selection are
|
||||
non-reusable locally; do not silently expand remote publication to every raw
|
||||
speaker or upload audio as a new default. Removing run directories must not
|
||||
remove canonical checkpointed transcripts or make reuse require run files.
|
||||
5. Verify old succeeded manifests are readable and rerun once when transcribe is
|
||||
selected, then reuse normally. Keep old manifests usable for other commands
|
||||
and bounded ranges. Do not automatically import old unrecorded JSON files.
|
||||
|
||||
Verify: `go test ./internal/app ./internal/stage ./internal/manifest` and
|
||||
`go test -race ./internal/app`.
|
||||
|
||||
Done when the new authority survives runner and storage lifecycles and cannot
|
||||
expose incomplete transcripts downstream.
|
||||
|
||||
## Stage 5: Implement Replayable Streaming Multipart Bodies
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Completion marker (2026-09-22): Replaced the producer-goroutine/pipe upload
|
||||
with bounded standard-library multipart framing and independently opened,
|
||||
pull-based streaming bodies shared by the initial request and `GetBody`.
|
||||
Bodies are tracked, idempotently closed, canceled with their attempt context,
|
||||
and retain genuine read/close failures without treating an intentionally
|
||||
abandoned original as a replay failure. Added focused coverage for identical
|
||||
framing, partial-read restart, simultaneous handles, source failures, and
|
||||
blocked-reader release. Verified with `go test ./internal/adapters/whisperx`,
|
||||
`go test -race ./internal/adapters/whisperx` (including ten repeated runs), and
|
||||
`git diff --check`.
|
||||
|
||||
Preparation: `internal/adapters/whisperx/http.go`, `http_test.go`,
|
||||
`docs/integrations/whisperx.md`, and the installed Go net/http Request/GetBody
|
||||
contract. Keep this change inside the WhisperX adapter.
|
||||
|
||||
1. Implement the pull-based framing/body factory described above, using standard
|
||||
multipart encoding rather than hand-escaped MIME headers. Add no new payload
|
||||
cap or upload-size setting. Preserve file-before-language wire semantics and
|
||||
the existing form names.
|
||||
2. Install the initial body and GetBody with the same per-attempt boundary.
|
||||
Track and close all opened bodies even if a custom transport returns early;
|
||||
make cleanup/context callbacks safe against overlapping reads and closes.
|
||||
A GetBody failure must be returned, not converted into an empty upload.
|
||||
3. Replace pipe-specific Wait/error paths in doTranscribeAttempt with source-error
|
||||
handling appropriate to pull bodies. Genuine source errors remain visible;
|
||||
intentional closure during transport retry is not itself a source error.
|
||||
Keep response bounds, JSON validation, output installation, retry
|
||||
classification/delay, cancellation, and application attempt reporting intact.
|
||||
4. Remove obsolete producer-only helpers rather than retaining two upload paths.
|
||||
Adapt tests tied to producer goroutines to assert streaming and resource
|
||||
release, which are the actual contracts.
|
||||
5. Add focused body tests: original/replayed bytes parse identically; replay
|
||||
after partial consumption starts from the beginning; simultaneous bodies use
|
||||
independent handles; source failures propagate; Close/cancel releases a
|
||||
blocked reader without deadlock. Use synthetic audio and gated readers.
|
||||
|
||||
Verify: `go test ./internal/adapters/whisperx` and
|
||||
`go test -race ./internal/adapters/whisperx`.
|
||||
|
||||
Done when replay is available without whole-file buffering or producer-lifetime
|
||||
ambiguity, and all existing HTTP behavior tests still pass.
|
||||
|
||||
## Stage 6: Verify Replay Through the HTTP Adapter Boundary
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Completion marker (2026-09-22): Added a deterministic transport regression
|
||||
that abandons a partially consumed original request, obtains and parses a fresh
|
||||
multipart replay from `GetBody`, and installs the returned transcript within one
|
||||
application attempt. Covered replay open/read failures, early responses,
|
||||
parent cancellation during replay, and disposal of every opened source, while
|
||||
proving an abandoned original does not poison a valid replay. Added a real HTTP
|
||||
retry-exhaustion integration that durably retains an earlier speaker and resumes
|
||||
only the uncommitted speakers. This controlled boundary verifies Narratio's
|
||||
replay contract, not recovery from every real proxy protocol failure. Verified
|
||||
with `go test ./internal/adapters/whisperx ./internal/app ./internal/stage`,
|
||||
`go test -race ./internal/adapters/whisperx ./internal/app ./internal/stage`,
|
||||
ten repeated race-enabled adapter runs, and `git diff --check`.
|
||||
|
||||
Preparation: Existing injectable HTTPClient/RoundTripper seam and local-server
|
||||
tests in `internal/adapters/whisperx/http_test.go`; app WhisperX wiring tests.
|
||||
|
||||
1. Add a deterministic transport-boundary regression: consume part of the
|
||||
original request, close it as a transport would, call GetBody, consume and
|
||||
parse the replay using the request Content-Type, then return valid JSON.
|
||||
Verify exact audio/language, installed output, and one application attempt.
|
||||
This must fail before replay support; merely checking non-nil GetBody is
|
||||
insufficient. Do not assert Go's internal retry count or require a flaky
|
||||
handcrafted HTTP/2 peer in the default suite.
|
||||
2. Cover replay-open/read failure, early response, parent cancellation during
|
||||
replay, and disposal of all body handles. Reuse Stage 5 body coverage rather
|
||||
than repeat every low-level case. Prove an intentionally abandoned original
|
||||
body does not turn a valid replay response into failure.
|
||||
3. Retain local HTTP tests for application retries, non-retryable responses,
|
||||
response limits, invalid JSON, and bounded streaming; exercise that an
|
||||
exhausted HTTP failure still leaves earlier speaker checkpoints reusable
|
||||
using the existing stage/app seams.
|
||||
4. Record the verification boundary honestly: controlled transport replay proves
|
||||
Narratio's body contract, not recovery from every real proxy PROTOCOL_ERROR.
|
||||
|
||||
Verify: `go test ./internal/adapters/whisperx ./internal/app ./internal/stage`
|
||||
and race tests for changed packages.
|
||||
|
||||
Done when the replay defect is protected at the adapter boundary and the
|
||||
application retry/checkpoint behavior remains intact.
|
||||
|
||||
## Stage 7: Document and Validate the Completed Feature
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Completion marker (2026-09-22): Updated the canonical transcribe, merge,
|
||||
manifest, WhisperX, operations, and troubleshooting documentation for durable
|
||||
speaker reconciliation, serialized checkpoints, aggregate gating, durable
|
||||
force reset, legacy upgrade and downgrade limits, replayable streaming bodies,
|
||||
resource ownership, and the absence of an exactly-once remote guarantee.
|
||||
Reviewed the completed implementation against the feature roadmap and
|
||||
implementation-discipline guidance. Verified with `go test ./...`,
|
||||
`go test -race ./...`, `go vet ./...`, `go build ./...`,
|
||||
`go test ./internal/doccheck`,
|
||||
`go test ./internal/config -run '^TestExamplesLoadAndValidate$'`, and
|
||||
`git diff --check`.
|
||||
|
||||
1. Update canonical transcribe and manifest internals for the collection,
|
||||
reconciliation, durable-reset force behavior, serialized saves, and aggregate
|
||||
gate. Remove obsolete all-speakers-before-canonical-write descriptions.
|
||||
2. Update the WhisperX contract for replayable bounded streaming, application
|
||||
attempts versus transport replay, resource ownership, and the lack of an
|
||||
exactly-once server guarantee. Do not claim the remote protocol issue is fixed.
|
||||
3. Update operations/troubleshooting with ordinary resume versus force, durable
|
||||
partial success, old-manifest one-time retranscription on selecting transcribe,
|
||||
downgrade limitations, and private-model-change guidance. No new CLI flags
|
||||
or promises of automatic historical-file recovery. Keep detailed wire and
|
||||
storage contracts in their canonical owners.
|
||||
4. Review changes against the feature roadmap and the implementation-discipline
|
||||
guidance. Do not elevate selected storage encoding, internal ordering, test
|
||||
synchronization, or exact error wording into new product guarantees.
|
||||
5. Run the full repository gates below; document results and update completion
|
||||
statuses only after they pass. Leave release preparation for a separate task.
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
go test ./internal/doccheck
|
||||
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
None required before implementation. The agreed roadmap and the explicit choices
|
||||
above resolve the migration decisions. Upstream credential-free inspection and
|
||||
native macOS arm64 CI remain deferred, not prerequisites for this plan.
|
||||
None requiring user input. The agreed roadmap supplies the product decisions;
|
||||
this plan selects a completed-only manifest collection, conservative old-record
|
||||
handling, and pull-based replay as implementation choices. No stronger remote
|
||||
reliability guarantee, new operator restriction, or generic orchestration
|
||||
surface is proposed.
|
||||
|
||||
151
docs/roadmap/notarius-config-profile.md
Normal file
151
docs/roadmap/notarius-config-profile.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Notarius Configuration Profile Selection
|
||||
|
||||
Status: Implemented.
|
||||
|
||||
## Goal and Authority
|
||||
|
||||
Implement the consumer behavior in the
|
||||
[feature request](feature-request-notarius-config-profile.yml): let a Narratio
|
||||
configuration overlay explicitly select a Notarius configuration overlay.
|
||||
Operators can select testing once in Narratio while each application retains
|
||||
ownership of its own model choices and other settings.
|
||||
|
||||
This roadmap describes the target state and required change areas, not an
|
||||
implementation sequence; the [implementation plan](implementation.md) owns
|
||||
the ordered work and implementation decisions. The feature request is the scope
|
||||
authority; the mechanisms suggested below do not establish additional
|
||||
user-facing contracts.
|
||||
|
||||
## Underlying Issue
|
||||
|
||||
Narratio can select its own testing profile, but extraction currently invokes
|
||||
Notarius without a configuration-profile selector. Consequently, a Narratio
|
||||
testing invocation cannot explicitly select the independently deployed Notarius
|
||||
testing overlay through pipeline configuration.
|
||||
|
||||
Adding individual downstream model or tuning fields would duplicate Notarius's
|
||||
configuration responsibilities. Passing its existing profile selector provides
|
||||
the requested flexibility without that duplication.
|
||||
|
||||
## Targeted End State
|
||||
|
||||
### Explicit Configuration Composition
|
||||
|
||||
`pipeline.notarius.config_profile` is an optional Notarius configuration-profile
|
||||
name, usable in Narratio's base configuration and ordinary overlays. For
|
||||
example, Narratio's `profiles/testing.yml` may contain:
|
||||
|
||||
```yaml
|
||||
notarius:
|
||||
config_profile: testing
|
||||
```
|
||||
|
||||
Selecting Narratio's `--profile testing` then selects that explicit downstream
|
||||
profile whenever extraction executes. The two profile names need not match.
|
||||
Narratio does not automatically forward its own profile name.
|
||||
|
||||
When supplied, the selection is passed through Notarius's `--config-profile`
|
||||
argument. Omitted or null values add no profile-selection flag and preserve
|
||||
existing downstream selection behavior. Notarius's selector semantics remain
|
||||
authoritative, including empty and `default` selections for the composed base
|
||||
configuration. Narratio does not introduce a stricter profile-name policy.
|
||||
|
||||
This field selects a ConfigKit configuration overlay, not a PromptKit profile
|
||||
through `--llm-profile`. The Notarius overlay owns any resulting LLM-profile
|
||||
selection. Notarius resolves overlays relative to its own configuration root;
|
||||
Narratio neither resolves those files against its own root nor rewrites them.
|
||||
|
||||
### Execution, Reuse, and Failure
|
||||
|
||||
A changed downstream profile selection must not silently reuse Narratio's
|
||||
completed extraction from the previous selection. Existing dependent-stage
|
||||
invalidation applies when extraction changes; the feature does not change the
|
||||
pipeline order or transcript-generation behavior.
|
||||
|
||||
Notarius continues to own downstream overlay discovery, composition, validation,
|
||||
and cache compatibility. A missing or invalid selected overlay must fail
|
||||
extraction through the existing subprocess error path, without silently falling
|
||||
back to production or reporting successful extraction.
|
||||
|
||||
Existing resume and chunk-cache controls, including force precedence, remain
|
||||
in place. Profile selection does not itself mean forced regeneration or cache
|
||||
deletion. Narratio's decision to execute extraction and Notarius's decision to
|
||||
reuse compatible work remain separate responsibilities.
|
||||
|
||||
Changes to private Notarius configuration behind an unchanged selector retain
|
||||
the existing operator responsibility to force extraction when needed. There is
|
||||
no new downstream file inspection or content-tracking requirement.
|
||||
|
||||
Credential forwarding remains independently configured through
|
||||
`notarius.llm_api_key`. A Narratio testing overlay may select a different
|
||||
credential reference alongside the downstream profile; no new credential
|
||||
discovery or provider-name mapping is introduced.
|
||||
|
||||
## Required Change Areas
|
||||
|
||||
### Configuration and Inspection
|
||||
|
||||
Extend the typed Notarius configuration and its existing safe effective-YAML
|
||||
projection to carry the optional field. Preserve the ordinary configuration
|
||||
composition behavior for base values, overlay overrides, and null values.
|
||||
Configuration inspection must reflect the selected field without exposing
|
||||
credential values. No replacement configuration loader or downstream preflight
|
||||
loader is needed.
|
||||
|
||||
### Stage and Adapter Boundary
|
||||
|
||||
Carry the selection from resolved configuration through the extraction request
|
||||
to the subprocess adapter. Construct the optional CLI flag using the existing
|
||||
argument-vector mechanism, alongside the current configuration path, references,
|
||||
credential forwarding, and cache controls. Retain normal Notarius error handling.
|
||||
|
||||
### Extraction Reuse Evidence
|
||||
|
||||
Account for the downstream selection in the existing extraction reuse evidence.
|
||||
The current semantic-configuration and invocation fingerprints are the relevant
|
||||
change points. Keep their treatment of selection consistent so that a profile
|
||||
change cannot bypass the required rerun and downstream invalidation.
|
||||
|
||||
This roadmap does not require a new manifest schema, separate profile-indexed
|
||||
workspaces, historical per-profile result retention, or an upgrade-wide forced
|
||||
rerun.
|
||||
|
||||
### Verification and Documentation
|
||||
|
||||
Use focused behavioral coverage for configuration composition and inspection,
|
||||
explicit argument forwarding and omission, changed-selection reuse decisions,
|
||||
and ordinary subprocess failure handling. Reuse existing test coverage where
|
||||
it already protects unchanged credentials, force behavior, and invalidation;
|
||||
do not duplicate Notarius's complete profile-validation or cache test suites.
|
||||
|
||||
When implemented, update the canonical
|
||||
[configuration reference](../config.md),
|
||||
[Notarius integration contract](../integrations/notarius.md),
|
||||
[extract internals](../internal/stage-extract.md), and relevant maintained
|
||||
examples. The development workflow in [Operations](../operations.md) should
|
||||
explain explicit profile composition and the existing need to force extraction
|
||||
after private downstream edits. Clarify that profile selection does not create
|
||||
an isolated testing workspace or separate current extraction state.
|
||||
|
||||
## Compatibility and Scope Boundaries
|
||||
|
||||
Notarius v0.7.0 already documents `--config-profile`; no upstream change is
|
||||
required. Document the capability needed when the option is used, without adding
|
||||
an unconditional runtime version gate for existing unconfigured deployments.
|
||||
|
||||
This feature adds no Narratio CLI selector, automatic profile propagation,
|
||||
Notarius LLM-profile selector, downstream configuration rewriting, new
|
||||
credential mechanism, or generalized recursive orchestration. Existing output
|
||||
and receipt contracts remain unchanged.
|
||||
|
||||
The consumer's Ansible role and suite updates are follow-up work outside this
|
||||
repository. They can deploy downstream overlays and populate this field after
|
||||
the interface is released.
|
||||
|
||||
## Scope Review
|
||||
|
||||
No stronger requirements or new invariants are proposed beyond the feature
|
||||
request and existing repository contracts. In particular, minimizing one-time
|
||||
upgrade invalidation and avoiding reruns between equivalent base selectors are
|
||||
implementation considerations, not additional acceptance guarantees imposed
|
||||
by this roadmap. No user decisions are currently required before planning.
|
||||
@@ -1,331 +1,104 @@
|
||||
# ConfigKit Migration Review — Required Remediations
|
||||
|
||||
Reviewed 2026-09-07 at `f7a40af`, against the changes after `1558c9c`, the
|
||||
[feature roadmap](configkit-adoption.md), and the completed
|
||||
[implementation plan](implementation.md).
|
||||
|
||||
The main migration is implemented. The following findings are reproducible gaps
|
||||
in the intended behavior, not proposals to restore retired compatibility or
|
||||
filesystem restrictions. P2 denotes a correctness issue to fix before release;
|
||||
P3 denotes an operator-facing completeness issue also required by the accepted
|
||||
plan. No application code was changed during this review.
|
||||
|
||||
## 1. Derive explicit publish destinations after family expansion
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Missing destinations are now derived through one shared domain helper
|
||||
both during ConfigKit resolution and again after concrete family expansion. Explicit
|
||||
destinations remain unchanged, while unknown sources still fail read-only publish
|
||||
validation. Regression coverage verifies resolved runtime and effective inspection
|
||||
output for generated members, explicit overrides, and unknown sources.
|
||||
|
||||
**Evidence:** `internal/config/pipeline_configkit.go:208` derives missing publish
|
||||
destinations during the ConfigKit resolver. A concrete family member does not
|
||||
exist yet, so the resolver ignores the failed derivation. After family expansion,
|
||||
`internal/config/artifact_families.go:141` calls the now-read-only publish
|
||||
validator, which rejects the still-empty destination. The previous implementation
|
||||
could derive it during that later validation.
|
||||
|
||||
**Reproduction:** In an otherwise valid pipeline, define a family `meta` over
|
||||
`party.characters`, with output pattern `artifacts/{character_id}.md`. Select a
|
||||
valid campaign whose party contains `alice`, and add:
|
||||
|
||||
```yaml
|
||||
publish:
|
||||
outputs:
|
||||
- source: narratio.artifact.meta_alice
|
||||
```
|
||||
|
||||
`config validate --config <pipeline> --campaign-file <campaign>` fails with
|
||||
`pipeline.publish.outputs[0].dest is required when destination cannot be derived
|
||||
from "narratio.artifact.meta_alice"`. Adding `dest: artifacts/alice.md` makes the
|
||||
same configuration pass. The destination is therefore available and derivable;
|
||||
the lifecycle omits the required final normalization step.
|
||||
|
||||
**Required change:** Complete publish destination resolution after concrete
|
||||
artifacts exist, before publish validation and final projection/digest generation.
|
||||
Share the domain derivation logic and keep validation read-only. Preserve
|
||||
explicit destinations and generated family publish-rule collision checks. Do not
|
||||
require redundant `dest` fields or revive mutation inside validators.
|
||||
|
||||
**Acceptance:** Test an explicit publish source naming a generated member with
|
||||
omitted destination, an explicit destination override, and a genuinely unknown
|
||||
source. Validate both effective inspection and resolved runtime configuration.
|
||||
|
||||
## 2. Stop re-defaulting an explicitly empty Notarius working directory
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Removed both legacy blank-value fallbacks after ConfigKit resolution.
|
||||
An omitted working directory is still derived by the presence-aware resolver, an
|
||||
explicit relative directory is still rooted at the pipeline, and explicit empty or
|
||||
whitespace-only values remain empty and fail enabled-Notarius validation. Existing
|
||||
relocation-invariant digest coverage remains green.
|
||||
|
||||
**Evidence:** ConfigKit's resolver correctly uses presence-aware defaulting at
|
||||
`internal/config/pipeline_configkit.go:180`. However,
|
||||
`internal/config/load.go:454` subsequently replaces any blank working directory
|
||||
with the config file's directory, irrespective of input presence.
|
||||
`internal/config/effective_digest.go` also applies a blank-value fallback while
|
||||
capturing logical paths. These retained legacy fallbacks bypass the new rule that
|
||||
defaults apply only to absent fields.
|
||||
|
||||
**Reproduction:** Set `notarius.enabled: true`, a valid config path, pipeline ID
|
||||
and output map, and `notarius.working_directory: ""`. With
|
||||
`config_path: notarius/config.yml`, `config show` succeeds and reports
|
||||
`working_directory: notarius`; validation never sees the explicit invalid blank.
|
||||
|
||||
**Required change:** Let the ConfigKit resolver own the absent-field default.
|
||||
The subsequent runtime and logical-path conversions must preserve explicit
|
||||
emptiness so enabled-Notarius validation can reject it. Remove duplicate
|
||||
value-based defaulting rather than adding another private presence mechanism.
|
||||
|
||||
**Acceptance:** Omission still derives the expected directory; explicit empty
|
||||
and whitespace-only values fail when Notarius is enabled; a configured relative
|
||||
directory resolves from the pipeline root. Preserve digest relocation invariance.
|
||||
|
||||
## 3. Enforce unique base definitions even when a profile removes them
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Loading now discovers base declarations through ConfigKit's public
|
||||
historical-path enumeration and metadata history from the selected result,
|
||||
without separately resolving the base (see finding 7). Duplicate ordinary or
|
||||
family definitions therefore fail even when an overlay nulls their container or
|
||||
all of Scriptorium. A single base owner
|
||||
with a legitimate profile override remains supported.
|
||||
|
||||
**Evidence:** `rejectDuplicateBaseDefinitions` at
|
||||
`internal/config/pipeline_configkit.go:354` enumerates only artifact/family keys
|
||||
remaining in the final typed configuration. It inspects history for those keys
|
||||
but never examines base declarations removed by an overlay.
|
||||
|
||||
**Reproduction:** Import `first.yml` defining
|
||||
`scriptorium.artifacts.recap.prompt_id` and `second.yml` defining
|
||||
`scriptorium.artifacts.recap.output_path`. Base validation correctly rejects the
|
||||
two-file declaration. Add `profiles/clear.yml` containing:
|
||||
|
||||
```yaml
|
||||
scriptorium:
|
||||
artifacts: null
|
||||
```
|
||||
|
||||
The same root with `--profile clear` passes. A selected profile can thus hide the
|
||||
base ownership conflict, contrary to the plan's unique base-definition rule.
|
||||
This concerns disjoint contributions to one artifact, not ConfigKit's generic
|
||||
conflicting-leaf checks, which still work.
|
||||
|
||||
**Required change:** Discover the complete base declaration set through public
|
||||
ConfigKit metadata/history, including declarations displaced by an overlay, and
|
||||
validate ownership independently of final effective-key survival. Keep legitimate
|
||||
profile overrides and profile-only definitions supported. Do not reparse source
|
||||
YAML or add a second composer.
|
||||
|
||||
**Acceptance:** Cover duplicate ordinary and family definitions hidden by a null
|
||||
artifact/family container or null Scriptorium section. Base and selected-profile
|
||||
loads must reject the same base conflict, while a single base declaration with a
|
||||
valid overlay remains accepted.
|
||||
|
||||
## 4. Preserve null versus empty collections in effective YAML
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Effective YAML is now emitted from the credential-safe JSON value
|
||||
projection through an output-only YAML-node conversion. Explicit nulls, empty maps,
|
||||
empty slices, booleans, strings, and exact JSON numbers retain their semantic types.
|
||||
Public show/diff tests cover null and empty maps and slices, while digest,
|
||||
credential-redaction, deterministic output, and exact-integer coverage remain intact.
|
||||
|
||||
**Evidence:** `internal/config/effective_pipeline.go:154` marshals the typed
|
||||
projection directly with `yaml.Marshal`. The encoder emits nil slices as `[]`
|
||||
and nil maps as `{}`, while the JSON projection used by diff and digest preserves
|
||||
them as `null`. The shared input type does not guarantee shared output semantics.
|
||||
|
||||
**Reproduction:** Set `audita.modules: null` in a valid base and
|
||||
`audita.modules: []` in `profiles/empty.yml`. Both `config show` invocations emit
|
||||
`modules: []`, but `config diff default empty` reports:
|
||||
|
||||
```text
|
||||
changed audita.modules null []
|
||||
```
|
||||
|
||||
The commands disagree about the effective value. The same renderer also supplies
|
||||
the resolved pipeline diagnostic written by prepare.
|
||||
|
||||
**Required change:** Render YAML from a credential-safe projection that explicitly
|
||||
preserves nulls and empty collections, consistently with the semantic projection.
|
||||
Keep exact integers and safe credential reference names. An output-only conversion
|
||||
is appropriate; do not restore YAML decoding or a configuration parser. This
|
||||
requires truthful inspection, not a guarantee that inspection output is reloadable.
|
||||
|
||||
**Acceptance:** Verify null versus empty maps and slices through the public show
|
||||
boundary, and agreement with diff/digest distinctions. Retain credential-leakage
|
||||
and exact-integer regression coverage.
|
||||
|
||||
## 5. Complete provenance for generated artifacts and inferred dependencies
|
||||
|
||||
Status: Resolved — P3
|
||||
|
||||
Resolution: Generated catalog records now collect the complete base/profile family
|
||||
definition set from ConfigKit history. Effective source ownership for materialized
|
||||
dependency lists is rebuilt narrowly from retained explicit-list and input-edge
|
||||
origins, including mixed and generated-family dependencies. CLI tests verify a
|
||||
profiled family catalog agrees with generated source attribution and that inferred
|
||||
ordinary edges name their input contributor instead of the central default.
|
||||
|
||||
Two new reporting paths lose available declaration information:
|
||||
|
||||
- **Profile contributions to family members:**
|
||||
`internal/config/artifact_catalog.go:52` fills a generated member's definitions
|
||||
from the single `member.Source`. That source is chosen by the first matching
|
||||
non-default family field in `artifactFamilySource`, not the full set of effective
|
||||
contributors. Define family `meta` in the base and override its `prompt_id` in
|
||||
`profiles/testing.yml`. `config artifacts --profile testing` reports only the
|
||||
base file for `meta_alice`, even though `config sources` correctly attributes
|
||||
that member's prompt to the testing overlay.
|
||||
- **Inferred ordinary dependencies:** With `second.inputs.prior.source` set to
|
||||
`narratio.artifact.first` and no explicit dependency list, `config sources`
|
||||
reports `scriptorium.artifacts.second.depends_on` as `default / default`.
|
||||
`finalizeArtifactDependencies` stores the input-edge origin, but the effective
|
||||
ownership projection retains the pre-inference default attribution from
|
||||
`internal/config/effective_digest.go:55` and does not consult that origin.
|
||||
|
||||
**Impact:** Operators inspecting a testing profile or following a dependency can
|
||||
be directed to the wrong file or told a user-derived value is a central default.
|
||||
Runtime ordering and generated artifact values were correct in these reproductions.
|
||||
|
||||
**Required change:** Use ConfigKit's contributing locations for family definition
|
||||
sets, as for ordinary artifacts, and add narrow generated attribution for inferred
|
||||
dependency lists using their retained edge origins. Preserve family/character/party
|
||||
identity and deterministic ordering. Avoid introducing a second general provenance
|
||||
store or choosing a file based on the first alphabetically visited field.
|
||||
|
||||
**Acceptance:** A profiled family catalog exposes both relevant files; an inferred
|
||||
edge points back to its input declaration; mixed explicit/inferred dependencies
|
||||
retain their relevant contributors. Verify catalog and sources agree after expansion.
|
||||
|
||||
## 6. Surface successful secrets bootstrap reports, including skipped empty variables
|
||||
|
||||
Status: Resolved — P3
|
||||
|
||||
Resolution: Successful bootstrap reports now flow once through the command
|
||||
diagnostics channel, while direct internal command calls default that channel to
|
||||
discard. ConfigKit's value-free report makes skipped existing empty variables
|
||||
explicit and remains available before later typed-load failures; structured output
|
||||
stays isolated on stdout. Command-level coverage verifies successful reporting,
|
||||
skipped-empty reporting, value redaction, clean config-show output, and one report
|
||||
for a two-profile diff.
|
||||
|
||||
**Evidence:** `internal/app/secrets_bootstrap.go:36` discards the report whenever
|
||||
`LoadSecretsDir` returns no error. Reports are only rendered when directory
|
||||
bootstrap itself fails. This does not implement the roadmap's safe bootstrap
|
||||
reporting boundary or Stage 6's warning presentation requirement.
|
||||
|
||||
**Reproduction:** Place a synthetic key in `secrets/NARRATIO_REVIEW_KEY`, reference
|
||||
it from `audita.llm_api_key`, and invoke config validation with
|
||||
`NARRATIO_REVIEW_KEY=` in the environment. ConfigKit correctly skips the file
|
||||
because the variable already exists, then typed loading fails with `secret.empty`.
|
||||
Narratio shows only that load failure, omitting the bootstrap report's
|
||||
`skipped_existing` and `ExistingEmpty` information. A successful load likewise
|
||||
discards all bootstrap results.
|
||||
|
||||
**Required change:** Retain and render or summarize the safe report once per
|
||||
invocation through a diagnostic output channel, including when a later typed load
|
||||
fails. Make skipped-existing-empty outcomes explicit. Preserve process environment
|
||||
precedence and the existing partial report on failure; keep values out of all
|
||||
diagnostics and keep structured/YAML stdout uncontaminated. Do not add bootstrap
|
||||
rollback, retries, or repeated directory loading.
|
||||
|
||||
**Acceptance:** Command-level tests demonstrate safe success/report output and a
|
||||
useful skipped-empty diagnostic, without leaking a synthetic secret or polluting
|
||||
show output. A two-profile diff still bootstraps and reports only once.
|
||||
|
||||
## 7. Do not resolve unused base credentials when loading a profile
|
||||
|
||||
Status: Resolved — P2
|
||||
|
||||
Resolution: Upgraded to released ConfigKit v0.1.2 and replaced the extra base load
|
||||
with `Metadata.HistoricalPaths()` enumeration of declaration paths in the selected
|
||||
result. Ownership checks still inspect base-layer history for both ordinary
|
||||
artifacts and families, even when overlays remove them. Regression tests verify
|
||||
that a profile can replace an unset production credential with a resolvable testing
|
||||
reference or remove it with null. Existing hidden-duplicate and legitimate-override
|
||||
tests remain intact. No local dependency replacement or second composer is needed.
|
||||
|
||||
Follow-up review: 2026-09-07 at `3ce1981`. The original six reproductions are
|
||||
addressed, but the fix for finding 3 introduces this regression.
|
||||
|
||||
**Evidence:** `internal/config/pipeline_configkit.go:49-57` loads the base again
|
||||
without a profile to check duplicate base definitions. This calls the complete
|
||||
typed ConfigKit loader, including secret resolution, domain resolution, and
|
||||
validation—not just declaration ownership inspection. Consequently, loading a
|
||||
valid testing profile also requires credentials from the unselected base.
|
||||
|
||||
**Reproduction:** In an otherwise valid base, set `audita.llm_api_key` to
|
||||
`NARRATIO_REVIEW_PROD_KEY`. In `profiles/testing.yml`, replace it with
|
||||
`NARRATIO_REVIEW_TEST_KEY`. Leave the production variable unset and supply only
|
||||
the testing variable with a synthetic value. `config validate --config <root>
|
||||
--profile testing` fails with `secret.unset` at the base credential declaration.
|
||||
Supplying both variables makes it pass. A profile that instead sets
|
||||
`audita.llm_api_key: null` also fails when the unused production variable is
|
||||
unset. Both failures were reproduced through the current CLI.
|
||||
|
||||
**Impact:** Operators cannot use an independent testing credential environment
|
||||
or remove a production credential requirement through a profile. Commands fail
|
||||
despite having all credentials required by their effective configuration. The
|
||||
extra full load also unnecessarily repeats configuration processing.
|
||||
|
||||
**Required change:** Enforce unique base ownership without requiring the
|
||||
unselected base to succeed as a separately resolved runtime configuration.
|
||||
Inspect complete declaration history through ConfigKit's public metadata,
|
||||
including declarations displaced by overlays. If additional upstream metadata
|
||||
support is needed, address that explicitly rather than requiring unused secrets,
|
||||
suppressing arbitrary load failures, reparsing YAML, or restoring a second
|
||||
composer. Preserve the duplicate-definition protection added for finding 3.
|
||||
|
||||
**Acceptance:** Add public-boundary regression tests for a profile replacing an
|
||||
unset production credential with an available testing credential, and for a
|
||||
profile removing that credential with null. Both must load successfully without
|
||||
the production variable. Retain failures for duplicate base ordinary/family
|
||||
definitions hidden by null overlays, along with legitimate profile override and
|
||||
profile-only definition coverage. Resolve secrets only for the selected effective
|
||||
configuration.
|
||||
|
||||
## Follow-up verification
|
||||
|
||||
After adopting ConfigKit v0.1.2 and completing finding 7, the full
|
||||
`scripts/check-release-candidate.sh v2.0.0` check passed: module hygiene,
|
||||
formatting/whitespace, uncached tests and race tests, vet, build, documentation,
|
||||
maintained examples, and Linux amd64/arm64 plus macOS arm64 release cross-builds.
|
||||
`go mod verify` also passed. The new credential regressions failed before the
|
||||
ownership-check fix and passed afterward. No local module replacements, release
|
||||
tags, or pushes were used.
|
||||
|
||||
At `3ce1981`, `go test ./...`, `go test -race ./...`, `go vet ./...`, and
|
||||
`go build ./...` passed. The new finding was reproduced with a freshly built CLI,
|
||||
temporary configuration files, and synthetic credentials; no external adapters
|
||||
or cloud services were invoked. That review pass changed only this review record.
|
||||
|
||||
## Original verification and scope
|
||||
|
||||
The following checks passed at the reviewed commit:
|
||||
|
||||
- `go test ./...`
|
||||
- `go test -race ./...`
|
||||
- `go vet ./...`
|
||||
- `go build ./...`
|
||||
- The release asset builder for `v2.0.0`, producing exactly Linux amd64, Linux
|
||||
arm64, and macOS arm64 assets; the Linux amd64 binary reported the expected version.
|
||||
|
||||
The existing full suites include documentation and maintained-example checks.
|
||||
The findings above were additionally reproduced through a freshly built CLI with
|
||||
isolated temporary YAML fixtures and synthetic credentials. No live downstream
|
||||
tools or cloud services were invoked. Linux arm64 and macOS arm64 were cross-built,
|
||||
not tested natively. No tag was created or pushed.
|
||||
|
||||
The migration removes a net 1,235 production Go lines and 843 test Go lines versus
|
||||
`1558c9c` (physical diff lines, including comments and whitespace). ConfigKit owns
|
||||
generic loading/composition; Narratio retains domain resolution, orchestration,
|
||||
safe inspection, and adapter delivery. These findings require completing that
|
||||
design, not reversing the simplification.
|
||||
# Transcription Checkpoint Review Remediation
|
||||
|
||||
Status: Complete.
|
||||
|
||||
Reviewed commits `7b0bf99` through `96b4e73` against the speaker-checkpoint and
|
||||
upload-replay roadmap and the original user request. No production changes were
|
||||
made during review. The two failures below were reproduced with temporary tests
|
||||
using the existing stage fixtures; those temporary tests were removed afterward.
|
||||
|
||||
## 1. Reconcile Aggregate State Even When No Speaker Needs HTTP Work
|
||||
|
||||
Priority: P2. Status: Complete.
|
||||
|
||||
Implemented read-only aggregate identity/evidence checks before resume skips.
|
||||
Regression tests cover removed inputs, missing/duplicate/mismatched outputs,
|
||||
repair without HTTP requests, downstream invalidation, and exclusion of removed
|
||||
speakers from merge without deleting their files. Equivalent path forms and
|
||||
output ordering remain reusable. Focused app/stage tests and documentation
|
||||
checks pass.
|
||||
|
||||
Location: `internal/stage/transcribe_resume.go:21` and the reconciliation result
|
||||
consumed there.
|
||||
|
||||
`ValidateResume` returns reusable whenever `reconciliation.work` is empty. That
|
||||
only establishes that the remaining speakers need no transcription; it does
|
||||
not establish that the stored speaker collection and aggregate outputs match
|
||||
the current input set. Removing a speaker while the remaining inputs are
|
||||
unchanged produces an empty work list, so a succeeded record can be skipped
|
||||
without pruning the removed speaker. Its old aggregate output can then remain
|
||||
available to merge. Likewise, missing/inconsistent aggregate output references
|
||||
are not repaired when all individual checkpoints remain valid. A prepare rerun
|
||||
may already stale transcribe in some workflows, but the resume validator cannot
|
||||
rely on that to satisfy its own current-input/output checks.
|
||||
|
||||
Reproduction: create complete alice/bob checkpoint records and aggregate
|
||||
outputs with the existing fixture, mark the stage succeeded, remove bob from
|
||||
the manifest's audio inputs, and call `ValidateResume`. It returns reusable
|
||||
despite still retaining bob in the stored collection and aggregate outputs.
|
||||
|
||||
Required change:
|
||||
|
||||
- Distinguish no HTTP work from no reconciliation work. Validate that the
|
||||
committed speaker set and aggregate transcript references represent the
|
||||
reconciled current set, including relevant identity/evidence, before skipping.
|
||||
- When only collection/output repair is needed, return non-resumable and let
|
||||
normal execution checkpoint the pruned collection and rebuild aggregate
|
||||
outputs without retranscribing reusable speakers. Preserve existing dependent
|
||||
invalidation; do not force otherwise-current speaker jobs.
|
||||
- Keep resume inspection read-only. Do not fix this by deleting incidental
|
||||
transcript files or changing stage selection rules.
|
||||
|
||||
Verification: add removed-only input coverage (not just removal combined with
|
||||
an added speaker), plus incomplete/inconsistent aggregate output coverage.
|
||||
Exercise the runner skip path and assert zero additional WhisperX requests,
|
||||
correct rebuilt outputs, and no removed speaker reaching merge.
|
||||
|
||||
## 2. Remove the Unrequested Positive Audio-Size Requirement
|
||||
|
||||
Priority: P2. Status: Complete.
|
||||
|
||||
Audio evidence now permits zero size while still rejecting negative values.
|
||||
Regression tests verify zero-size manifest round trips, successful service
|
||||
results being checkpointed and reused, and service rejection being propagated
|
||||
without creating a completion record. Nonempty valid-JSON output validation is
|
||||
unchanged. Both new success regressions failed before the fix.
|
||||
|
||||
Location: `internal/manifest/transcribe_state.go:95`.
|
||||
|
||||
The new record validator requires `AudioSize > 0`. Previously Narratio accepted
|
||||
a regular `.flac` file and delegated transcription acceptance to WhisperX;
|
||||
neither the user request nor the roadmap introduced minimum audio size or a new
|
||||
audio-content validation policy. Zero-byte content still has a valid checksum
|
||||
and can be represented by the checkpoint mechanism. The current check runs
|
||||
after a successful response and output materialization, causing the entire
|
||||
stage to fail instead of retaining the completed work.
|
||||
|
||||
Reproduction: prepare one speaker, replace its prepared audio with an empty
|
||||
regular file, and use the existing successful fake WhisperX client. Stage
|
||||
execution fails at checkpoint validation with `audio_size must be positive`.
|
||||
This demonstrates the extra Narratio restriction; it does not assert that a
|
||||
particular production WhisperX deployment accepts empty audio.
|
||||
|
||||
Required change:
|
||||
|
||||
- Permit zero audio size and reject negative sizes. Keep byte hashing and
|
||||
size equality checks. Let the downstream service decide whether the input
|
||||
can be transcribed, as before.
|
||||
- Do not remove the nonempty valid-JSON output requirement, which has a separate
|
||||
existing justification. Do not add a new early empty-audio rejection instead.
|
||||
- Update any new tests/documentation that promote strictly positive input size
|
||||
to a contract.
|
||||
|
||||
Verification: cover a zero-size record round-trip and successful adapter result
|
||||
being checkpointed/reused for that input; retain negative-size rejection and
|
||||
ordinary downstream-failure tests.
|
||||
|
||||
## Verification After Remediation
|
||||
|
||||
Completed on 2026-09-22 with Go 1.26.8: focused stage/application tests,
|
||||
changed-package race tests, `go test ./...`, `go test -race ./...`,
|
||||
`go vet ./...`, `go build ./...`, `go test ./internal/doccheck`,
|
||||
`go test ./internal/config -run '^TestExamplesLoadAndValidate$'`, and
|
||||
`git diff --check` all passed. Existing replay and cancellation regression
|
||||
tests remain intact; the HTTP adapter required no changes.
|
||||
|
||||
183
docs/roadmap/transcribe-checkpoints-and-replay.md
Normal file
183
docs/roadmap/transcribe-checkpoints-and-replay.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Resumable Speaker Transcription and Replayable Uploads
|
||||
|
||||
Status: Implemented.
|
||||
|
||||
## Goal and User Intent
|
||||
|
||||
Preserve completed speaker transcriptions when another speaker fails, so an
|
||||
ordinary run or resume does not repeat expensive successful work. Separately,
|
||||
allow Go's HTTP transport to replay WhisperX uploads when it encounters a
|
||||
recoverable connection or stream failure.
|
||||
|
||||
The motivating incident exhausted four transcription attempts for one speaker
|
||||
after three other speakers had completed. The final error reported an HTTP/2
|
||||
peer `PROTOCOL_ERROR` and an inability to replay the request body without
|
||||
`Request.GetBody`.
|
||||
|
||||
These are related reliability improvements, not a promise to eliminate all
|
||||
service failures. This document owns the target state and scope; the
|
||||
[implementation plan](implementation.md) owns ordered changes and concrete
|
||||
storage and replay decisions.
|
||||
|
||||
## Pre-implementation Limitations
|
||||
|
||||
The [transcribe stage](../internal/stage-transcribe.md) writes successful
|
||||
responses into the invocation's run directory. It installs canonical transcripts
|
||||
and returns its per-file metadata only after every speaker succeeds. A failure
|
||||
therefore leaves no durable per-speaker progress that ordinary resume can use,
|
||||
even though some completed JSON files may remain on disk.
|
||||
|
||||
The [WhisperX adapter](../integrations/whisperx.md) already retries requests at
|
||||
the application level, reopening audio for each attempt. Its multipart request
|
||||
body is streamed through a pipe and has no replay callback. Transport-level
|
||||
recovery within an attempt cannot reconstruct a consumed body. Providing that
|
||||
capability addresses the replay limitation, not the underlying peer protocol
|
||||
error; persistent failures can still require service or proxy investigation.
|
||||
|
||||
## Targeted End State
|
||||
|
||||
### Durable Per-Speaker Completion
|
||||
|
||||
Each successfully transcribed speaker file is validated, saved, and recorded
|
||||
durably before that work is treated as reusable. The session manifest remains
|
||||
the progress authority. Follow the existing analyze-stage checkpoint pattern
|
||||
without turning speakers into pipeline stages or introducing a general task
|
||||
engine.
|
||||
|
||||
Ordinary execution reconciles the current prepared audio set with recorded
|
||||
speaker results. It reuses valid completed work and schedules failed,
|
||||
unattempted, missing, or obsolete results. A result is not reusable merely
|
||||
because a matching filename exists. Evidence binds it to the speaker/audio
|
||||
identity, audio content, Narratio-visible recognition settings, and the saved
|
||||
transcript's integrity.
|
||||
|
||||
The relevant recognition settings remain the WhisperX service URL and language.
|
||||
Retry count/delay, concurrency, timeout, credentials, and diagnostic locations
|
||||
remain operational settings rather than reasons to discard completed results.
|
||||
Changes to private service models or configuration remain an operator reason
|
||||
to force transcription; Narratio does not inspect the service's private state.
|
||||
|
||||
Changes to individual audio inputs require replacement of affected results,
|
||||
not automatic retranscription of unrelated unchanged speakers. New speakers
|
||||
need transcription; removed speakers do not remain in the current aggregate
|
||||
output set. Records must describe the inputs actually used, not grant reuse to
|
||||
old bytes under a changed identity.
|
||||
|
||||
### Aggregate Completion, Failure, and Force
|
||||
|
||||
The overall stage succeeds only when every currently required speaker has a
|
||||
validated, durably recorded result. Incomplete per-speaker progress does not
|
||||
make the aggregate stage successful or authorize downstream consumption of an
|
||||
incomplete collection. Existing dependent-stage invalidation remains in effect.
|
||||
|
||||
Retain bounded concurrency and the current fail-fast behavior: a speaker
|
||||
failure cancels outstanding work. Results already checkpointed remain reusable
|
||||
after failure, cancellation, or process interruption. A later ordinary run or
|
||||
resume retries the remaining work; it does not need a new CLI command.
|
||||
|
||||
Checkpoint writes must be serialized so concurrently finishing speakers cannot
|
||||
overwrite each other's progress. A failed persistence operation must not
|
||||
advertise an unrecorded result as complete. Previously committed progress remains
|
||||
the recovery authority, and checkpoint errors must be surfaced rather than
|
||||
reported as successful transcription.
|
||||
|
||||
`--force` on transcribe regenerates all currently required speakers instead of
|
||||
reusing their prior completions. Successful replacements are checkpointed too,
|
||||
so an ordinary retry after a failed forced invocation can retain the newly
|
||||
completed work. Old results for speakers not successfully regenerated must not
|
||||
satisfy that unfinished forced replacement on the next ordinary retry.
|
||||
|
||||
This deliberately relaxes the current all-speakers-before-canonical-installation
|
||||
implementation: individual durable results may exist while the stage remains
|
||||
failed or incomplete. The all-speakers-before-stage-success boundary remains.
|
||||
|
||||
### Replayable WhisperX Requests
|
||||
|
||||
Provide `Request.GetBody` so the HTTP transport can obtain a fresh body for the
|
||||
same request. Replay reopens the audio and reproduces the multipart boundary,
|
||||
filename, fields, and framing used by the original request. Independent bodies
|
||||
must not share a consumed reader or mutable read position.
|
||||
|
||||
Preserve bounded-memory streaming rather than buffering entire audio files in
|
||||
memory. Correctly close every original and replay body and its audio resources;
|
||||
preserve source-read errors and cancellation, including partial uploads and
|
||||
early server responses. Successful response handling must not accidentally wait
|
||||
on, or report errors solely from, an abandoned body instead of the replay that
|
||||
produced the response.
|
||||
|
||||
Keep the existing application-level retry count, delay, classification, and
|
||||
per-attempt timeout semantics. Transport replays occur within an application
|
||||
attempt and its context; they are not additional Narratio-configured attempts.
|
||||
Do not add another custom retry loop, silently increase retries, or disable
|
||||
HTTP/2 globally as a substitute for replay support.
|
||||
|
||||
The adapter still validates and installs only successful JSON responses.
|
||||
Request replay does not provide exactly-once server execution: a lost response
|
||||
can cause repeated transcription work, as can existing application retries.
|
||||
No new service-side idempotency protocol is required by this feature.
|
||||
|
||||
## Required Change Areas
|
||||
|
||||
- **Manifest and persistence:** Add transcribe-owned per-speaker progress and
|
||||
evidence, with safe checkpoint updates and preservation through runner failure
|
||||
handling. Review manifest validation, copying, serialization, and existing
|
||||
publish/restore handling wherever the new records pass through those paths.
|
||||
- **Stage execution and reuse:** Reconcile speaker records before scheduling,
|
||||
checkpoint validated completions, rebuild aggregate outputs from the current
|
||||
set, and account for partial progress in ordinary and forced execution.
|
||||
Review successful-stage reuse as well as failed-stage retries so stale audio
|
||||
or missing/corrupt transcript evidence is not bypassed by an aggregate skip.
|
||||
- **Artifact lifecycle:** Use existing path, installation, integrity, and manifest
|
||||
mechanisms. Ensure checkpointed results do not depend on an ephemeral run
|
||||
directory surviving cleanup. Do not introduce a second authoritative ledger
|
||||
or infer completion by scanning abandoned runs.
|
||||
- **HTTP adapter:** Make multipart bodies reproducible and correctly manage
|
||||
every replay's lifetime, without changing the WhisperX HTTP schema or requiring
|
||||
an upstream service change.
|
||||
- **Tests:** Cover per-speaker recovery after a later failure, interruption and
|
||||
checkpoint failure; changed/missing inputs or outputs; force followed by an
|
||||
ordinary retry; concurrent successful completions; and aggregate downstream
|
||||
gating. Extend existing fixtures rather than duplicating analyze's complete
|
||||
test suite. Exercise actual body reconstruction after partial consumption,
|
||||
multipart equivalence, cancellation/resource cleanup, and existing bounded
|
||||
streaming and retry behavior—not just the presence of a callback. Default
|
||||
tests remain offline and use local/fake HTTP boundaries, never paid services.
|
||||
- **Documentation:** Update the canonical
|
||||
[stage guide](../internal/stage-transcribe.md),
|
||||
[manifest guide](../internal/manifest.md),
|
||||
[WhisperX contract](../integrations/whisperx.md),
|
||||
[operations guide](../operations.md), and
|
||||
[troubleshooting guide](../troubleshooting.md) when implemented. Explain
|
||||
partial completion, ordinary retry versus force, and the distinction between
|
||||
request replay limitations and persistent peer protocol failures.
|
||||
|
||||
## Compatibility and Scope Boundaries
|
||||
|
||||
Existing speaker identities, raw transcript formats, and the fixed pipeline
|
||||
order remain unchanged. Existing manifests remain readable, but must not be
|
||||
treated as containing per-speaker evidence they never recorded. Recovery relies
|
||||
on verified recorded evidence, not guessed associations between old transcripts
|
||||
and current audio. No general legacy-migration framework is part of the feature.
|
||||
|
||||
Automatic adoption of unrecorded outputs from previously failed runs is outside
|
||||
this feature. Such files may be recoverable after verifying their inputs, but
|
||||
their presence alone does not prove completion. No recovery/import command,
|
||||
speaker-selection CLI, historical invocation ledger, new service configuration
|
||||
discovery, or broader orchestration refactor is included.
|
||||
|
||||
The feature does not diagnose the remote service/proxy's underlying HTTP/2
|
||||
problem, guarantee recovery from persistent protocol errors, or change the
|
||||
policy for continuing unrelated work after failure.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
For speakers A, B, C, and D: if A and B are checkpointed, C fails, and D has not
|
||||
completed, ordinary resume retains A and B, retries C, and completes D. No
|
||||
downstream stage receives an incomplete successful transcription collection.
|
||||
Force replaces all four, while an ordinary retry can reuse checkpoints from
|
||||
that forced replacement attempt.
|
||||
|
||||
The HTTP transport can reconstruct the upload after consuming the original
|
||||
body, without whole-file memory buffering, lost source errors, or stranded
|
||||
upload resources. A persistent peer error still fails normally after the
|
||||
configured application attempts, without discarding checkpointed speakers.
|
||||
@@ -213,6 +213,134 @@ input validation.
|
||||
|
||||
Relevant reference: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior).
|
||||
|
||||
## Transcription retries speakers that previously finished
|
||||
|
||||
Symptom:
|
||||
|
||||
- an ordinary transcribe retry calls WhisperX for a speaker that appeared to
|
||||
finish earlier; or
|
||||
- an older successful session retranscribes all speakers once after upgrade.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- the earlier response or canonical file was not followed by a successful
|
||||
speaker manifest checkpoint;
|
||||
- the prepared audio bytes/path, WhisperX URL, or language changed;
|
||||
- the canonical transcript is missing, unsafe, non-regular, invalid JSON, or
|
||||
no longer matches its recorded size/checksum;
|
||||
- the previous invocation used `--force`, which durably cleared pre-force
|
||||
speaker authority; or
|
||||
- the manifest has only legacy aggregate transcribe state and cannot bind old
|
||||
outputs to their source audio.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- inspect the reported speaker and filesystem error, repair the prepared input
|
||||
or canonical-output path when needed, then retry transcribe normally;
|
||||
- allow a legacy aggregate-only record to regenerate once; and
|
||||
- do not edit the manifest or copy an old JSON file into place as a substitute
|
||||
for a successful checkpoint.
|
||||
|
||||
```bash
|
||||
narratio run-stage transcribe 2026-04-04
|
||||
```
|
||||
|
||||
Valid earlier speaker checkpoints remain reusable even while aggregate
|
||||
transcribe status is failed or interrupted. Merge remains gated until
|
||||
transcribe reaches aggregate success with the complete current set.
|
||||
|
||||
Relevant reference: [Operations: Transcription continuation and replacement](./operations.md#transcription-continuation-and-replacement).
|
||||
|
||||
## WhisperX model changed but transcription was reused
|
||||
|
||||
Symptom:
|
||||
|
||||
- WhisperX model weights, prompts, or private server configuration changed at
|
||||
the same configured URL, but Narratio still considers speaker transcripts
|
||||
current.
|
||||
|
||||
Likely cause:
|
||||
|
||||
- Narratio can fingerprint its configured URL and language, not private state
|
||||
loaded behind the service boundary.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- explicitly force transcribe to establish a durable reset, then use ordinary
|
||||
retry if the replacement attempt is interrupted.
|
||||
|
||||
```bash
|
||||
narratio run-stage transcribe 2026-04-04 --force
|
||||
```
|
||||
|
||||
Transport replay and application retries do not provide an exactly-once remote
|
||||
execution guarantee. Investigate server-side failures or duplicate work using
|
||||
WhisperX diagnostics; do not infer completion from a server log without the
|
||||
local speaker checkpoint.
|
||||
|
||||
Relevant references: [WhisperX integration](./integrations/whisperx.md) and
|
||||
[Transcribe internals](./internal/stage-transcribe.md).
|
||||
|
||||
## Transcription manifest checkpoint failed
|
||||
|
||||
Symptom:
|
||||
|
||||
- transcribe reports that a speaker completed but the session manifest could
|
||||
not be saved.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- the workspace is unwritable or out of space;
|
||||
- the manifest path or parent directory is unsafe or the wrong type; or
|
||||
- a transient filesystem error interrupted atomic replacement.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- correct the reported filesystem or permission problem and retry transcribe
|
||||
normally. Narratio reconciles from the last readable collection and reuses
|
||||
only successfully checkpointed speakers.
|
||||
|
||||
A canonical raw transcript left by the failed checkpoint is incidental and
|
||||
does not become reusable authority. Preserve the manifest and diagnostics if
|
||||
the failure repeats; do not advertise the file by editing manifest evidence.
|
||||
|
||||
Relevant references: [Manifest persistence](./internal/manifest.md#persistence-semantics)
|
||||
and [Transcribe internals](./internal/stage-transcribe.md).
|
||||
|
||||
## Notarius credential reference unavailable
|
||||
|
||||
Symptom:
|
||||
|
||||
- configuration loading reports `secret.unset`, `secret.empty`, or an invalid
|
||||
name for `pipeline.notarius.llm_api_key`, including during inspection or a
|
||||
run that would not execute extraction.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- the named environment variable is absent or empty;
|
||||
- the selected secrets directory lacks a file with that exact name;
|
||||
- a process environment variable exists but is empty, so bootstrap correctly
|
||||
leaves it in place; or
|
||||
- the Narratio reference name does not match `api_key_env` in the selected
|
||||
Notarius/PromptKit profile.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- set the exact nonempty environment variable or provide an equivalently named
|
||||
file through the conventional or explicit secrets directory;
|
||||
- remove or set the field to `null` when that deployment needs no credential;
|
||||
and
|
||||
- use `config show`, `config sources`, and `config diff` to inspect the public
|
||||
reference name and ownership without exposing its value.
|
||||
|
||||
Narratio resolves selected credential references during ordinary pipeline
|
||||
loading, before stage selection or resume. Credential changes alone do not
|
||||
invalidate a completed extraction, but the current reference must still resolve
|
||||
for the command to proceed.
|
||||
|
||||
Relevant references: [Secrets](./config.md#secrets-handling) and
|
||||
[Notarius credential](./config.md#notarius-credential).
|
||||
|
||||
## Notarius executable missing
|
||||
|
||||
Symptom:
|
||||
@@ -289,6 +417,59 @@ Do not point Notarius directly at the original source path or edit the manifest
|
||||
checksum. Relevant references: [Notarius reference configuration](./config.md#notarius-reference-bindings)
|
||||
and [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||
|
||||
## Notarius reference snapshot cache conflict
|
||||
|
||||
Symptom:
|
||||
|
||||
- extraction reports that a path beneath
|
||||
`cache/notarius/references/<sha256>/` is a symlink, directory, or another
|
||||
non-regular or unsafe entry.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- stop commands for the affected session and inspect the exact reported path;
|
||||
- investigate unexpected links or special files as possible tampering; and
|
||||
- remove only the reported conflicting leaf or digest directory after
|
||||
confirming it belongs to the reconstructible reference cache.
|
||||
|
||||
A missing snapshot or corrupt regular snapshot is reconstructed automatically
|
||||
from currently verified prepared input. Do not recursively delete the session
|
||||
root, edit the manifest checksum, or remove cache entries while extraction is
|
||||
active.
|
||||
|
||||
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||
|
||||
## Notarius resume executes work instead of reusing it
|
||||
|
||||
Symptom:
|
||||
|
||||
- Narratio executes `extract`, and Notarius makes provider calls despite
|
||||
`pipeline.notarius.resume: true`.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- checkpoint recording is disabled in the Notarius configuration;
|
||||
- Notarius is using a nonpersistent or different checkpoint root;
|
||||
- transcript/reference bytes, reference paths, pipeline/profile content,
|
||||
local-backend endpoint, selected lanes, or another checkpoint identity input
|
||||
changed; or
|
||||
- Narratio extraction was forced, which deliberately omits `--resume`.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- validate the deployed Notarius configuration and configure explicit
|
||||
persistent chunk-plan and checkpoint roots;
|
||||
- inspect Notarius checkpoint decision reason codes for a legitimate cold miss;
|
||||
and
|
||||
- retry without force only when reuse is intended.
|
||||
|
||||
Remember that Narratio skipping a completed `extract` stage is separate from
|
||||
Notarius reusing work inside an extraction that does execute. Narratio does not
|
||||
read the Notarius configuration to infer checkpoint enablement.
|
||||
|
||||
Relevant references: [Notarius cache controls](./config.md#notarius-cache-controls)
|
||||
and [Notarius integration](./integrations/notarius.md).
|
||||
|
||||
## Notarius reference selector or generated-handoff collision
|
||||
|
||||
Symptom:
|
||||
@@ -361,10 +542,10 @@ Symptom:
|
||||
|
||||
Likely causes:
|
||||
|
||||
- the executable/config path, pipeline ID, timeout, working directory, or
|
||||
configured output contracts changed;
|
||||
- the pipeline ID or configured output contracts changed;
|
||||
- a configured prepared reference selector, source, path, checksum, or byte
|
||||
size changed;
|
||||
- the direct trimmed transcript identity or bytes changed; or
|
||||
- the durable bundle, index, lane set, provenance, regular-file status, or
|
||||
checksum no longer validates.
|
||||
|
||||
@@ -374,6 +555,11 @@ Safe fix:
|
||||
an unsafe path or bundle symlink error as filesystem corruption or tampering
|
||||
and investigate it rather than replacing files manually.
|
||||
|
||||
Executable, config path, timeout, working directory, and Notarius credential
|
||||
changes are operational and do not by themselves invalidate reusable extraction
|
||||
evidence. A newly selected credential must nevertheless resolve during command
|
||||
configuration loading.
|
||||
|
||||
## Notarius transitive configuration changed
|
||||
|
||||
Symptom:
|
||||
@@ -488,18 +674,22 @@ Symptom:
|
||||
or unsupported; or
|
||||
- restore rejects a committed remote session manifest for the same reason.
|
||||
|
||||
Likely cause:
|
||||
Likely causes:
|
||||
|
||||
- the manifest predates the supported canonical state contract. Narratio
|
||||
rejects it at the schema boundary rather than interpreting aggregate output
|
||||
lists or incidental files as current authority.
|
||||
lists or incidental files as current authority; or
|
||||
- an older strict-decoding Narratio binary is reading a current manifest that
|
||||
contains additive transcribe speaker state it does not recognize.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- for unsupported local state, move the old manifest aside and regenerate, or
|
||||
restore a verified current committed snapshot when one is authoritative;
|
||||
- for unsupported committed remote state, regenerate healthy local state as
|
||||
needed and republish the session.
|
||||
needed and republish the session; and
|
||||
- for a downgrade rejection, return to the current binary or restore a verified
|
||||
pre-upgrade backup. Do not delete only the speaker fields by hand.
|
||||
|
||||
```bash
|
||||
narratio run 2026-04-04
|
||||
@@ -511,7 +701,7 @@ old files into canonical paths merely to influence selection; availability is
|
||||
manifest-owned.
|
||||
|
||||
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
|
||||
and [Manifest Internals](./internal/manifest.md#analyze-owned-artifact-state).
|
||||
and [Manifest Internals](./internal/manifest.md#transcribe-owned-speaker-state).
|
||||
|
||||
## Scriptorium private input changed without a rerun
|
||||
|
||||
@@ -726,7 +916,7 @@ Diagnostics:
|
||||
|
||||
```bash
|
||||
ls -la /path/to/secrets_dir
|
||||
env | sed 's/=.*//' | grep -E 'OBJECT_STORAGE|AWS|AUDITA|SCRIPTORIUM'
|
||||
env | sed 's/=.*//' | grep -E 'OBJECT_STORAGE|AWS|AUDITA|NOTARIUS|SCRIPTORIUM'
|
||||
```
|
||||
|
||||
Safe fix:
|
||||
|
||||
@@ -60,17 +60,17 @@ with the sample campaign and a compatible local- or S3-audio session.
|
||||
[glossary](campaigns/sample-campaign/glossary.yml), and canonical
|
||||
[party](campaigns/sample-campaign/party.yml) fixture, plus an optional
|
||||
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
|
||||
follows the Notarius v0.6 contract. Narratio derives the players projection
|
||||
follows the Notarius v0.7 contract. Narratio derives the players projection
|
||||
from this party source.
|
||||
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
|
||||
reserves the expected filename and directory shape. Replace it with a real
|
||||
FLAC file before running transcription.
|
||||
|
||||
The examples contain environment-variable names but no credential values. Set
|
||||
`OBJECT_STORAGE_KEY_ID`, `OBJECT_STORAGE_KEY`, and `AUDITA_LLM_API_KEY` to
|
||||
nonempty synthetic or real values before loading the production-shaped and full
|
||||
annotated pipelines. They use fictional campaign content and reserved example
|
||||
domains.
|
||||
`OBJECT_STORAGE_KEY_ID`, `OBJECT_STORAGE_KEY`, `AUDITA_LLM_API_KEY`, and
|
||||
`NOTARIUS_LLM_API_KEY` to nonempty synthetic or real values before loading the
|
||||
examples that reference them. They use fictional campaign content and reserved
|
||||
example domains.
|
||||
|
||||
For a deployed bundle, keep credential files out of source control in a
|
||||
`secrets/` directory beside the root `pipeline.yml`, one file per referenced
|
||||
|
||||
@@ -128,13 +128,26 @@ trim:
|
||||
report: false
|
||||
|
||||
notarius:
|
||||
# Optional structured extraction between trim and render.
|
||||
# Optional structured extraction after render and before analyze.
|
||||
enabled: true
|
||||
binary: notarius
|
||||
# Optional environment-variable reference. Match this name to api_key_env in
|
||||
# the selected Notarius/PromptKit profile; never put the credential here.
|
||||
llm_api_key: NOTARIUS_LLM_API_KEY
|
||||
# Optionally select a Notarius ConfigKit overlay when extraction runs. This
|
||||
# is independent of Narratio's selected profile and may use a different name.
|
||||
# See docs/config.md and docs/integrations/notarius.md for the contracts.
|
||||
# config_profile: downstream-economy
|
||||
config_path: /usr/local/etc/notarius/config.yml
|
||||
pipeline_id: dnd-session
|
||||
timeout: 3h
|
||||
working_directory: /usr/local/etc/notarius
|
||||
# Reuse compatible accepted Notarius checkpoints during executed extraction.
|
||||
# The Notarius config must enable recording and provide persistent cache roots.
|
||||
resume: true
|
||||
# auto uses or creates a source-addressed chunk plan. Forced Narratio
|
||||
# extraction overrides this with refresh and disables checkpoint loading.
|
||||
chunk_cache: auto
|
||||
# External campaign references use prepared Narratio source IDs. Omit an
|
||||
# optional binding when the selected Notarius pipeline does not need it.
|
||||
references:
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module gitea.maximumdirect.net/eric/narratio
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.1.2
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.3.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.1.2 h1:uZ5Mvf271SLItXEvUpQYfAvCOVYj9RaoG0auCcbCcSU=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.1.2/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.3.1 h1:etfTI/dxO3NnS18La0V6IeUh/Iv+3PjVfEyaN8hTuOM=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.3.1/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||
|
||||
@@ -15,6 +15,10 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
|
||||
return RunResult{}, err
|
||||
}
|
||||
copyRequest := req
|
||||
if req.ConfigProfile != nil {
|
||||
profile := *req.ConfigProfile
|
||||
copyRequest.ConfigProfile = &profile
|
||||
}
|
||||
copyRequest.References = append([]ReferenceBinding(nil), req.References...)
|
||||
f.Requests = append(f.Requests, copyRequest)
|
||||
if f.Err != nil {
|
||||
|
||||
@@ -4,6 +4,8 @@ package notarius
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
const ReceiptSchemaVersion = "notarius.run-result.v2"
|
||||
@@ -22,7 +24,10 @@ type ReferenceBinding struct {
|
||||
|
||||
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
|
||||
type RunRequest struct {
|
||||
Binary string
|
||||
Binary string
|
||||
// LLMAPIKey is an optional already-resolved credential reference.
|
||||
LLMAPIKey *configkit.Secret
|
||||
ConfigProfile *string
|
||||
ConfigPath string
|
||||
PipelineID string
|
||||
InputPath string
|
||||
@@ -31,6 +36,8 @@ type RunRequest struct {
|
||||
ReceiptPath string
|
||||
LogPath string
|
||||
Timeout time.Duration
|
||||
Resume bool
|
||||
ChunkCache string
|
||||
References []ReferenceBinding
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,20 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
var envOverrides map[string]string
|
||||
var sensitiveEnvNames []string
|
||||
if req.LLMAPIKey != nil {
|
||||
if !req.LLMAPIKey.IsResolved() {
|
||||
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
|
||||
}
|
||||
name := req.LLMAPIKey.EnvName()
|
||||
value := req.LLMAPIKey.Value()
|
||||
if value == "" {
|
||||
return RunResult{}, fmt.Errorf("notarius LLM API credential must be resolved and nonempty")
|
||||
}
|
||||
envOverrides = map[string]string{name: value}
|
||||
sensitiveEnvNames = []string{name}
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"run", req.PipelineID,
|
||||
@@ -59,18 +73,29 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
"--input", req.InputPath,
|
||||
"--output-dir", req.OutputRoot,
|
||||
}
|
||||
if req.Resume {
|
||||
args = append(args, "--resume")
|
||||
}
|
||||
if req.ChunkCache != "" {
|
||||
args = append(args, "--chunk_cache", req.ChunkCache)
|
||||
}
|
||||
for _, reference := range references {
|
||||
args = append(args, "--reference", reference.Selector+"="+reference.Path)
|
||||
}
|
||||
if req.ConfigProfile != nil {
|
||||
args = append(args, "--config-profile", *req.ConfigProfile)
|
||||
}
|
||||
args = append(args, "--json")
|
||||
processResult, err := r.run(ctx, subprocess.RunRequest{
|
||||
Executable: req.Binary,
|
||||
Args: args,
|
||||
WorkingDir: req.WorkingDirectory,
|
||||
Timeout: req.Timeout,
|
||||
DiagnosticOwner: "notarius",
|
||||
StdoutLogPath: req.ReceiptPath,
|
||||
StderrLogPath: req.LogPath,
|
||||
Executable: req.Binary,
|
||||
Args: args,
|
||||
WorkingDir: req.WorkingDirectory,
|
||||
Timeout: req.Timeout,
|
||||
DiagnosticOwner: "notarius",
|
||||
StdoutLogPath: req.ReceiptPath,
|
||||
StderrLogPath: req.LogPath,
|
||||
EnvOverrides: envOverrides,
|
||||
SensitiveEnvNames: sensitiveEnvNames,
|
||||
})
|
||||
baseResult := RunResult{
|
||||
ReceiptPath: req.ReceiptPath,
|
||||
@@ -143,6 +168,9 @@ func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
|
||||
if req.Timeout <= 0 {
|
||||
return nil, fmt.Errorf("notarius timeout must be positive")
|
||||
}
|
||||
if !validChunkCacheMode(req.ChunkCache) {
|
||||
return nil, fmt.Errorf("notarius chunk cache mode must be empty, auto, bypass, or refresh")
|
||||
}
|
||||
for label, path := range map[string]string{
|
||||
"config": req.ConfigPath,
|
||||
"input": req.InputPath,
|
||||
@@ -201,6 +229,15 @@ func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
|
||||
return references, nil
|
||||
}
|
||||
|
||||
func validChunkCacheMode(mode string) bool {
|
||||
switch mode {
|
||||
case "", "auto", "bypass", "refresh":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type receiptDocument struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
RunID string `json:"run_id"`
|
||||
@@ -248,7 +285,7 @@ func validateValidationSummaries(documents []validationSummaryDocument) ([]Valid
|
||||
if document.Status != "complete" && document.Status != "rejected" && document.Status != "incomplete" {
|
||||
return nil, fmt.Errorf("notarius validation summary status %q is invalid", document.Status)
|
||||
}
|
||||
if document.ProducerAttemptCount == nil || *document.ProducerAttemptCount <= 0 || !validTerminalAction(document.TerminalAction) {
|
||||
if document.ProducerAttemptCount == nil || *document.ProducerAttemptCount < 0 || !validTerminalAction(document.TerminalAction) {
|
||||
return nil, fmt.Errorf("notarius validation summary is missing required fields")
|
||||
}
|
||||
summaries = append(summaries, ValidationSummary{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
sharedsubprocess "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||
)
|
||||
|
||||
@@ -76,6 +77,174 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerMapsCacheControlsToArguments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resume bool
|
||||
chunkCache string
|
||||
want []string
|
||||
}{
|
||||
{name: "resume", resume: true, want: []string{"--resume"}},
|
||||
{name: "auto", chunkCache: "auto", want: []string{"--chunk_cache", "auto"}},
|
||||
{name: "bypass", chunkCache: "bypass", want: []string{"--chunk_cache", "bypass"}},
|
||||
{name: "refresh", chunkCache: "refresh", want: []string{"--chunk_cache", "refresh"}},
|
||||
{name: "resume and mode", resume: true, chunkCache: "auto", want: []string{"--resume", "--chunk_cache", "auto"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
req.Resume = test.resume
|
||||
req.ChunkCache = test.chunkCache
|
||||
var captured sharedsubprocess.RunRequest
|
||||
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
captured = processReq
|
||||
writeValidBundleAndReceipt(t, req, false)
|
||||
return sharedsubprocess.RunResult{ExitCode: 0}, nil
|
||||
}}
|
||||
|
||||
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"run", req.PipelineID, "--config", req.ConfigPath, "--input", req.InputPath,
|
||||
"--output-dir", req.OutputRoot,
|
||||
}
|
||||
want = append(want, test.want...)
|
||||
want = append(want, "--json")
|
||||
if !reflect.DeepEqual(captured.Args, want) {
|
||||
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerMapsConfigProfileToArgumentPair(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
supplied bool
|
||||
value string
|
||||
}{
|
||||
{name: "omitted"},
|
||||
{name: "empty", supplied: true},
|
||||
{name: "default", supplied: true, value: "default"},
|
||||
{name: "named", supplied: true, value: "inexpensive"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
if test.supplied {
|
||||
req.ConfigProfile = &test.value
|
||||
}
|
||||
var captured sharedsubprocess.RunRequest
|
||||
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
captured = processReq
|
||||
writeValidBundleAndReceipt(t, req, false)
|
||||
return sharedsubprocess.RunResult{ExitCode: 0}, nil
|
||||
}}
|
||||
|
||||
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
flagIndex := -1
|
||||
for index, arg := range captured.Args {
|
||||
if arg == "--config-profile" {
|
||||
flagIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if !test.supplied {
|
||||
if flagIndex >= 0 {
|
||||
t.Fatalf("subprocess args unexpectedly contain config profile: %#v", captured.Args)
|
||||
}
|
||||
return
|
||||
}
|
||||
if flagIndex < 0 || flagIndex+2 >= len(captured.Args) || captured.Args[flagIndex+1] != test.value || captured.Args[flagIndex+2] != "--json" {
|
||||
t.Fatalf("config profile argument boundary = %#v, want flag, exact value, then --json", captured.Args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRejectsInvalidChunkCacheBeforeLaunch(t *testing.T) {
|
||||
for _, mode := range []string{"AUTO", "invalid", " auto "} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
req.ChunkCache = mode
|
||||
started := false
|
||||
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
started = true
|
||||
return sharedsubprocess.RunResult{}, nil
|
||||
}}
|
||||
_, err := runner.Run(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), "chunk cache mode") {
|
||||
t.Fatalf("Run() error = %v, want chunk cache validation failure", err)
|
||||
}
|
||||
if started {
|
||||
t.Fatal("subprocess started with invalid chunk cache mode")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerForwardsLoadedCredentialWithoutRereadingEnvironment(t *testing.T) {
|
||||
const envName = "NARRATIO_NOTARIUS_CUSTOM_LLM_KEY"
|
||||
const loadedValue = "loaded-notarius-credential"
|
||||
credential := loadTestCredential(t, envName, loadedValue)
|
||||
t.Setenv(envName, "changed-parent-credential")
|
||||
|
||||
req := validRunRequest(t)
|
||||
req.LLMAPIKey = credential
|
||||
var captured sharedsubprocess.RunRequest
|
||||
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
captured = processReq
|
||||
writeValidBundleAndReceipt(t, req, false)
|
||||
return sharedsubprocess.RunResult{ExitCode: 0}, nil
|
||||
}}
|
||||
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(captured.EnvOverrides) != 1 || captured.EnvOverrides[envName] != loadedValue {
|
||||
t.Fatal("subprocess did not receive exactly the loaded credential override")
|
||||
}
|
||||
if !reflect.DeepEqual(captured.SensitiveEnvNames, []string{envName}) {
|
||||
t.Fatalf("sensitive environment names = %#v, want configured reference name", captured.SensitiveEnvNames)
|
||||
}
|
||||
wantArgs := []string{
|
||||
"run", req.PipelineID, "--config", req.ConfigPath, "--input", req.InputPath,
|
||||
"--output-dir", req.OutputRoot, "--json",
|
||||
}
|
||||
if !reflect.DeepEqual(captured.Args, wantArgs) {
|
||||
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRejectsInvalidProgrammaticCredentialBeforeLaunch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
reference configkit.Secret
|
||||
}{
|
||||
{name: "unresolved", reference: configkit.SecretEnv("NARRATIO_NOTARIUS_UNRESOLVED_KEY")},
|
||||
{name: "empty", reference: configkit.SecretEnv("")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
req.LLMAPIKey = &test.reference
|
||||
started := false
|
||||
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
started = true
|
||||
return sharedsubprocess.RunResult{}, nil
|
||||
}}
|
||||
_, err := runner.Run(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), "resolved and nonempty") {
|
||||
t.Fatalf("Run() error = %v, want credential validation failure", err)
|
||||
}
|
||||
if started {
|
||||
t.Fatal("subprocess started with an invalid credential")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
referenceRoot := t.TempDir()
|
||||
@@ -172,16 +341,23 @@ func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
|
||||
const envName = "NARRATIO_NOTARIUS_CUSTOM_CAPTURE_KEY"
|
||||
const canary = "notarius-captured-stream-canary"
|
||||
const unrelated = "NARRATIO_NOTARIUS_UNRELATED_PARENT_KEY"
|
||||
req := validRunRequest(t)
|
||||
req.LLMAPIKey = loadTestCredential(t, envName, canary)
|
||||
writeValidBundleAndReceipt(t, req, false)
|
||||
receiptFixture := req.ReceiptPath + ".fixture"
|
||||
data, err := os.ReadFile(req.ReceiptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(receipt) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(receiptFixture, data, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(receipt fixture) error = %v", err)
|
||||
var receipt map[string]any
|
||||
if err := json.Unmarshal(data, &receipt); err != nil {
|
||||
t.Fatalf("decode receipt fixture: %v", err)
|
||||
}
|
||||
receipt["credential_echo"] = canary
|
||||
writeJSONFile(t, receiptFixture, receipt)
|
||||
if err := os.Remove(req.ReceiptPath); err != nil {
|
||||
t.Fatalf("Remove(receipt) error = %v", err)
|
||||
}
|
||||
@@ -193,27 +369,55 @@ func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T)
|
||||
script := writeShellScript(t, `#!/bin/sh
|
||||
pwd > "$NOTARIUS_CAPTURE_DIR/working-directory"
|
||||
printf '%s' "$NOTARIUS_INHERITED_VALUE" > "$NOTARIUS_CAPTURE_DIR/environment"
|
||||
printf 'diagnostic stream\n' >&2
|
||||
if [ "$`+envName+`" != '`+canary+`' ]; then
|
||||
printf 'selected credential mismatch\n' >&2
|
||||
exit 8
|
||||
fi
|
||||
if [ -n "$`+unrelated+`" ]; then
|
||||
printf 'unrelated credential inherited\n' >&2
|
||||
exit 9
|
||||
fi
|
||||
printf '%s\n' "$`+envName+`" >&2
|
||||
cat "$NOTARIUS_RECEIPT_FIXTURE"
|
||||
`)
|
||||
req.Binary = script
|
||||
t.Setenv("NOTARIUS_CAPTURE_DIR", captureDir)
|
||||
t.Setenv("NOTARIUS_INHERITED_VALUE", "inherited-value")
|
||||
t.Setenv("NOTARIUS_RECEIPT_FIXTURE", receiptFixture)
|
||||
t.Setenv(unrelated, "unrelated-parent-credential")
|
||||
|
||||
if _, err := NewSubprocessRunner().Run(context.Background(), req); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
assertTextFile(t, filepath.Join(captureDir, "working-directory"), req.WorkingDirectory+"\n")
|
||||
assertTextFile(t, filepath.Join(captureDir, "environment"), "")
|
||||
assertTextFile(t, req.LogPath, "diagnostic stream\n")
|
||||
assertRedactedCredentialFile(t, req.LogPath, canary)
|
||||
receiptBytes, err := os.ReadFile(req.ReceiptPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(receipt) error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(receiptBytes), "diagnostic stream") {
|
||||
t.Fatal("receipt contains stderr output")
|
||||
assertRedactedCredentialBytes(t, receiptBytes, canary)
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRedactsCredentialOnProcessFailure(t *testing.T) {
|
||||
const envName = "NARRATIO_NOTARIUS_CUSTOM_FAILURE_KEY"
|
||||
const canary = "notarius-failure-stream-canary"
|
||||
req := validRunRequest(t)
|
||||
req.LLMAPIKey = loadTestCredential(t, envName, canary)
|
||||
req.Binary = writeShellScript(t, `#!/bin/sh
|
||||
printf '%s\n' "$`+envName+`"
|
||||
printf '%s\n' "$`+envName+`" >&2
|
||||
exit 7
|
||||
`)
|
||||
_, err := NewSubprocessRunner().Run(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Run() error = nil, want process failure")
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) || !strings.Contains(err.Error(), "<redacted>") {
|
||||
t.Fatal("process failure did not return redacted diagnostics")
|
||||
}
|
||||
assertRedactedCredentialFile(t, req.ReceiptPath, canary)
|
||||
assertRedactedCredentialFile(t, req.LogPath, canary)
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T) {
|
||||
@@ -231,6 +435,10 @@ func TestSubprocessRunnerReturnsProcessFailuresWithoutParsingStdout(t *testing.T
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
if test.name == "nonzero" {
|
||||
profile := "missing-profile"
|
||||
req.ConfigProfile = &profile
|
||||
}
|
||||
req.Binary = writeShellScript(t, "#!/bin/sh\n"+test.scriptBody)
|
||||
req.Timeout = test.timeout
|
||||
ctx := context.Background()
|
||||
@@ -527,7 +735,8 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
||||
}
|
||||
|
||||
func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
|
||||
req := RunRequest{PipelineID: "pipeline", References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}}
|
||||
profile := "testing"
|
||||
req := RunRequest{PipelineID: "pipeline", ConfigProfile: &profile, References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}}
|
||||
want := RunResult{BundleRoot: "/bundle"}
|
||||
fake := &FakeRunner{Result: want}
|
||||
got, err := fake.Run(context.Background(), req)
|
||||
@@ -538,6 +747,10 @@ func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
|
||||
if fake.Requests[0].References[0].Path != "/references/party.json" {
|
||||
t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References)
|
||||
}
|
||||
profile = "changed"
|
||||
if *fake.Requests[0].ConfigProfile != "testing" {
|
||||
t.Fatalf("fake retained aliased config profile: %q", *fake.Requests[0].ConfigProfile)
|
||||
}
|
||||
|
||||
wantErr := errors.New("configured failure")
|
||||
fake.Err = wantErr
|
||||
@@ -579,6 +792,56 @@ func validRunRequest(t *testing.T) RunRequest {
|
||||
}
|
||||
}
|
||||
|
||||
func loadTestCredential(t *testing.T, envName, value string) *configkit.Secret {
|
||||
t.Helper()
|
||||
t.Setenv(envName, value)
|
||||
type testConfig struct {
|
||||
Credential *configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
result, err := configkit.Load(configkit.Bytes("credential.yml", []byte("credential: "+envName+"\n")), configkit.Options[testConfig]{})
|
||||
if err != nil {
|
||||
t.Fatalf("load test credential: %v", err)
|
||||
}
|
||||
return result.Config.Credential
|
||||
}
|
||||
|
||||
func assertRedactedCredentialFile(t *testing.T, path, canary string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
||||
}
|
||||
assertRedactedCredentialBytes(t, data, canary)
|
||||
}
|
||||
|
||||
func assertRedactedCredentialBytes(t *testing.T, data []byte, canary string) {
|
||||
t.Helper()
|
||||
text := string(data)
|
||||
if strings.Contains(text, canary) || !strings.Contains(text, "<redacted>") {
|
||||
t.Fatal("captured stream did not contain the required redaction marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateValidationSummariesAcceptsReusedProducerWithoutAttempts(t *testing.T) {
|
||||
zero := 0
|
||||
summaries, err := validateValidationSummaries([]validationSummaryDocument{{
|
||||
Stage: "chunk", Status: "complete", ProducerAttemptCount: &zero, TerminalAction: "accepted",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("validateValidationSummaries() error = %v", err)
|
||||
}
|
||||
if len(summaries) != 1 || summaries[0].ProducerAttemptCount != 0 {
|
||||
t.Fatalf("summaries = %#v, want one reused producer with zero attempts", summaries)
|
||||
}
|
||||
|
||||
negative := -1
|
||||
if _, err := validateValidationSummaries([]validationSummaryDocument{{
|
||||
Stage: "chunk", Status: "complete", ProducerAttemptCount: &negative, TerminalAction: "accepted",
|
||||
}}); err == nil {
|
||||
t.Fatal("validateValidationSummaries() negative producer attempts error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown bool) {
|
||||
t.Helper()
|
||||
bundle := filepath.Join(req.OutputRoot, "notarius-run-1")
|
||||
|
||||
@@ -6,14 +6,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
@@ -21,7 +18,6 @@ import (
|
||||
|
||||
const (
|
||||
defaultMaxWhisperXResponseBytes int64 = 10 * 1024 * 1024
|
||||
whisperXUploadBufferSize = 32 * 1024
|
||||
)
|
||||
|
||||
// HTTPClientConfig contains parsed, deterministic WhisperX HTTP client settings.
|
||||
@@ -192,21 +188,32 @@ func (c *HTTPClient) Transcribe(ctx context.Context, req TranscribeRequest) (Tra
|
||||
}
|
||||
|
||||
func (c *HTTPClient) doTranscribeAttempt(ctx context.Context, audioPath string) (int, []byte, error) {
|
||||
upload := newMultipartUpload(ctx, audioPath, c.language, c.openAudio)
|
||||
defer upload.Close()
|
||||
upload, err := newMultipartBodyFactory(ctx, audioPath, c.language, c.openAudio)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("prepare whisperx request body: %w", err)
|
||||
}
|
||||
defer func() { _ = upload.Close() }()
|
||||
body, err := upload.NewBody()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("open whisperx request body: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url.String(), upload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url.String(), body)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("build whisperx request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", upload.contentType)
|
||||
req.Header.Set("Expect", "100-continue")
|
||||
req.GetBody = upload.NewBody
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
_ = upload.Close()
|
||||
if producerErr := upload.Wait(); producerErr != nil {
|
||||
return 0, nil, fmt.Errorf("stream whisperx request body: %w", producerErr)
|
||||
if sourceErr := upload.SourceError(true); sourceErr != nil {
|
||||
return 0, nil, fmt.Errorf("stream whisperx request body: %w", sourceErr)
|
||||
}
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return 0, nil, fmt.Errorf("perform whisperx request: %w", ctxErr)
|
||||
}
|
||||
return 0, nil, fmt.Errorf("perform whisperx request: %w", err)
|
||||
}
|
||||
@@ -214,8 +221,8 @@ func (c *HTTPClient) doTranscribeAttempt(ctx context.Context, audioPath string)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
_ = upload.Close()
|
||||
if producerErr := upload.Wait(); producerErr != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("stream whisperx request body: %w", producerErr)
|
||||
if sourceErr := upload.SourceError(false); sourceErr != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("stream whisperx request body: %w", sourceErr)
|
||||
}
|
||||
if _, err := readWhisperXResponse(resp.Body, c.maxResponseBytes); err != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("read whisperx response body: %w", err)
|
||||
@@ -223,13 +230,19 @@ func (c *HTTPClient) doTranscribeAttempt(ctx context.Context, audioPath string)
|
||||
return resp.StatusCode, nil, fmt.Errorf("whisperx returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if err := upload.Wait(); err != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("stream whisperx request body: %w", err)
|
||||
data, responseErr := readWhisperXResponse(resp.Body, c.maxResponseBytes)
|
||||
_ = upload.Close()
|
||||
if sourceErr := upload.SourceError(false); sourceErr != nil {
|
||||
if responseErr != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("stream whisperx request body: %w", errors.Join(sourceErr, responseErr))
|
||||
}
|
||||
return resp.StatusCode, nil, fmt.Errorf("stream whisperx request body: %w", sourceErr)
|
||||
}
|
||||
|
||||
data, err := readWhisperXResponse(resp.Body, c.maxResponseBytes)
|
||||
if err != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("read whisperx response body: %w", err)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("complete whisperx request: %w", err)
|
||||
}
|
||||
if responseErr != nil {
|
||||
return resp.StatusCode, nil, fmt.Errorf("read whisperx response body: %w", responseErr)
|
||||
}
|
||||
return resp.StatusCode, data, nil
|
||||
}
|
||||
@@ -286,161 +299,6 @@ func isHTTPURLScheme(scheme string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
type multipartUpload struct {
|
||||
reader *io.PipeReader
|
||||
writer *io.PipeWriter
|
||||
contentType string
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
audio io.Closer
|
||||
err error
|
||||
aborted bool
|
||||
}
|
||||
|
||||
func newMultipartUpload(ctx context.Context, audioPath, language string, openAudio func(string) (io.ReadCloser, error)) *multipartUpload {
|
||||
reader, writer := io.Pipe()
|
||||
multipartWriter := multipart.NewWriter(writer)
|
||||
upload := &multipartUpload{
|
||||
reader: reader,
|
||||
writer: writer,
|
||||
contentType: multipartWriter.FormDataContentType(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := upload.write(ctx, multipartWriter, audioPath, language, openAudio)
|
||||
if err != nil {
|
||||
_ = writer.CloseWithError(err)
|
||||
} else {
|
||||
_ = writer.Close()
|
||||
}
|
||||
upload.mu.Lock()
|
||||
upload.err = err
|
||||
upload.audio = nil
|
||||
upload.mu.Unlock()
|
||||
close(upload.done)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
upload.abort()
|
||||
case <-upload.done:
|
||||
}
|
||||
}()
|
||||
|
||||
return upload
|
||||
}
|
||||
|
||||
func (u *multipartUpload) Read(p []byte) (int, error) {
|
||||
return u.reader.Read(p)
|
||||
}
|
||||
|
||||
func (u *multipartUpload) Close() error {
|
||||
u.abort()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *multipartUpload) Wait() error {
|
||||
<-u.done
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return u.err
|
||||
}
|
||||
|
||||
func (u *multipartUpload) write(ctx context.Context, writer *multipart.Writer, audioPath, language string, openAudio func(string) (io.ReadCloser, error)) error {
|
||||
fileWriter, err := writer.CreateFormFile("file", filepath.Base(audioPath))
|
||||
if err != nil {
|
||||
return u.producerError(ctx, fmt.Errorf("create multipart file field: %w", err))
|
||||
}
|
||||
|
||||
audioFile, err := openAudio(audioPath)
|
||||
if err != nil {
|
||||
return u.producerError(ctx, fmt.Errorf("open audio file %q: %w", audioPath, err))
|
||||
}
|
||||
u.setAudio(audioFile)
|
||||
|
||||
_, copyErr := io.CopyBuffer(fileWriter, &contextReader{ctx: ctx, reader: audioFile}, make([]byte, whisperXUploadBufferSize))
|
||||
closeErr := audioFile.Close()
|
||||
u.clearAudio(audioFile)
|
||||
if copyErr != nil {
|
||||
return u.producerError(ctx, fmt.Errorf("copy audio file %q: %w", audioPath, copyErr))
|
||||
}
|
||||
if closeErr != nil {
|
||||
return u.producerError(ctx, fmt.Errorf("close audio file %q: %w", audioPath, closeErr))
|
||||
}
|
||||
|
||||
if err := writer.WriteField("language", language); err != nil {
|
||||
return u.producerError(ctx, fmt.Errorf("write language form field: %w", err))
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return u.producerError(ctx, fmt.Errorf("close multipart writer: %w", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *multipartUpload) producerError(ctx context.Context, err error) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
u.mu.Lock()
|
||||
aborted := u.aborted
|
||||
u.mu.Unlock()
|
||||
if aborted {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *multipartUpload) setAudio(audio io.Closer) {
|
||||
u.mu.Lock()
|
||||
u.audio = audio
|
||||
aborted := u.aborted
|
||||
u.mu.Unlock()
|
||||
if aborted {
|
||||
_ = audio.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (u *multipartUpload) clearAudio(audio io.Closer) {
|
||||
u.mu.Lock()
|
||||
if u.audio == audio {
|
||||
u.audio = nil
|
||||
}
|
||||
u.mu.Unlock()
|
||||
}
|
||||
|
||||
func (u *multipartUpload) abort() {
|
||||
u.mu.Lock()
|
||||
if u.aborted {
|
||||
u.mu.Unlock()
|
||||
return
|
||||
}
|
||||
u.aborted = true
|
||||
audio := u.audio
|
||||
u.mu.Unlock()
|
||||
|
||||
_ = u.reader.Close()
|
||||
if audio != nil {
|
||||
_ = audio.Close()
|
||||
}
|
||||
}
|
||||
|
||||
type contextReader struct {
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (r *contextReader) Read(p []byte) (int, error) {
|
||||
select {
|
||||
case <-r.ctx.Done():
|
||||
return 0, r.ctx.Err()
|
||||
default:
|
||||
return r.reader.Read(p)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("path is required")
|
||||
|
||||
261
internal/adapters/whisperx/http_replay_test.go
Normal file
261
internal/adapters/whisperx/http_replay_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package whisperx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHTTPClientTransportReplaysAbandonedMultipartBody(t *testing.T) {
|
||||
var opens atomic.Int32
|
||||
var closes atomic.Int32
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
partial := make([]byte, 17)
|
||||
if _, err := io.ReadFull(request.Body, partial); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := request.Body.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.GetBody == nil {
|
||||
return nil, errors.New("request GetBody is nil")
|
||||
}
|
||||
replay, err := request.GetBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
replayedBytes, err := io.ReadAll(replay)
|
||||
if err != nil {
|
||||
_ = replay.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := replay.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts := parseMultipartParts(t, request.Header.Get("Content-Type"), replayedBytes)
|
||||
if len(parts) != 2 || parts[0].name != "file" || parts[0].filename != "audio.flac" || string(parts[0].data) != "audio-data" {
|
||||
t.Fatalf("replayed file part = %#v, want exact audio.flac payload", parts)
|
||||
}
|
||||
if parts[1].name != "language" || string(parts[1].data) != "en" {
|
||||
t.Fatalf("replayed language part = %#v, want language=en", parts[1])
|
||||
}
|
||||
return whisperXJSONResponse(request), nil
|
||||
})
|
||||
client := newReplayTestHTTPClient(t, transport)
|
||||
client.openAudio = func(string) (io.ReadCloser, error) {
|
||||
opens.Add(1)
|
||||
return &countedReadCloser{Reader: strings.NewReader("audio-data"), closes: &closes}, nil
|
||||
}
|
||||
outPath := filepath.Join(t.TempDir(), "raw.json")
|
||||
|
||||
result, err := client.Transcribe(context.Background(), TranscribeRequest{
|
||||
AudioPath: "audio.flac",
|
||||
OutputRawTranscriptPath: outPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe() error = %v", err)
|
||||
}
|
||||
if result.Attempts != 1 {
|
||||
t.Fatalf("application attempts = %d, want 1", result.Attempts)
|
||||
}
|
||||
verifyJSONFile(t, outPath)
|
||||
if got := opens.Load(); got != 2 {
|
||||
t.Fatalf("audio opens = %d, want original and replay", got)
|
||||
}
|
||||
if got := closes.Load(); got != 2 {
|
||||
t.Fatalf("audio closes = %d, want all body handles disposed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientTransportReturnsReplayOpenFailure(t *testing.T) {
|
||||
replayErr := errors.New("replay open failed")
|
||||
var opens atomic.Int32
|
||||
var closes atomic.Int32
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if _, err := io.ReadFull(request.Body, make([]byte, 1)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = request.Body.Close()
|
||||
_, err := request.GetBody()
|
||||
return nil, err
|
||||
})
|
||||
client := newReplayTestHTTPClient(t, transport)
|
||||
client.openAudio = func(string) (io.ReadCloser, error) {
|
||||
if opens.Add(1) == 2 {
|
||||
return nil, replayErr
|
||||
}
|
||||
return &countedReadCloser{Reader: strings.NewReader("audio-data"), closes: &closes}, nil
|
||||
}
|
||||
outPath := filepath.Join(t.TempDir(), "raw.json")
|
||||
|
||||
result, err := client.Transcribe(context.Background(), TranscribeRequest{
|
||||
AudioPath: "audio.flac",
|
||||
OutputRawTranscriptPath: outPath,
|
||||
})
|
||||
if !errors.Is(err, replayErr) {
|
||||
t.Fatalf("Transcribe() error = %v, want replay open failure", err)
|
||||
}
|
||||
if result.Attempts != 1 {
|
||||
t.Fatalf("application attempts = %d, want 1", result.Attempts)
|
||||
}
|
||||
if got := closes.Load(); got != 1 {
|
||||
t.Fatalf("audio closes = %d, want original disposed", got)
|
||||
}
|
||||
if _, statErr := os.Stat(outPath); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("output should not exist, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientTransportCannotSwallowReplayReadFailure(t *testing.T) {
|
||||
replayErr := errors.New("replay read failed")
|
||||
var opens atomic.Int32
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if _, err := io.ReadFull(request.Body, make([]byte, 1)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = request.Body.Close()
|
||||
replay, err := request.GetBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = io.ReadAll(replay)
|
||||
_ = replay.Close()
|
||||
return whisperXJSONResponse(request), nil
|
||||
})
|
||||
client := newReplayTestHTTPClient(t, transport)
|
||||
client.openAudio = func(string) (io.ReadCloser, error) {
|
||||
if opens.Add(1) == 2 {
|
||||
return &failingReadCloser{first: []byte("partial"), err: replayErr}, nil
|
||||
}
|
||||
return io.NopCloser(strings.NewReader("audio-data")), nil
|
||||
}
|
||||
outPath := filepath.Join(t.TempDir(), "raw.json")
|
||||
|
||||
_, err := client.Transcribe(context.Background(), TranscribeRequest{
|
||||
AudioPath: "audio.flac",
|
||||
OutputRawTranscriptPath: outPath,
|
||||
})
|
||||
if !errors.Is(err, replayErr) {
|
||||
t.Fatalf("Transcribe() error = %v, want replay read failure", err)
|
||||
}
|
||||
if _, statErr := os.Stat(outPath); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("output should not exist, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientTransportEarlyResponseDisposesOriginalBody(t *testing.T) {
|
||||
var closes atomic.Int32
|
||||
client := newReplayTestHTTPClient(t, roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return whisperXJSONResponse(request), nil
|
||||
}))
|
||||
client.openAudio = func(string) (io.ReadCloser, error) {
|
||||
return &countedReadCloser{Reader: strings.NewReader("audio-data"), closes: &closes}, nil
|
||||
}
|
||||
|
||||
_, err := client.Transcribe(context.Background(), TranscribeRequest{
|
||||
AudioPath: "audio.flac",
|
||||
OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Transcribe() error = %v", err)
|
||||
}
|
||||
if got := closes.Load(); got != 1 {
|
||||
t.Fatalf("audio closes = %d, want early-response body disposed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientParentCancellationDuringReplayClosesEveryBody(t *testing.T) {
|
||||
var opens atomic.Int32
|
||||
var originalCloses atomic.Int32
|
||||
replaySource := newSignalingBlockedReadCloser()
|
||||
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if _, err := io.ReadFull(request.Body, make([]byte, 1)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = request.Body.Close()
|
||||
replay, err := request.GetBody()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = io.ReadAll(replay)
|
||||
_ = replay.Close()
|
||||
return nil, request.Context().Err()
|
||||
})
|
||||
client := newReplayTestHTTPClient(t, transport)
|
||||
client.openAudio = func(string) (io.ReadCloser, error) {
|
||||
if opens.Add(1) == 1 {
|
||||
return &countedReadCloser{Reader: strings.NewReader("audio-data"), closes: &originalCloses}, nil
|
||||
}
|
||||
return replaySource, nil
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := client.Transcribe(ctx, TranscribeRequest{
|
||||
AudioPath: "audio.flac",
|
||||
OutputRawTranscriptPath: filepath.Join(t.TempDir(), "raw.json"),
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-replaySource.blocked:
|
||||
cancel()
|
||||
case <-time.After(time.Second):
|
||||
cancel()
|
||||
t.Fatal("replay source did not block")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Transcribe() error = %v, want context cancellation", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Transcribe() did not return after replay cancellation")
|
||||
}
|
||||
if got := originalCloses.Load(); got != 1 {
|
||||
t.Fatalf("original audio closes = %d, want 1", got)
|
||||
}
|
||||
select {
|
||||
case <-replaySource.closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("replay audio source was not closed")
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return roundTrip(request)
|
||||
}
|
||||
|
||||
func newReplayTestHTTPClient(t *testing.T, transport http.RoundTripper) *HTTPClient {
|
||||
t.Helper()
|
||||
client, err := NewHTTPClient(HTTPClientConfig{
|
||||
TranscribeURL: "http://whisperx.test/transcribe",
|
||||
Language: "en",
|
||||
Timeout: time.Second,
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPClient() error = %v", err)
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func whisperXJSONResponse(request *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"source":"transport-replay","segments":[]}`)),
|
||||
Request: request,
|
||||
}
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func TestHTTPClientEarlyServerResponseReturns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientCancellationReleasesBlockedProducer(t *testing.T) {
|
||||
func TestHTTPClientCancellationReleasesBlockedSource(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
source := newGatedReadCloser([]byte("audio-data"), release)
|
||||
firstByteReceived := make(chan struct{})
|
||||
|
||||
263
internal/adapters/whisperx/multipart_body.go
Normal file
263
internal/adapters/whisperx/multipart_body.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package whisperx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type multipartBodyFactory struct {
|
||||
ctx context.Context
|
||||
audioPath string
|
||||
openAudio func(string) (io.ReadCloser, error)
|
||||
prefix []byte
|
||||
suffix []byte
|
||||
contentType string
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
bodies []*multipartBody
|
||||
}
|
||||
|
||||
func newMultipartBodyFactory(
|
||||
ctx context.Context,
|
||||
audioPath string,
|
||||
language string,
|
||||
openAudio func(string) (io.ReadCloser, error),
|
||||
) (*multipartBodyFactory, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if openAudio == nil {
|
||||
return nil, fmt.Errorf("audio opener is required")
|
||||
}
|
||||
var framing bytes.Buffer
|
||||
writer := multipart.NewWriter(&framing)
|
||||
if _, err := writer.CreateFormFile("file", filepath.Base(audioPath)); err != nil {
|
||||
return nil, fmt.Errorf("create multipart file field: %w", err)
|
||||
}
|
||||
prefixLength := framing.Len()
|
||||
if err := writer.WriteField("language", language); err != nil {
|
||||
return nil, fmt.Errorf("write language form field: %w", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close multipart framing: %w", err)
|
||||
}
|
||||
framingBytes := framing.Bytes()
|
||||
return &multipartBodyFactory{
|
||||
ctx: ctx,
|
||||
audioPath: audioPath,
|
||||
openAudio: openAudio,
|
||||
prefix: append([]byte(nil), framingBytes[:prefixLength]...),
|
||||
suffix: append([]byte(nil), framingBytes[prefixLength:]...),
|
||||
contentType: writer.FormDataContentType(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (factory *multipartBodyFactory) NewBody() (io.ReadCloser, error) {
|
||||
factory.mu.Lock()
|
||||
if factory.closed {
|
||||
factory.mu.Unlock()
|
||||
return nil, fmt.Errorf("multipart body factory is closed")
|
||||
}
|
||||
if err := factory.ctx.Err(); err != nil {
|
||||
factory.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
audio, err := factory.openAudio(factory.audioPath)
|
||||
if err != nil {
|
||||
factory.mu.Unlock()
|
||||
return nil, fmt.Errorf("open audio file %q: %w", factory.audioPath, err)
|
||||
}
|
||||
if err := factory.ctx.Err(); err != nil {
|
||||
factory.mu.Unlock()
|
||||
_ = audio.Close()
|
||||
return nil, err
|
||||
}
|
||||
source := &multipartAudioSource{audio: audio, closeDone: make(chan struct{})}
|
||||
body := &multipartBody{
|
||||
ctx: factory.ctx,
|
||||
source: source,
|
||||
reader: io.MultiReader(bytes.NewReader(factory.prefix), source, bytes.NewReader(factory.suffix)),
|
||||
}
|
||||
factory.bodies = append(factory.bodies, body)
|
||||
factory.mu.Unlock()
|
||||
stopCancel := context.AfterFunc(factory.ctx, func() {
|
||||
_ = body.Close()
|
||||
})
|
||||
body.mu.Lock()
|
||||
body.stopCancel = stopCancel
|
||||
closed := body.closed
|
||||
body.mu.Unlock()
|
||||
if closed {
|
||||
stopCancel()
|
||||
}
|
||||
if err := factory.ctx.Err(); err != nil {
|
||||
_ = body.Close()
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (factory *multipartBodyFactory) Close() error {
|
||||
factory.mu.Lock()
|
||||
if factory.closed {
|
||||
bodies := append([]*multipartBody(nil), factory.bodies...)
|
||||
factory.mu.Unlock()
|
||||
return multipartBodyCloseErrors(bodies)
|
||||
}
|
||||
factory.closed = true
|
||||
bodies := append([]*multipartBody(nil), factory.bodies...)
|
||||
factory.mu.Unlock()
|
||||
for _, body := range bodies {
|
||||
_ = body.Close()
|
||||
}
|
||||
return multipartBodyCloseErrors(bodies)
|
||||
}
|
||||
|
||||
func (factory *multipartBodyFactory) SourceError(includeAbandoned bool) error {
|
||||
factory.mu.Lock()
|
||||
bodies := append([]*multipartBody(nil), factory.bodies...)
|
||||
factory.mu.Unlock()
|
||||
var result error
|
||||
for _, body := range bodies {
|
||||
if err := body.sourceError(includeAbandoned); err != nil {
|
||||
result = errors.Join(result, err)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func multipartBodyCloseErrors(bodies []*multipartBody) error {
|
||||
var result error
|
||||
for _, body := range bodies {
|
||||
if err := body.sourceError(false); err != nil {
|
||||
result = errors.Join(result, err)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
type multipartBody struct {
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
source *multipartAudioSource
|
||||
stopCancel func() bool
|
||||
|
||||
mu sync.Mutex
|
||||
closed bool
|
||||
abandoned bool
|
||||
}
|
||||
|
||||
func (body *multipartBody) Read(buffer []byte) (int, error) {
|
||||
if err := body.ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
body.mu.Lock()
|
||||
closed := body.closed
|
||||
body.mu.Unlock()
|
||||
if closed {
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
return body.reader.Read(buffer)
|
||||
}
|
||||
|
||||
func (body *multipartBody) Close() error {
|
||||
body.mu.Lock()
|
||||
if body.closed {
|
||||
body.mu.Unlock()
|
||||
return body.source.Close()
|
||||
}
|
||||
body.closed = true
|
||||
body.abandoned = !body.source.reachedEOF()
|
||||
stopCancel := body.stopCancel
|
||||
body.mu.Unlock()
|
||||
if stopCancel != nil {
|
||||
stopCancel()
|
||||
}
|
||||
return body.source.Close()
|
||||
}
|
||||
|
||||
func (body *multipartBody) sourceError(includeAbandoned bool) error {
|
||||
body.mu.Lock()
|
||||
abandoned := body.abandoned
|
||||
body.mu.Unlock()
|
||||
err := body.source.Err()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if abandoned && !includeAbandoned {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type multipartAudioSource struct {
|
||||
audio io.ReadCloser
|
||||
|
||||
mu sync.Mutex
|
||||
closing bool
|
||||
eof bool
|
||||
readErr error
|
||||
closeErr error
|
||||
closeDone chan struct{}
|
||||
closeResult error
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (source *multipartAudioSource) Read(buffer []byte) (int, error) {
|
||||
n, err := source.audio.Read(buffer)
|
||||
source.mu.Lock()
|
||||
switch {
|
||||
case errors.Is(err, io.EOF):
|
||||
source.eof = true
|
||||
case err != nil && !source.closing:
|
||||
source.readErr = errors.Join(source.readErr, err)
|
||||
}
|
||||
source.mu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (source *multipartAudioSource) Close() error {
|
||||
source.mu.Lock()
|
||||
if source.closed {
|
||||
done := source.closeDone
|
||||
source.mu.Unlock()
|
||||
<-done
|
||||
source.mu.Lock()
|
||||
err := source.closeResult
|
||||
source.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
source.closed = true
|
||||
source.closing = true
|
||||
reachedEOF := source.eof
|
||||
source.mu.Unlock()
|
||||
|
||||
err := source.audio.Close()
|
||||
source.mu.Lock()
|
||||
source.closeResult = err
|
||||
if err != nil && reachedEOF {
|
||||
source.closeErr = err
|
||||
}
|
||||
close(source.closeDone)
|
||||
source.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (source *multipartAudioSource) reachedEOF() bool {
|
||||
source.mu.Lock()
|
||||
defer source.mu.Unlock()
|
||||
return source.eof
|
||||
}
|
||||
|
||||
func (source *multipartAudioSource) Err() error {
|
||||
source.mu.Lock()
|
||||
defer source.mu.Unlock()
|
||||
return errors.Join(source.readErr, source.closeErr)
|
||||
}
|
||||
327
internal/adapters/whisperx/multipart_body_test.go
Normal file
327
internal/adapters/whisperx/multipart_body_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package whisperx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMultipartBodyReplayUsesIdenticalFraming(t *testing.T) {
|
||||
factory := newTestMultipartBodyFactory(t, context.Background(), "audio-data")
|
||||
defer factory.Close()
|
||||
|
||||
original, err := factory.NewBody()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBody() original error = %v", err)
|
||||
}
|
||||
replay, err := factory.NewBody()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBody() replay error = %v", err)
|
||||
}
|
||||
originalBytes, err := io.ReadAll(original)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(original) error = %v", err)
|
||||
}
|
||||
replayBytes, err := io.ReadAll(replay)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(replay) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(originalBytes, replayBytes) {
|
||||
t.Fatal("original and replay multipart bytes differ")
|
||||
}
|
||||
|
||||
parts := parseMultipartParts(t, factory.contentType, replayBytes)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("part count = %d, want 2", len(parts))
|
||||
}
|
||||
if parts[0].name != "file" || parts[0].filename != "audio.flac" || string(parts[0].data) != "audio-data" {
|
||||
t.Fatalf("file part = %#v, want file/audio.flac with exact audio", parts[0])
|
||||
}
|
||||
if parts[1].name != "language" || string(parts[1].data) != "en" {
|
||||
t.Fatalf("language part = %#v, want language=en", parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartBodyReplayRestartsAfterPartialOriginal(t *testing.T) {
|
||||
factory := newTestMultipartBodyFactory(t, context.Background(), "audio-data")
|
||||
defer factory.Close()
|
||||
|
||||
original, err := factory.NewBody()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBody() original error = %v", err)
|
||||
}
|
||||
partial := make([]byte, len(factory.prefix)+3)
|
||||
if _, err := io.ReadFull(original, partial); err != nil {
|
||||
t.Fatalf("ReadFull(original) error = %v", err)
|
||||
}
|
||||
if err := original.Close(); err != nil {
|
||||
t.Fatalf("Close(original) error = %v", err)
|
||||
}
|
||||
|
||||
replay, err := factory.NewBody()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBody() replay error = %v", err)
|
||||
}
|
||||
replayBytes, err := io.ReadAll(replay)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(replay) error = %v", err)
|
||||
}
|
||||
parts := parseMultipartParts(t, factory.contentType, replayBytes)
|
||||
if got := string(parts[0].data); got != "audio-data" {
|
||||
t.Fatalf("replayed audio = %q, want complete payload", got)
|
||||
}
|
||||
if err := factory.SourceError(false); err != nil {
|
||||
t.Fatalf("SourceError(false) = %v, want abandoned original ignored", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartBodySimultaneousBodiesUseIndependentSources(t *testing.T) {
|
||||
var opens atomic.Int32
|
||||
var closes atomic.Int32
|
||||
factory, err := newMultipartBodyFactory(context.Background(), "audio.flac", "en", func(string) (io.ReadCloser, error) {
|
||||
opens.Add(1)
|
||||
return &countedReadCloser{Reader: strings.NewReader("audio-data"), closes: &closes}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newMultipartBodyFactory() error = %v", err)
|
||||
}
|
||||
|
||||
first, err := factory.NewBody()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBody() first error = %v", err)
|
||||
}
|
||||
second, err := factory.NewBody()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBody() second error = %v", err)
|
||||
}
|
||||
var firstBytes, secondBytes []byte
|
||||
var firstErr, secondErr error
|
||||
var group sync.WaitGroup
|
||||
group.Add(2)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
firstBytes, firstErr = io.ReadAll(first)
|
||||
}()
|
||||
go func() {
|
||||
defer group.Done()
|
||||
secondBytes, secondErr = io.ReadAll(second)
|
||||
}()
|
||||
group.Wait()
|
||||
if firstErr != nil || secondErr != nil {
|
||||
t.Fatalf("concurrent reads returned (%v, %v)", firstErr, secondErr)
|
||||
}
|
||||
if !bytes.Equal(firstBytes, secondBytes) {
|
||||
t.Fatal("independent body bytes differ")
|
||||
}
|
||||
if got := opens.Load(); got != 2 {
|
||||
t.Fatalf("audio opens = %d, want 2", got)
|
||||
}
|
||||
if err := factory.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if err := factory.Close(); err != nil {
|
||||
t.Fatalf("second Close() error = %v", err)
|
||||
}
|
||||
if got := closes.Load(); got != 2 {
|
||||
t.Fatalf("audio closes = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartBodyReportsSourceFailures(t *testing.T) {
|
||||
t.Run("read", func(t *testing.T) {
|
||||
sourceErr := errors.New("read source")
|
||||
factory, err := newMultipartBodyFactory(context.Background(), "audio.flac", "en", func(string) (io.ReadCloser, error) {
|
||||
return &failingReadCloser{first: []byte("partial"), err: sourceErr}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newMultipartBodyFactory() error = %v", err)
|
||||
}
|
||||
body, err := factory.NewBody()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBody() error = %v", err)
|
||||
}
|
||||
if _, err := io.ReadAll(body); !errors.Is(err, sourceErr) {
|
||||
t.Fatalf("ReadAll() error = %v, want source failure", err)
|
||||
}
|
||||
_ = body.Close()
|
||||
if err := factory.SourceError(false); !errors.Is(err, sourceErr) {
|
||||
t.Fatalf("SourceError(false) = %v, want source failure", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("close after complete read", func(t *testing.T) {
|
||||
closeErr := errors.New("close source")
|
||||
factory, err := newMultipartBodyFactory(context.Background(), "audio.flac", "en", func(string) (io.ReadCloser, error) {
|
||||
return &errorCloseReadCloser{Reader: strings.NewReader("audio-data"), err: closeErr}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newMultipartBodyFactory() error = %v", err)
|
||||
}
|
||||
body, err := factory.NewBody()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBody() error = %v", err)
|
||||
}
|
||||
if _, err := io.ReadAll(body); err != nil {
|
||||
t.Fatalf("ReadAll() error = %v", err)
|
||||
}
|
||||
if err := body.Close(); !errors.Is(err, closeErr) {
|
||||
t.Fatalf("Close() error = %v, want source close failure", err)
|
||||
}
|
||||
if err := factory.SourceError(false); !errors.Is(err, closeErr) {
|
||||
t.Fatalf("SourceError(false) = %v, want source close failure", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMultipartBodyCloseAndCancelReleaseBlockedSource(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
release func(context.CancelFunc, io.ReadCloser)
|
||||
}{
|
||||
{name: "close", release: func(_ context.CancelFunc, body io.ReadCloser) { _ = body.Close() }},
|
||||
{name: "cancel", release: func(cancel context.CancelFunc, _ io.ReadCloser) { cancel() }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
source := newSignalingBlockedReadCloser()
|
||||
factory, err := newMultipartBodyFactory(ctx, "audio.flac", "en", func(string) (io.ReadCloser, error) {
|
||||
return source, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newMultipartBodyFactory() error = %v", err)
|
||||
}
|
||||
defer factory.Close()
|
||||
body, err := factory.NewBody()
|
||||
if err != nil {
|
||||
t.Fatalf("NewBody() error = %v", err)
|
||||
}
|
||||
readDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := io.ReadAll(body)
|
||||
readDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-source.blocked:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("audio source did not block")
|
||||
}
|
||||
test.release(cancel, body)
|
||||
select {
|
||||
case <-source.closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("audio source was not closed")
|
||||
}
|
||||
select {
|
||||
case <-readDone:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("blocked body read did not return")
|
||||
}
|
||||
if test.name == "cancel" {
|
||||
if _, err := factory.NewBody(); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("NewBody() after cancellation error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newTestMultipartBodyFactory(t *testing.T, ctx context.Context, audio string) *multipartBodyFactory {
|
||||
t.Helper()
|
||||
factory, err := newMultipartBodyFactory(ctx, "audio.flac", "en", func(string) (io.ReadCloser, error) {
|
||||
return io.NopCloser(strings.NewReader(audio)), nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("newMultipartBodyFactory() error = %v", err)
|
||||
}
|
||||
return factory
|
||||
}
|
||||
|
||||
type multipartTestPart struct {
|
||||
name string
|
||||
filename string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func parseMultipartParts(t *testing.T, contentType string, body []byte) []multipartTestPart {
|
||||
t.Helper()
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType() error = %v", err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("media type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
|
||||
var parts []multipartTestPart
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return parts
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NextPart() error = %v", err)
|
||||
}
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll(part %q) error = %v", part.FormName(), err)
|
||||
}
|
||||
parts = append(parts, multipartTestPart{name: part.FormName(), filename: part.FileName(), data: data})
|
||||
}
|
||||
}
|
||||
|
||||
type countedReadCloser struct {
|
||||
io.Reader
|
||||
closes *atomic.Int32
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (reader *countedReadCloser) Close() error {
|
||||
reader.once.Do(func() { reader.closes.Add(1) })
|
||||
return nil
|
||||
}
|
||||
|
||||
type errorCloseReadCloser struct {
|
||||
io.Reader
|
||||
err error
|
||||
}
|
||||
|
||||
func (reader *errorCloseReadCloser) Close() error { return reader.err }
|
||||
|
||||
type signalingBlockedReadCloser struct {
|
||||
blocked chan struct{}
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
read bool
|
||||
}
|
||||
|
||||
func newSignalingBlockedReadCloser() *signalingBlockedReadCloser {
|
||||
return &signalingBlockedReadCloser{blocked: make(chan struct{}), closed: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (reader *signalingBlockedReadCloser) Read(buffer []byte) (int, error) {
|
||||
if !reader.read {
|
||||
reader.read = true
|
||||
return copy(buffer, "audio-data"), nil
|
||||
}
|
||||
reader.once.Do(func() { close(reader.blocked) })
|
||||
<-reader.closed
|
||||
return 0, errors.New("audio source closed")
|
||||
}
|
||||
|
||||
func (reader *signalingBlockedReadCloser) Close() error {
|
||||
select {
|
||||
case <-reader.closed:
|
||||
default:
|
||||
close(reader.closed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -272,13 +272,18 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
||||
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"analyze", "2026-05-03"}, &stdout, &stderr)
|
||||
code := executeWithDependencies([]string{"analyze", "2026-05-03"}, &stdout, &stderr, deps, executor)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "analyze: no pipeline config path provided and no default pipeline config found; searched:") {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "analyze: no pipeline config path provided") ||
|
||||
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -371,13 +376,18 @@ func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
||||
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"publish", "2026-05-03"}, &stdout, &stderr)
|
||||
code := executeWithDependencies([]string{"publish", "2026-05-03"}, &stdout, &stderr, deps, executor)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "publish: no pipeline config path provided and no default pipeline config found; searched:") {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "publish: no pipeline config path provided") ||
|
||||
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
type commandDependencies struct {
|
||||
diagnostics io.Writer
|
||||
resolvePipelineConfigPath func(string) (string, error)
|
||||
loadPipelineConfig func(string, config.PipelineLoadOptions) (*config.PipelineConfig, error)
|
||||
downloadObjectToTemp func(context.Context, storage.ObjectStore, string, string) (string, error)
|
||||
loadSecretsDir func(string) (*configkit.SecretsReport, error)
|
||||
@@ -25,6 +26,9 @@ func normalizeCommandDependencies(deps commandDependencies) commandDependencies
|
||||
if deps.diagnostics == nil {
|
||||
deps.diagnostics = io.Discard
|
||||
}
|
||||
if deps.resolvePipelineConfigPath == nil {
|
||||
deps.resolvePipelineConfigPath = resolvePipelineConfigPath
|
||||
}
|
||||
if deps.loadPipelineConfig == nil {
|
||||
deps.loadPipelineConfig = config.LoadPipelineWithOptions
|
||||
}
|
||||
|
||||
@@ -59,9 +59,10 @@ func TestExecuteValidCommands(t *testing.T) {
|
||||
|
||||
func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
controlledDiscovery bool
|
||||
}{
|
||||
{name: "run missing session", args: []string{"run"}, want: "run: session_id is required"},
|
||||
{name: "plan old top-level removed", args: []string{"plan"}, want: `unknown command: "plan"`},
|
||||
@@ -69,7 +70,7 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
{name: "resume removed", args: []string{"resume"}, want: `unknown command: "resume"`},
|
||||
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected stage name and session_id"},
|
||||
{name: "run-stage missing session", args: []string{"run-stage", "polish"}, want: "run-stage: expected stage name and session_id"},
|
||||
{name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||
{name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided", controlledDiscovery: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -77,7 +78,13 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := Execute(tc.args, &stdout, &stderr)
|
||||
deps := commandDependencies{}
|
||||
executor := pipelineExecutor(executePlan)
|
||||
missingPath := ""
|
||||
if tc.controlledDiscovery {
|
||||
deps, missingPath, executor = guardedMissingPipelineDiscovery(t)
|
||||
}
|
||||
code := executeWithDependencies(tc.args, &stdout, &stderr, deps, executor)
|
||||
if code == 0 {
|
||||
t.Fatalf("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -87,6 +94,11 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), tc.want) {
|
||||
t.Fatalf("stderr = %q, want to contain %q", stderr.String(), tc.want)
|
||||
}
|
||||
if tc.controlledDiscovery {
|
||||
if got := stderr.String(); !strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||
t.Fatalf("stderr = %q, want controlled searched path %q", got, missingPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,10 +367,12 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
||||
const firstCanary = "inspection-credential-canary-one"
|
||||
const secondCanary = "inspection-credential-canary-two"
|
||||
for name, value := range map[string]string{
|
||||
"NARRATIO_INSPECT_ACCESS": firstCanary,
|
||||
"NARRATIO_INSPECT_SECRET": firstCanary,
|
||||
"NARRATIO_INSPECT_LLM_ONE": firstCanary,
|
||||
"NARRATIO_INSPECT_LLM_TWO": secondCanary,
|
||||
"NARRATIO_INSPECT_ACCESS": firstCanary,
|
||||
"NARRATIO_INSPECT_SECRET": firstCanary,
|
||||
"NARRATIO_INSPECT_LLM_ONE": firstCanary,
|
||||
"NARRATIO_INSPECT_LLM_TWO": secondCanary,
|
||||
"NARRATIO_INSPECT_NOTARIUS_ONE": firstCanary,
|
||||
"NARRATIO_INSPECT_NOTARIUS_TWO": secondCanary,
|
||||
} {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
@@ -384,13 +386,23 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
||||
access_key_id: NARRATIO_INSPECT_ACCESS
|
||||
secret_access_key: NARRATIO_INSPECT_SECRET
|
||||
`, 1)
|
||||
pipelineYAML += `notarius:
|
||||
enabled: false
|
||||
llm_api_key: NARRATIO_INSPECT_NOTARIUS_ONE
|
||||
`
|
||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeInspectionProfiles(t, pipelinePath,
|
||||
"audita:\n model: production\n llm_api_key: NARRATIO_INSPECT_LLM_ONE\n",
|
||||
"audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\n",
|
||||
"audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n llm_api_key: NARRATIO_INSPECT_NOTARIUS_TWO\n",
|
||||
)
|
||||
mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "profiles", "without-notarius-key.yml"), "notarius:\n llm_api_key: null\n")
|
||||
|
||||
var validate bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &validate); err != nil {
|
||||
t.Fatalf("config validate: %v", err)
|
||||
}
|
||||
|
||||
var show bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &show); err != nil {
|
||||
@@ -408,19 +420,41 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
||||
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diff); err != nil {
|
||||
t.Fatalf("config diff: %v", err)
|
||||
}
|
||||
var removed bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "without-notarius-key"}, &removed); err != nil {
|
||||
t.Fatalf("config show with removed Notarius credential: %v", err)
|
||||
}
|
||||
for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "artifacts": catalog.String(), "diff": diff.String()} {
|
||||
if strings.Contains(output, firstCanary) || strings.Contains(output, secondCanary) {
|
||||
t.Fatalf("%s output exposed credential value: %q", label, output)
|
||||
}
|
||||
}
|
||||
for _, reference := range []string{"NARRATIO_INSPECT_ACCESS", "NARRATIO_INSPECT_SECRET", "NARRATIO_INSPECT_LLM_ONE"} {
|
||||
for _, reference := range []string{"NARRATIO_INSPECT_ACCESS", "NARRATIO_INSPECT_SECRET", "NARRATIO_INSPECT_LLM_ONE", "NARRATIO_INSPECT_NOTARIUS_ONE"} {
|
||||
if !strings.Contains(show.String(), reference) {
|
||||
t.Fatalf("show omitted safe credential reference %q: %q", reference, show.String())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_TWO") {
|
||||
if !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_LLM_TWO") ||
|
||||
!strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") || !strings.Contains(diff.String(), "NARRATIO_INSPECT_NOTARIUS_TWO") {
|
||||
t.Fatalf("diff omitted safe credential references: %q", diff.String())
|
||||
}
|
||||
if !strings.Contains(sources.String(), "notarius.llm_api_key") {
|
||||
t.Fatalf("sources omitted Notarius credential ownership: %q", sources.String())
|
||||
}
|
||||
if strings.Contains(removed.String(), "llm_api_key") || strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") {
|
||||
t.Fatalf("profile null did not remove Notarius credential: %q", removed.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateRejectsUnavailableNotariusCredential(t *testing.T) {
|
||||
const envName = "NARRATIO_INSPECT_NOTARIUS_MISSING"
|
||||
restoreEnvAfterTest(t, envName)
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
mustWriteTestFile(t, path, "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n")
|
||||
err := ConfigValidate(context.Background(), []string{"--config", path}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "secret.unset") {
|
||||
t.Fatalf("ConfigValidate() error = %v, want unavailable credential failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testing.T) {
|
||||
|
||||
@@ -163,7 +163,7 @@ func loadPipelineCampaignConfigWithDependencies(pipelineFlag, campaignFlag, camp
|
||||
|
||||
func loadPipelineConfigWithDependencies(pipelineFlag string, opts config.PipelineLoadOptions, secretsDir *string, deps commandDependencies) (string, *config.PipelineConfig, error) {
|
||||
deps = normalizeCommandDependencies(deps)
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||
resolvedPipelinePath, err := deps.resolvePipelineConfigPath(pipelineFlag)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
@@ -181,12 +183,16 @@ func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) {
|
||||
for index, expected := range want {
|
||||
binding := request.References[index]
|
||||
canonical := filepath.Join(paths.InputsDir, expected.filename)
|
||||
snapshot := filepath.Join(
|
||||
artifacts.SessionRunNotariusReferencesDirForCampaign(
|
||||
cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, loaded.RunID,
|
||||
),
|
||||
expected.filename,
|
||||
descriptor, ok := artifactpolicy.DescribePreparedInputSource(expected.sourceID)
|
||||
if !ok {
|
||||
t.Fatalf("DescribePreparedInputSource(%q) ok = false", expected.sourceID)
|
||||
}
|
||||
snapshot, err := artifacts.SessionNotariusReferenceSnapshotPath(
|
||||
paths, lifecycleInputChecksum(t, loaded, descriptor.ManifestKind), expected.filename,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SessionNotariusReferenceSnapshotPath(%q) error = %v", expected.sourceID, err)
|
||||
}
|
||||
if binding.Selector != expected.selector || binding.Path != snapshot || binding.Path == canonical || binding.Path == originalPaths[expected.sourceID] {
|
||||
t.Fatalf("reference[%d] = %#v, want selector %q snapshot %q and not prepared/source paths", index, binding, expected.selector, snapshot)
|
||||
}
|
||||
@@ -393,6 +399,140 @@ func TestExtractLifecycleFailureInvalidatesAndOrdinaryRetryRerunsDownstream(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleRetryReusesStableSnapshotsAndChangedReferenceSelectsNewPath(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
configureLifecycleReferences(t, cfg)
|
||||
runner.failuresRemaining = 1
|
||||
plan := prepareExtractLifecyclePlan(t)
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err == nil {
|
||||
t.Fatal("first executeStages() error = nil, want extraction failure")
|
||||
}
|
||||
if len(runner.requests) != 1 {
|
||||
t.Fatalf("failed requests = %d, want 1", len(runner.requests))
|
||||
}
|
||||
firstPaths := lifecycleReferencePaths(runner.requests[0])
|
||||
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("retry executeStages() error = %v", err)
|
||||
}
|
||||
if len(runner.requests) != 2 {
|
||||
t.Fatalf("retry requests = %d, want 2", len(runner.requests))
|
||||
}
|
||||
if secondPaths := lifecycleReferencePaths(runner.requests[1]); !reflect.DeepEqual(secondPaths, firstPaths) {
|
||||
t.Fatalf("snapshot paths changed on retry:\nfirst %#v\nsecond %#v", firstPaths, secondPaths)
|
||||
}
|
||||
|
||||
partyDocument, err := config.ParseParty([]byte(`schema_version: narratio.party.v1
|
||||
characters:
|
||||
changed:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Changed
|
||||
classes: [{name: wizard}]
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseParty(changed party) error = %v", err)
|
||||
}
|
||||
cfg.Party.Canonical = partyDocument
|
||||
persisted := loadLifecycleManifest(t, cfg)
|
||||
prepare, err := stage.Select("prepare")
|
||||
if err != nil {
|
||||
t.Fatalf("stage.Select(prepare) error = %v", err)
|
||||
}
|
||||
if _, err := prepare.Run(context.Background(), env, persisted); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), persisted); err != nil {
|
||||
t.Fatalf("Save(reprepared manifest) error = %v", err)
|
||||
}
|
||||
extractPlan, _ := BuildSingleStagePlan("extract")
|
||||
if _, err := executeStages(context.Background(), cfg, extractPlan, RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("changed-reference executeStages() error = %v", err)
|
||||
}
|
||||
if len(runner.requests) != 3 {
|
||||
t.Fatalf("changed-reference requests = %d, want 3", len(runner.requests))
|
||||
}
|
||||
thirdPaths := lifecycleReferencePaths(runner.requests[2])
|
||||
if thirdPaths["party"] == firstPaths["party"] {
|
||||
t.Fatalf("changed party retained snapshot path %q", thirdPaths["party"])
|
||||
}
|
||||
if thirdPaths["players"] == firstPaths["players"] {
|
||||
t.Fatalf("changed party retained derived players snapshot path %q", thirdPaths["players"])
|
||||
}
|
||||
for selector, firstPath := range firstPaths {
|
||||
if selector != "party" && selector != "players" && thirdPaths[selector] != firstPath {
|
||||
t.Fatalf("unchanged %s snapshot path = %q, want %q", selector, thirdPaths[selector], firstPath)
|
||||
}
|
||||
if _, err := os.Stat(firstPath); err != nil {
|
||||
t.Fatalf("old %s snapshot %q was not retained: %v", selector, firstPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleCacheControlsAndSnapshotLossDoNotInvalidateCompleteResult(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
configureLifecycleReferences(t, cfg)
|
||||
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("initial executeStages() error = %v", err)
|
||||
}
|
||||
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
cacheRoot := artifacts.SessionNotariusReferenceCacheDir(paths)
|
||||
if err := os.RemoveAll(cacheRoot); err != nil {
|
||||
t.Fatalf("RemoveAll(snapshot cache) error = %v", err)
|
||||
}
|
||||
cfg.Pipeline.Notarius.Resume = true
|
||||
cfg.Pipeline.Notarius.ChunkCache = "bypass"
|
||||
extractPlan, _ := BuildSingleStagePlan("extract")
|
||||
summary, err := executeStages(context.Background(), cfg, extractPlan, RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("resume executeStages() error = %v", err)
|
||||
}
|
||||
if len(summary.Executed) != 0 || !reflect.DeepEqual(summary.Skipped, []string{"extract"}) || len(runner.requests) != 1 {
|
||||
t.Fatalf("resume summary = %#v requests=%d", summary, len(runner.requests))
|
||||
}
|
||||
if _, err := os.Stat(cacheRoot); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("skipped extraction recreated snapshot cache: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeOnlyLifecyclePreservesExtractionWithoutInvokingNotarius(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
extractPlan, _ := BuildSingleStagePlan("extract")
|
||||
if _, err := executeStages(context.Background(), cfg, extractPlan, RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("extract executeStages() error = %v", err)
|
||||
}
|
||||
before := loadLifecycleManifest(t, cfg).Stages["extract"]
|
||||
beforeJSON, _ := json.Marshal(before)
|
||||
analyzeRuns := 0
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{countingStage{name: "analyze", runs: &analyzeRuns}}, RunOptions{Env: env, Force: true}); err != nil {
|
||||
t.Fatalf("analyze-only executeStages() error = %v", err)
|
||||
}
|
||||
after := loadLifecycleManifest(t, cfg).Stages["extract"]
|
||||
afterJSON, _ := json.Marshal(after)
|
||||
if analyzeRuns != 1 || len(runner.requests) != 1 || string(afterJSON) != string(beforeJSON) {
|
||||
t.Fatalf("analyze-only lifecycle changed extraction: analyze=%d requests=%d\nbefore=%s\nafter=%s", analyzeRuns, len(runner.requests), beforeJSON, afterJSON)
|
||||
}
|
||||
for _, output := range after.Outputs {
|
||||
if _, err := os.Stat(output.LocalPath); err != nil {
|
||||
t.Fatalf("analyze-only lifecycle removed extract output %q: %v", output.LocalPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForcedArtifactRegenerationUsesOrdinaryExtractAndAnalyzePath(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
cfg.Pipeline.Notarius.Resume = true
|
||||
cfg.Pipeline.Notarius.ChunkCache = "bypass"
|
||||
analyzeRuns := 0
|
||||
plan := extractionLifecyclePlan(t, &analyzeRuns)
|
||||
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env, Force: true, SelectedArtifacts: []string{"session_recap"}}); err != nil {
|
||||
t.Fatalf("forced artifact regeneration error = %v", err)
|
||||
}
|
||||
if len(runner.requests) != 1 || runner.requests[0].Resume || runner.requests[0].ChunkCache != "refresh" || analyzeRuns != 1 {
|
||||
t.Fatalf("forced path request=%#v analyze=%d", runner.requests, analyzeRuns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleForcedSelfSkipInvalidatesDownstream(t *testing.T) {
|
||||
cfg, env, _ := extractionLifecycleFixture(t, false)
|
||||
markLifecycleStageSucceeded(t, cfg, "analyze")
|
||||
@@ -485,6 +625,273 @@ func TestExtractLifecycleSkipsCurrentResumableResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleCredentialChangesRemainResumable(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
before func(*testing.T) *configkit.Secret
|
||||
after func(*testing.T) *configkit.Secret
|
||||
}{
|
||||
{
|
||||
name: "added",
|
||||
after: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_ADDED_KEY", "added-value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "removed",
|
||||
before: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_REMOVED_KEY", "removed-value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "renamed",
|
||||
before: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_FIRST_KEY", "first-value")
|
||||
},
|
||||
after: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_SECOND_KEY", "second-value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "value rotated",
|
||||
before: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_ROTATED_KEY", "first-value")
|
||||
},
|
||||
after: func(t *testing.T) *configkit.Secret {
|
||||
return loadLifecycleNotariusCredential(t, "NARRATIO_LIFECYCLE_ROTATED_KEY", "second-value")
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
if test.before != nil {
|
||||
cfg.Pipeline.Notarius.LLMAPIKey = test.before(t)
|
||||
}
|
||||
analyzeRuns := 0
|
||||
plan := extractionLifecyclePlan(t, &analyzeRuns)
|
||||
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("initial executeStages() error = %v", err)
|
||||
}
|
||||
cfg.Pipeline.Notarius.LLMAPIKey = nil
|
||||
if test.after != nil {
|
||||
cfg.Pipeline.Notarius.LLMAPIKey = test.after(t)
|
||||
}
|
||||
resumed, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("resume executeStages() error = %v", err)
|
||||
}
|
||||
if len(resumed.Executed) != 0 || len(resumed.Skipped) != 2 || len(runner.requests) != 1 || analyzeRuns != 1 {
|
||||
t.Fatalf("credential-only change reexecuted work: summary=%#v requests=%d analyze=%d", resumed, len(runner.requests), analyzeRuns)
|
||||
}
|
||||
loaded := loadLifecycleManifest(t, cfg)
|
||||
if loaded.Stages["extract"].Status != manifest.StatusSucceeded || loaded.Stages["analyze"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("credential-only change invalidated stages: extract=%q analyze=%q", loaded.Stages["extract"].Status, loaded.Stages["analyze"].Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleConfigProfileChangesRerunExtractionAndDependents(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
selected := "inexpensive"
|
||||
cfg.Pipeline.Notarius.ConfigProfile = &selected
|
||||
analyzeRuns := 0
|
||||
plan := extractionLifecyclePlan(t, &analyzeRuns)
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("initial executeStages() error = %v", err)
|
||||
}
|
||||
if len(runner.requests) != 1 || runner.requests[0].ConfigProfile == nil || *runner.requests[0].ConfigProfile != selected || analyzeRuns != 1 {
|
||||
t.Fatalf("initial requests=%#v analyze=%d, want selected extraction and analysis", runner.requests, analyzeRuns)
|
||||
}
|
||||
reused, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("reuse executeStages() error = %v", err)
|
||||
}
|
||||
if len(reused.Executed) != 0 || len(runner.requests) != 1 || analyzeRuns != 1 {
|
||||
t.Fatalf("same-profile reuse summary=%#v requests=%d analyze=%d", reused, len(runner.requests), analyzeRuns)
|
||||
}
|
||||
|
||||
markLifecycleStageSucceeded(t, cfg, "render")
|
||||
markLifecycleStageSucceeded(t, cfg, "publish")
|
||||
before := loadLifecycleManifest(t, cfg)
|
||||
trimBefore, err := json.Marshal(before.Stages["trim"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
changed := "experimental"
|
||||
cfg.Pipeline.Notarius.ConfigProfile = &changed
|
||||
rerun, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("changed-profile executeStages() error = %v", err)
|
||||
}
|
||||
if len(rerun.Executed) != 2 || len(runner.requests) != 2 || runner.requests[1].ConfigProfile == nil || *runner.requests[1].ConfigProfile != changed || analyzeRuns != 2 {
|
||||
t.Fatalf("changed-profile summary=%#v requests=%#v analyze=%d", rerun, runner.requests, analyzeRuns)
|
||||
}
|
||||
after := loadLifecycleManifest(t, cfg)
|
||||
trimAfter, err := json.Marshal(after.Stages["trim"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(trimAfter) != string(trimBefore) {
|
||||
t.Fatalf("profile change altered upstream trim record:\nbefore=%s\nafter=%s", trimBefore, trimAfter)
|
||||
}
|
||||
if after.Stages["render"].Status != manifest.StatusSucceeded || after.Stages["analyze"].Status != manifest.StatusSucceeded || after.Stages["publish"].Status != manifest.StatusStale {
|
||||
t.Fatalf("changed-profile lifecycle render=%#v analyze=%#v publish=%#v", after.Stages["render"], after.Stages["analyze"], after.Stages["publish"])
|
||||
}
|
||||
|
||||
cfg.Pipeline.Notarius.ConfigProfile = nil
|
||||
returned, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("return-to-base executeStages() error = %v", err)
|
||||
}
|
||||
if len(returned.Executed) != 2 || len(runner.requests) != 3 || runner.requests[2].ConfigProfile != nil || analyzeRuns != 3 {
|
||||
t.Fatalf("return-to-base summary=%#v requests=%#v analyze=%d", returned, runner.requests, analyzeRuns)
|
||||
}
|
||||
final := loadLifecycleManifest(t, cfg)
|
||||
if final.Stages["trim"].Status != manifest.StatusSucceeded || final.Stages["render"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("return-to-base invalidated upstream siblings: trim=%#v render=%#v", final.Stages["trim"], final.Stages["render"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleConfigProfileFailureClearsCurrentResultAndDependents(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
selected := "inexpensive"
|
||||
cfg.Pipeline.Notarius.ConfigProfile = &selected
|
||||
analyzeRuns := 0
|
||||
plan := extractionLifecyclePlan(t, &analyzeRuns)
|
||||
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("initial executeStages() error = %v", err)
|
||||
}
|
||||
markLifecycleStageSucceeded(t, cfg, "render")
|
||||
markLifecycleStageSucceeded(t, cfg, "publish")
|
||||
|
||||
changed := "missing-profile"
|
||||
cfg.Pipeline.Notarius.ConfigProfile = &changed
|
||||
runner.failuresRemaining = 1
|
||||
if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}); err == nil {
|
||||
t.Fatal("changed-profile executeStages() error = nil, want downstream failure")
|
||||
}
|
||||
failed := loadLifecycleManifest(t, cfg)
|
||||
extract := failed.Stages["extract"]
|
||||
if extract.Status != manifest.StatusFailed || len(extract.Outputs) != 0 || len(extract.Logs) != 0 || len(extract.GeneratedConfigs) != 0 || len(extract.Metadata) != 0 {
|
||||
t.Fatalf("failed changed-profile extraction advertised prior success: %#v", extract)
|
||||
}
|
||||
if failed.Stages["trim"].Status != manifest.StatusSucceeded || failed.Stages["render"].Status != manifest.StatusSucceeded ||
|
||||
failed.Stages["analyze"].Status != manifest.StatusStale || failed.Stages["publish"].Status != manifest.StatusStale || analyzeRuns != 1 {
|
||||
t.Fatalf("failed changed-profile lifecycle trim=%#v render=%#v analyze=%#v publish=%#v analyzeRuns=%d",
|
||||
failed.Stages["trim"], failed.Stages["render"], failed.Stages["analyze"], failed.Stages["publish"], analyzeRuns)
|
||||
}
|
||||
if len(runner.requests) != 2 || runner.requests[1].ConfigProfile == nil || *runner.requests[1].ConfigProfile != changed {
|
||||
t.Fatalf("failed request = %#v, want changed selector", runner.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T) {
|
||||
seed, env, runner := extractionLifecycleFixture(t, true)
|
||||
workspace := seed.Pipeline.Workspace.Root
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspace)
|
||||
pipelineFile, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, writeErr := pipelineFile.WriteString(`notarius:
|
||||
enabled: true
|
||||
binary: ` + filepath.Join(workspace, "notarius") + `
|
||||
config_path: notarius.yml
|
||||
pipeline_id: dnd-session
|
||||
timeout: 45m
|
||||
outputs:
|
||||
npc_registry:
|
||||
lane_id: npc-registry
|
||||
media_type: application/json
|
||||
schema_id: notarius.dnd.npc_registry
|
||||
schema_version: v1
|
||||
module_key: dnd/npc-registry
|
||||
`)
|
||||
closeErr := pipelineFile.Close()
|
||||
if writeErr != nil || closeErr != nil {
|
||||
t.Fatalf("write Notarius configuration: %v / %v", writeErr, closeErr)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(pipelinePath), "notarius.yml"), []byte("pipelines: {}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profilesDir := filepath.Join(filepath.Dir(pipelinePath), "profiles")
|
||||
if err := os.MkdirAll(profilesDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, contents := range map[string]string{
|
||||
"testing": "whisperx:\n language: fr\nnotarius:\n config_profile: downstream-economy\n",
|
||||
"narratio-only": "whisperx:\n language: de\n",
|
||||
"narratio-only-two": "whisperx:\n language: es\n",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(profilesDir, name+".yml"), []byte(contents), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
load := func(profile string) *config.Config {
|
||||
t.Helper()
|
||||
loaded, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{Profile: &profile})
|
||||
if err != nil {
|
||||
t.Fatalf("load profile %q: %v", profile, err)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
plan, err := BuildSingleStagePlan("extract")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
testingConfig := load("testing")
|
||||
runner.cfg = testingConfig.Pipeline.Notarius
|
||||
if _, err := executeStages(context.Background(), testingConfig, plan, RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("execute testing profile: %v", err)
|
||||
}
|
||||
if len(runner.requests) != 1 || runner.requests[0].ConfigProfile == nil || *runner.requests[0].ConfigProfile != "downstream-economy" {
|
||||
t.Fatalf("testing profile request = %#v, want explicit differently named downstream selector", runner.requests)
|
||||
}
|
||||
|
||||
narratioOnly := load("narratio-only")
|
||||
runner.cfg = narratioOnly.Pipeline.Notarius
|
||||
if _, err := executeStages(context.Background(), narratioOnly, plan, RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("execute Narratio-only profile: %v", err)
|
||||
}
|
||||
if len(runner.requests) != 2 || runner.requests[1].ConfigProfile != nil {
|
||||
t.Fatalf("Narratio-only profile request = %#v, want no automatic downstream selector", runner.requests)
|
||||
}
|
||||
|
||||
narratioOnlyTwo := load("narratio-only-two")
|
||||
runner.cfg = narratioOnlyTwo.Pipeline.Notarius
|
||||
reused, err := executeStages(context.Background(), narratioOnlyTwo, plan, RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("execute second Narratio-only profile: %v", err)
|
||||
}
|
||||
if len(reused.Executed) != 0 || len(runner.requests) != 2 {
|
||||
t.Fatalf("Narratio profile label/effective digest broadened invalidation: summary=%#v requests=%#v", reused, runner.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedPipelineAndManifestExcludeNotariusCredentialValue(t *testing.T) {
|
||||
const name = "NARRATIO_LIFECYCLE_PERSISTED_KEY"
|
||||
const canary = "notarius-persisted-credential-canary"
|
||||
cfg, env, _ := extractionLifecycleFixture(t, true)
|
||||
cfg.Pipeline.Notarius.LLMAPIKey = loadLifecycleNotariusCredential(t, name, canary)
|
||||
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
for _, path := range []string{paths.ManifestPath, filepath.Join(paths.InputsDir, "pipeline.resolved.yml")} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
||||
}
|
||||
if strings.Contains(string(data), canary) {
|
||||
t.Fatalf("persisted Narratio file %q contains the credential value", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleRerunsAfterDirectTranscriptChange(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
analyzeRuns := 0
|
||||
@@ -696,6 +1103,14 @@ func lifecycleInputChecksum(t *testing.T, m *manifest.Manifest, kind string) str
|
||||
return ""
|
||||
}
|
||||
|
||||
func lifecycleReferencePaths(request notarius.RunRequest) map[string]string {
|
||||
paths := make(map[string]string, len(request.References))
|
||||
for _, reference := range request.References {
|
||||
paths[reference.Selector] = reference.Path
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func lifecycleDndOutputs() map[string]config.NotariusOutputConfig {
|
||||
return map[string]config.NotariusOutputConfig{
|
||||
"item_registry": {LaneID: "item-registry", MediaType: "application/json", SchemaID: "notarius.dnd.item_registry", SchemaVersion: "v1", ModuleKey: "dnd/item-registry"},
|
||||
@@ -761,3 +1176,17 @@ func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *st
|
||||
runner := &materializingNotariusRunner{cfg: cfg.Pipeline.Notarius}
|
||||
return cfg, &stage.Env{Notarius: runner}, runner
|
||||
}
|
||||
|
||||
func loadLifecycleNotariusCredential(t *testing.T, name, value string) *configkit.Secret {
|
||||
t.Helper()
|
||||
t.Setenv(name, value)
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+name+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pipeline, err := config.LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pipeline.Notarius.LLMAPIKey
|
||||
}
|
||||
|
||||
@@ -164,19 +164,19 @@ func TestExecuteSessionInitRequiresSessionID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) {
|
||||
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
||||
config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")}
|
||||
t.Cleanup(func() {
|
||||
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
|
||||
})
|
||||
deps, missingPath, executor := guardedMissingPipelineDiscovery(t)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr)
|
||||
code := executeWithDependencies([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr, deps, executor)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "session init: no pipeline config path provided and no default pipeline config found; searched:") {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if got := stderr.String(); !strings.Contains(got, "session init: no pipeline config path provided") ||
|
||||
!strings.Contains(got, "no default pipeline config found") || !strings.Contains(got, missingPath) {
|
||||
t.Fatalf("stderr = %q, want default pipeline searched-path error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestResolvePipelineConfigPathWithCandidatesExplicitWins(t *testing.T) {
|
||||
@@ -55,11 +61,57 @@ func TestResolvePipelineConfigPathWithCandidatesPrecedence(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolvePipelineConfigPathWithCandidatesMissing(t *testing.T) {
|
||||
_, err := resolvePipelineConfigPathWithCandidates("", []string{"/does/not/exist/one.yml", "/does/not/exist/two.yml"})
|
||||
directory := t.TempDir()
|
||||
first := filepath.Join(directory, "one.yml")
|
||||
second := filepath.Join(directory, "two.yml")
|
||||
_, err := resolvePipelineConfigPathWithCandidates("", []string{first, second})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no default pipeline config found") {
|
||||
t.Fatalf("error = %q, want missing-defaults context", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), first) || !strings.Contains(err.Error(), second) {
|
||||
t.Fatalf("error = %q, want controlled searched paths", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMissingExplicitConfigDiscoversExistingDefaultBeforeLoading(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
missing := filepath.Join(directory, "missing.yml")
|
||||
existing := filepath.Join(directory, "pipeline.yml")
|
||||
if err := os.WriteFile(existing, []byte("workspace:\n root: ./tmp\n"), 0o644); err != nil {
|
||||
t.Fatalf("write default pipeline: %v", err)
|
||||
}
|
||||
|
||||
controlledErr := errors.New("controlled pipeline loader boundary")
|
||||
loadedPath := ""
|
||||
deps := commandDependenciesForPipelineCandidates(missing, existing)
|
||||
deps.loadPipelineConfig = func(path string, _ config.PipelineLoadOptions) (*config.PipelineConfig, error) {
|
||||
loadedPath = path
|
||||
return nil, controlledErr
|
||||
}
|
||||
deps.loadSecretsDir = func(string) (*configkit.SecretsReport, error) {
|
||||
t.Fatal("absent conventional secrets directory unexpectedly invoked bootstrap loader")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := executeWithDependencies([]string{"config", "validate"}, &stdout, &stderr, deps, func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||
t.Fatal("config validation unexpectedly invoked pipeline executor")
|
||||
return nil, nil
|
||||
})
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want controlled loader failure")
|
||||
}
|
||||
if loadedPath != existing {
|
||||
t.Fatalf("loader path = %q, want discovered default %q", loadedPath, existing)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), controlledErr.Error()) {
|
||||
t.Fatalf("stderr = %q, want controlled loader error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,28 @@ func TestExecuteRestoreRoundTripsPublishedExtractionAndManifestMetadata(t *testi
|
||||
remoteManifest.RunID = "20260519T010203Z-a1b2c3d4"
|
||||
producerRoot := "/prior/workspace/work/sample-campaign/2026-05-03"
|
||||
remoteManifest.LocalWorkDir = filepath.Join(producerRoot, "runs", remoteManifest.RunID)
|
||||
rawRelative := "transcripts/raw/alice.json"
|
||||
rawOutput := manifest.ArtifactRecord{
|
||||
Kind: "transcript_raw", LocalPath: filepath.Join(producerRoot, filepath.FromSlash(rawRelative)),
|
||||
ProducerRunID: remoteManifest.RunID, Checksum: strings.Repeat("b", 64),
|
||||
}
|
||||
remoteManifest.MarkStageSucceeded("transcribe", time.Now().UTC(), []manifest.ArtifactRecord{rawOutput})
|
||||
remoteManifest.Stages["transcribe"].TranscribeStateVersion = manifest.TranscribeStateContractVersion
|
||||
remoteManifest.Stages["transcribe"].TranscribeSpeakers = map[string]manifest.TranscribeSpeakerRecord{
|
||||
"alice": {
|
||||
FingerprintVersion: manifest.TranscribeFingerprintContractVersion,
|
||||
Fingerprint: strings.Repeat("c", 64),
|
||||
AudioPath: "audio/alice.flac",
|
||||
AudioSHA256: strings.Repeat("a", 64),
|
||||
AudioSize: 12,
|
||||
Output: manifest.ArtifactRecord{
|
||||
Kind: "transcript_raw", LocalPath: rawRelative,
|
||||
ProducerRunID: remoteManifest.RunID, Checksum: strings.Repeat("b", 64),
|
||||
},
|
||||
OutputSize: 15,
|
||||
ProducerRunID: remoteManifest.RunID,
|
||||
},
|
||||
}
|
||||
remoteManifest.Stages["extract"] = &manifest.StageRecord{
|
||||
Name: "extract", Status: manifest.StatusSucceeded,
|
||||
Outputs: []manifest.ArtifactRecord{{
|
||||
@@ -121,6 +143,13 @@ func TestExecuteRestoreRoundTripsPublishedExtractionAndManifestMetadata(t *testi
|
||||
if want := filepath.Join(sessionRoot, "artifacts", "encounters.json"); lane.LocalPath != want {
|
||||
t.Fatalf("rebased extraction path = %q, want %q", lane.LocalPath, want)
|
||||
}
|
||||
speaker := restored.Stages["transcribe"].TranscribeSpeakers["alice"]
|
||||
if speaker.AudioPath != "audio/alice.flac" || speaker.Output.LocalPath != rawRelative {
|
||||
t.Fatalf("restored speaker paths = %#v, want session-relative paths", speaker)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sessionRoot, filepath.FromSlash(rawRelative))); !os.IsNotExist(err) {
|
||||
t.Fatalf("unpublished raw transcript should not be restored; stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
|
||||
|
||||
@@ -24,6 +24,7 @@ func TestRestorePlanDefaultScope(t *testing.T) {
|
||||
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
||||
seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}"))
|
||||
seedRestoreObject(store, current.SessionPrefix+"logs/publish.log", []byte("log"))
|
||||
seedRestoreObject(store, current.SessionPrefix+"cache/notarius/references/ignored/party.yml", []byte("cache"))
|
||||
|
||||
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||
if err != nil {
|
||||
|
||||
@@ -1082,7 +1082,7 @@ func TestExecuteStagesReportsSkippedStage(t *testing.T) {
|
||||
t.Fatalf("Save manifest error = %v", err)
|
||||
}
|
||||
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{BuildFullPlan()[1]}, RunOptions{})
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{resultStage{name: "transcribe"}}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
@@ -1302,7 +1302,7 @@ func TestExecuteStagesSkippedStagePreservesExistingOutputsProvenance(t *testing.
|
||||
t.Fatalf("Save manifest error = %v", err)
|
||||
}
|
||||
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{BuildFullPlan()[1]}, RunOptions{})
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{resultStage{name: "transcribe"}}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,18 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/pathsafe"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
@@ -38,6 +46,94 @@ func seedCurrentSemanticEvidence(t *testing.T, cfg *config.Config, m *manifest.M
|
||||
t.Fatalf("fingerprint stage %q: %v", name, err)
|
||||
}
|
||||
record.SemanticConfig = &fingerprint
|
||||
if name == "transcribe" {
|
||||
seedCurrentTranscribeEvidence(t, cfg, m, fingerprint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedCurrentTranscribeEvidence(
|
||||
t *testing.T,
|
||||
cfg *config.Config,
|
||||
m *manifest.Manifest,
|
||||
semantic manifest.SemanticConfigFingerprint,
|
||||
) {
|
||||
t.Helper()
|
||||
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
||||
paths := store.SessionPathsFor(cfg.Session.Campaign, m.SessionID)
|
||||
if strings.TrimSpace(m.RunID) == "" {
|
||||
m.RunID = "20260503T010203Z-a1b2c3d4"
|
||||
}
|
||||
records := make(map[string]manifest.TranscribeSpeakerRecord)
|
||||
for _, input := range m.Inputs {
|
||||
if input.Kind != "audio" {
|
||||
continue
|
||||
}
|
||||
audioPath := artifacts.ResolveSessionLocalPathForRead(paths, input.Path)
|
||||
audioBytes, err := os.ReadFile(audioPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read transcribe audio fixture %q: %v", audioPath, err)
|
||||
}
|
||||
audioRelative, err := pathsafe.SlashRelativeFromRoot(paths.Root, audioPath)
|
||||
if err != nil {
|
||||
t.Fatalf("relativize transcribe audio fixture %q: %v", audioPath, err)
|
||||
}
|
||||
speaker := strings.TrimSuffix(filepath.Base(audioPath), filepath.Ext(audioPath))
|
||||
outputRelative := filepath.ToSlash(filepath.Join("transcripts", "raw", speaker+".json"))
|
||||
outputPath, err := pathsafe.JoinSlashRelativeUnderRoot(paths.Root, outputRelative)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve transcribe output fixture: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
||||
mustWriteTestFile(t, outputPath, `{"segments":[]}`)
|
||||
} else if err != nil {
|
||||
t.Fatalf("inspect transcribe output fixture %q: %v", outputPath, err)
|
||||
}
|
||||
outputBytes, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read transcribe output fixture %q: %v", outputPath, err)
|
||||
}
|
||||
audioDigest := sha256.Sum256(audioBytes)
|
||||
outputDigest := sha256.Sum256(outputBytes)
|
||||
fingerprint, err := stage.FingerprintSemanticConfig(
|
||||
manifest.TranscribeFingerprintContractVersion,
|
||||
struct {
|
||||
SpeakerID string `json:"speaker_id"`
|
||||
AudioPath string `json:"audio_path"`
|
||||
AudioSHA256 string `json:"audio_sha256"`
|
||||
AudioSize int64 `json:"audio_size"`
|
||||
SemanticConfigVersion int `json:"semantic_config_version"`
|
||||
SemanticConfigDigest string `json:"semantic_config_digest"`
|
||||
}{
|
||||
SpeakerID: speaker, AudioPath: audioRelative,
|
||||
AudioSHA256: hex.EncodeToString(audioDigest[:]), AudioSize: int64(len(audioBytes)),
|
||||
SemanticConfigVersion: semantic.Version, SemanticConfigDigest: semantic.Digest,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("fingerprint transcribe speaker fixture %q: %v", speaker, err)
|
||||
}
|
||||
output := manifest.ArtifactRecord{
|
||||
Kind: "transcript_raw", LocalPath: outputRelative,
|
||||
ProducerRunID: m.RunID, Checksum: hex.EncodeToString(outputDigest[:]),
|
||||
}
|
||||
records[speaker] = manifest.TranscribeSpeakerRecord{
|
||||
FingerprintVersion: manifest.TranscribeFingerprintContractVersion,
|
||||
Fingerprint: fingerprint.Digest, AudioPath: audioRelative,
|
||||
AudioSHA256: hex.EncodeToString(audioDigest[:]), AudioSize: int64(len(audioBytes)),
|
||||
Output: output, OutputSize: int64(len(outputBytes)), ProducerRunID: m.RunID,
|
||||
}
|
||||
}
|
||||
record := m.Stages["transcribe"]
|
||||
record.TranscribeStateVersion = manifest.TranscribeStateContractVersion
|
||||
record.TranscribeSpeakers = records
|
||||
if len(record.Outputs) == 0 {
|
||||
for _, speakerRecord := range records {
|
||||
record.Outputs = append(record.Outputs, speakerRecord.Output)
|
||||
}
|
||||
slices.SortFunc(record.Outputs, func(a, b manifest.ArtifactRecord) int {
|
||||
return strings.Compare(a.LocalPath, b.LocalPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -119,6 +120,52 @@ func TestSecretsBootstrapPreservesExistingEnvironmentValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialLoadsFromCommandSecretBootstrap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
explicit bool
|
||||
environment string
|
||||
fileValue string
|
||||
wantValue string
|
||||
}{
|
||||
{name: "conventional", fileValue: "conventional-value", wantValue: "conventional-value"},
|
||||
{name: "explicit", explicit: true, fileValue: "explicit-value", wantValue: "explicit-value"},
|
||||
{name: "environment precedence", explicit: true, environment: "environment-value", fileValue: "file-value", wantValue: "environment-value"},
|
||||
}
|
||||
for index, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
envName := fmt.Sprintf("NARRATIO_NOTARIUS_BOOTSTRAP_%d", index)
|
||||
restoreEnvAfterTest(t, envName)
|
||||
root := t.TempDir()
|
||||
pipelinePath := filepath.Join(root, "pipeline.yml")
|
||||
if err := os.WriteFile(pipelinePath, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secretsDir := filepath.Join(root, "secrets")
|
||||
var selection *string
|
||||
if test.explicit {
|
||||
secretsDir = filepath.Join(t.TempDir(), "selected-secrets")
|
||||
selection = &secretsDir
|
||||
}
|
||||
mustWriteSecretFile(t, filepath.Join(secretsDir, envName), test.fileValue+"\n")
|
||||
if test.environment != "" {
|
||||
if err := os.Setenv(envName, test.environment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
_, pipeline, err := loadPipelineConfigWithDependencies(pipelinePath, config.PipelineLoadOptions{}, selection, commandDependencies{diagnostics: io.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("load pipeline: %v", err)
|
||||
}
|
||||
if pipeline.Notarius == nil || pipeline.Notarius.LLMAPIKey == nil || !pipeline.Notarius.LLMAPIKey.IsResolved() ||
|
||||
pipeline.Notarius.LLMAPIKey.EnvName() != envName || pipeline.Notarius.LLMAPIKey.Value() != test.wantValue {
|
||||
t.Fatal("Notarius credential did not resolve from the selected command bootstrap source")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsBootstrapPreservesSafePartialReportOnFailure(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mustWriteSecretFile(t, filepath.Join(directory, "INVALID-NAME"), "must-not-appear\n")
|
||||
@@ -173,7 +220,7 @@ func TestExecuteConfigValidateReportsSkippedEmptyBeforeTypedLoadFailure(t *testi
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pipelineYAML = []byte(strings.Replace(string(pipelineYAML), "audita:\n binary:", "audita:\n llm_api_key: "+name+"\n binary:", 1))
|
||||
pipelineYAML = append(pipelineYAML, []byte("notarius:\n enabled: false\n llm_api_key: "+name+"\n")...)
|
||||
if err := os.WriteFile(pipelinePath, pipelineYAML, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
// restoreEnvAfterTest clears names for a test and restores both their prior
|
||||
@@ -47,3 +49,29 @@ func mustWriteSecretFile(t *testing.T, path, value string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func commandDependenciesForPipelineCandidates(candidates ...string) commandDependencies {
|
||||
ownedCandidates := append([]string(nil), candidates...)
|
||||
return commandDependencies{resolvePipelineConfigPath: func(flagValue string) (string, error) {
|
||||
return resolvePipelineConfigPathWithCandidates(flagValue, ownedCandidates)
|
||||
}}
|
||||
}
|
||||
|
||||
func guardedMissingPipelineDiscovery(t *testing.T) (commandDependencies, string, pipelineExecutor) {
|
||||
t.Helper()
|
||||
candidate := filepath.Join(t.TempDir(), "missing-pipeline.yml")
|
||||
deps := commandDependenciesForPipelineCandidates(candidate)
|
||||
deps.loadSecretsDir = func(string) (*configkit.SecretsReport, error) {
|
||||
t.Fatal("secret bootstrap continued after pipeline discovery failed")
|
||||
return nil, nil
|
||||
}
|
||||
deps.loadPipelineConfig = func(string, config.PipelineLoadOptions) (*config.PipelineConfig, error) {
|
||||
t.Fatal("typed pipeline loading continued after pipeline discovery failed")
|
||||
return nil, nil
|
||||
}
|
||||
executor := func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||
t.Fatal("pipeline execution continued after pipeline discovery failed")
|
||||
return nil, nil
|
||||
}
|
||||
return deps, candidate, executor
|
||||
}
|
||||
|
||||
472
internal/app/transcribe_checkpoint_recovery_test.go
Normal file
472
internal/app/transcribe_checkpoint_recovery_test.go
Normal file
@@ -0,0 +1,472 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
||||
"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 TestExecuteStagesDelegatesEmptyAudioAcceptanceToWhisperX(t *testing.T) {
|
||||
for _, rejected := range []bool{false, true} {
|
||||
name := "accepted"
|
||||
if rejected {
|
||||
name = "rejected"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg, store := setupTranscribeRecoveryFixture(t, []string{"alice"})
|
||||
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
if err := os.WriteFile(filepath.Join(paths.AudioDir, "alice.flac"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &whisperx.FakeClient{}
|
||||
if rejected {
|
||||
fake.Err = errors.New("service rejected audio")
|
||||
}
|
||||
transcribe := mustSelectStage(t, "transcribe")
|
||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: fake},
|
||||
})
|
||||
if !errors.Is(err, fake.Err) {
|
||||
t.Fatalf("transcribe error = %v, want %v", err, fake.Err)
|
||||
}
|
||||
if len(fake.RequestsSnapshot()) != 1 {
|
||||
t.Fatal("empty audio was not sent to service")
|
||||
}
|
||||
m := loadTranscribeRecoveryManifest(t, store, cfg)
|
||||
if rejected {
|
||||
if len(m.Stages["transcribe"].TranscribeSpeakers) != 0 {
|
||||
t.Fatal("rejected audio was checkpointed")
|
||||
}
|
||||
return
|
||||
}
|
||||
record, ok := m.Stages["transcribe"].TranscribeSpeakers["alice"]
|
||||
if !ok || record.AudioSize != 0 {
|
||||
t.Fatalf("missing zero-size checkpoint: %#v", record)
|
||||
}
|
||||
retry := &whisperx.FakeClient{}
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: retry},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summary.Skipped) != 1 || len(retry.RequestsSnapshot()) != 0 {
|
||||
t.Fatal("successful empty-audio result was not reused")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesRetriesOnlyUncommittedTranscribeSpeakers(t *testing.T) {
|
||||
cfg, baseStore := setupTranscribeRecoveryFixture(t, []string{"a", "b", "c", "d"})
|
||||
concurrency := 1
|
||||
cfg.Pipeline.WhisperX.Concurrency = &concurrency
|
||||
cause := errors.New("speaker c failed")
|
||||
failing := &whisperx.FakeClient{TranscribeFn: func(
|
||||
_ context.Context,
|
||||
req whisperx.TranscribeRequest,
|
||||
) (whisperx.TranscribeResult, error) {
|
||||
if req.SpeakerID == "c" {
|
||||
return whisperx.TranscribeResult{}, cause
|
||||
}
|
||||
if req.SpeakerID == "d" {
|
||||
t.Fatalf("speaker d was dispatched after c failed")
|
||||
}
|
||||
return writeTranscribeRecoveryResult(req)
|
||||
}}
|
||||
transcribe := mustSelectStage(t, "transcribe")
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: baseStore, WhisperX: failing},
|
||||
}); !errors.Is(err, cause) {
|
||||
t.Fatalf("failed executeStages() error = %v, want %v", err, cause)
|
||||
}
|
||||
failed := loadTranscribeRecoveryManifest(t, baseStore, cfg)
|
||||
if failed.Stages["transcribe"].Status != manifest.StatusFailed {
|
||||
t.Fatalf("transcribe status = %q, want failed", failed.Stages["transcribe"].Status)
|
||||
}
|
||||
if got := transcribeSpeakerKeys(failed); !slices.Equal(got, []string{"a", "b"}) {
|
||||
t.Fatalf("checkpointed speakers = %#v, want a and b", got)
|
||||
}
|
||||
if failed.Stages["prepare"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("prepare status = %q, want unchanged success", failed.Stages["prepare"].Status)
|
||||
}
|
||||
if failed.Stages["merge"].Status != manifest.StatusStale {
|
||||
t.Fatalf("merge status = %q, want stale after transcribe failure", failed.Stages["merge"].Status)
|
||||
}
|
||||
|
||||
retry := &whisperx.FakeClient{}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: baseStore, WhisperX: retry},
|
||||
}); err != nil {
|
||||
t.Fatalf("retry executeStages() error = %v", err)
|
||||
}
|
||||
if got := requestedTranscribeSpeakers(retry); !slices.Equal(got, []string{"c", "d"}) {
|
||||
t.Fatalf("retry speakers = %#v, want c and d", got)
|
||||
}
|
||||
completed := loadTranscribeRecoveryManifest(t, baseStore, cfg)
|
||||
if completed.Stages["transcribe"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("transcribe status = %q, want succeeded", completed.Stages["transcribe"].Status)
|
||||
}
|
||||
if got := transcribeSpeakerKeys(completed); !slices.Equal(got, []string{"a", "b", "c", "d"}) {
|
||||
t.Fatalf("completed speakers = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesRebuildsTranscribeAggregateAfterFinalSaveFailure(t *testing.T) {
|
||||
cfg, baseStore := setupTranscribeRecoveryFixture(t, []string{"alice", "bob"})
|
||||
store := &transcribeAggregateSaveFailingStore{
|
||||
Store: baseStore,
|
||||
Err: errors.New("aggregate save failed"),
|
||||
}
|
||||
fake := &whisperx.FakeClient{}
|
||||
transcribe := mustSelectStage(t, "transcribe")
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: fake},
|
||||
}); !errors.Is(err, store.Err) {
|
||||
t.Fatalf("executeStages() error = %v, want aggregate save failure", err)
|
||||
}
|
||||
failed := loadTranscribeRecoveryManifest(t, baseStore, cfg)
|
||||
if failed.Stages["transcribe"].Status != manifest.StatusFailed {
|
||||
t.Fatalf("transcribe status = %q, want failed", failed.Stages["transcribe"].Status)
|
||||
}
|
||||
if got := transcribeSpeakerKeys(failed); !slices.Equal(got, []string{"alice", "bob"}) {
|
||||
t.Fatalf("checkpointed speakers = %#v, want both speakers", got)
|
||||
}
|
||||
|
||||
retry := &whisperx.FakeClient{}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: baseStore, WhisperX: retry},
|
||||
}); err != nil {
|
||||
t.Fatalf("retry executeStages() error = %v", err)
|
||||
}
|
||||
if got := len(retry.RequestsSnapshot()); got != 0 {
|
||||
t.Fatalf("retry adapter requests = %d, want aggregate-only rebuild", got)
|
||||
}
|
||||
completed := loadTranscribeRecoveryManifest(t, baseStore, cfg)
|
||||
if completed.Stages["transcribe"].Status != manifest.StatusSucceeded || len(completed.Stages["transcribe"].Outputs) != 2 {
|
||||
t.Fatalf("rebuilt aggregate = %#v", completed.Stages["transcribe"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesRepairsSucceededTranscribeAggregateWithoutRequests(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*manifest.Manifest)
|
||||
removed bool
|
||||
}{
|
||||
{name: "removed speaker", removed: true, mutate: func(m *manifest.Manifest) {
|
||||
m.Inputs = slices.DeleteFunc(m.Inputs, func(in manifest.InputRecord) bool { return filepath.Base(in.Path) == "bob.flac" })
|
||||
}},
|
||||
{name: "missing output", mutate: func(m *manifest.Manifest) { m.Stages["transcribe"].Outputs = m.Stages["transcribe"].Outputs[:1] }},
|
||||
{name: "duplicate output", mutate: func(m *manifest.Manifest) { m.Stages["transcribe"].Outputs[1] = m.Stages["transcribe"].Outputs[0] }},
|
||||
{name: "wrong path", mutate: func(m *manifest.Manifest) { m.Stages["transcribe"].Outputs[0].LocalPath = "transcripts/raw/other.json" }},
|
||||
{name: "wrong checksum", mutate: func(m *manifest.Manifest) { m.Stages["transcribe"].Outputs[0].Checksum = "obsolete" }},
|
||||
{name: "wrong producer", mutate: func(m *manifest.Manifest) { m.Stages["transcribe"].Outputs[0].ProducerRunID = "other-run" }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg, store := setupTranscribeRecoveryFixture(t, []string{"alice", "bob"})
|
||||
transcribe := mustSelectStage(t, "transcribe")
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: &whisperx.FakeClient{}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := loadTranscribeRecoveryManifest(t, store, cfg)
|
||||
test.mutate(m)
|
||||
m.MarkStageSucceeded("merge", time.Now().UTC(), nil)
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry := &whisperx.FakeClient{}
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: retry},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(summary.Skipped) != 0 || len(retry.RequestsSnapshot()) != 0 {
|
||||
t.Fatalf("want aggregate repair without HTTP; summary=%#v requests=%#v", summary, retry.RequestsSnapshot())
|
||||
}
|
||||
completed := loadTranscribeRecoveryManifest(t, store, cfg)
|
||||
want := []string{"alice", "bob"}
|
||||
if test.removed {
|
||||
want = []string{"alice"}
|
||||
}
|
||||
record := completed.Stages["transcribe"]
|
||||
if !slices.Equal(transcribeSpeakerKeys(completed), want) || len(record.Outputs) != len(want) {
|
||||
t.Fatalf("unexpected repaired state: %#v", record)
|
||||
}
|
||||
for _, output := range record.Outputs {
|
||||
speaker := record.TranscribeSpeakers[strings.TrimSuffix(filepath.Base(output.LocalPath), ".json")]
|
||||
if output.Checksum != speaker.Output.Checksum || output.ProducerRunID != speaker.ProducerRunID {
|
||||
t.Fatalf("aggregate evidence does not match checkpoint: %#v", output)
|
||||
}
|
||||
}
|
||||
if completed.Stages["merge"].Status != manifest.StatusStale {
|
||||
t.Fatal("aggregate repair did not stale merge")
|
||||
}
|
||||
if test.removed {
|
||||
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
for _, name := range []string{"speakers.yml", "autocorrect.yml"} {
|
||||
if err := os.WriteFile(filepath.Join(paths.InputsDir, name), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
merge := &seriatim.FakeRunner{}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{mustSelectStage(t, "merge")}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, Seriatim: merge},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(merge.NormalizeRequests) != 1 || filepath.Base(merge.NormalizeRequests[0].InputTranscriptPath) != "alice.json" {
|
||||
t.Fatalf("removed speaker reached merge: %#v", merge.NormalizeRequests)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths.TranscriptsRawDir, "bob.json")); err != nil {
|
||||
t.Fatalf("incidental transcript was removed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesResumesInterruptedRunningTranscribeState(t *testing.T) {
|
||||
cfg, baseStore := setupTranscribeRecoveryFixture(t, []string{"alice", "bob"})
|
||||
concurrency := 1
|
||||
cfg.Pipeline.WhisperX.Concurrency = &concurrency
|
||||
stageErr := errors.New("speaker b failed")
|
||||
terminalErr := errors.New("terminal save interrupted")
|
||||
store := &interruptAfterTranscribeCheckpointStore{Store: baseStore, Err: terminalErr}
|
||||
fake := &whisperx.FakeClient{TranscribeFn: func(
|
||||
_ context.Context,
|
||||
req whisperx.TranscribeRequest,
|
||||
) (whisperx.TranscribeResult, error) {
|
||||
if req.SpeakerID == "bob" {
|
||||
return whisperx.TranscribeResult{}, stageErr
|
||||
}
|
||||
return writeTranscribeRecoveryResult(req)
|
||||
}}
|
||||
transcribe := mustSelectStage(t, "transcribe")
|
||||
|
||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: fake},
|
||||
})
|
||||
if !errors.Is(err, stageErr) || !errors.Is(err, terminalErr) {
|
||||
t.Fatalf("executeStages() error = %v, want stage and terminal failures", err)
|
||||
}
|
||||
interrupted := loadTranscribeRecoveryManifest(t, baseStore, cfg)
|
||||
if interrupted.Stages["transcribe"].Status != manifest.StatusRunning {
|
||||
t.Fatalf("transcribe status = %q, want durable running state", interrupted.Stages["transcribe"].Status)
|
||||
}
|
||||
if got := transcribeSpeakerKeys(interrupted); !slices.Equal(got, []string{"alice"}) {
|
||||
t.Fatalf("checkpointed speakers = %#v, want alice", got)
|
||||
}
|
||||
|
||||
retry := &whisperx.FakeClient{}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: baseStore, WhisperX: retry},
|
||||
}); err != nil {
|
||||
t.Fatalf("retry executeStages() error = %v", err)
|
||||
}
|
||||
if got := requestedTranscribeSpeakers(retry); !slices.Equal(got, []string{"bob"}) {
|
||||
t.Fatalf("retry speakers = %#v, want bob", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesPersistsForcedTranscribeResetAcrossRetry(t *testing.T) {
|
||||
cfg, store := setupTranscribeRecoveryFixture(t, []string{"alice", "bob"})
|
||||
concurrency := 1
|
||||
cfg.Pipeline.WhisperX.Concurrency = &concurrency
|
||||
transcribe := mustSelectStage(t, "transcribe")
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: &whisperx.FakeClient{}},
|
||||
}); err != nil {
|
||||
t.Fatalf("baseline executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
cause := errors.New("forced bob failed")
|
||||
forced := &whisperx.FakeClient{TranscribeFn: func(
|
||||
_ context.Context,
|
||||
req whisperx.TranscribeRequest,
|
||||
) (whisperx.TranscribeResult, error) {
|
||||
if req.SpeakerID == "bob" {
|
||||
return whisperx.TranscribeResult{}, cause
|
||||
}
|
||||
return writeTranscribeRecoveryResult(req)
|
||||
}}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Force: true,
|
||||
Env: &Env{ManifestStore: store, WhisperX: forced},
|
||||
}); !errors.Is(err, cause) {
|
||||
t.Fatalf("forced executeStages() error = %v, want %v", err, cause)
|
||||
}
|
||||
failed := loadTranscribeRecoveryManifest(t, store, cfg)
|
||||
if got := transcribeSpeakerKeys(failed); !slices.Equal(got, []string{"alice"}) {
|
||||
t.Fatalf("speakers after forced failure = %#v, want only replacement alice", got)
|
||||
}
|
||||
|
||||
retry := &whisperx.FakeClient{}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: retry},
|
||||
}); err != nil {
|
||||
t.Fatalf("retry executeStages() error = %v", err)
|
||||
}
|
||||
if got := requestedTranscribeSpeakers(retry); !slices.Equal(got, []string{"bob"}) {
|
||||
t.Fatalf("retry speakers = %#v, want bob", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesUpgradesLegacyTranscribeSuccessOnce(t *testing.T) {
|
||||
cfg, store := setupTranscribeRecoveryFixture(t, []string{"alice"})
|
||||
m := loadTranscribeRecoveryManifest(t, store, cfg)
|
||||
m.MarkStageSucceeded("transcribe", time.Now().UTC(), []manifest.ArtifactRecord{{
|
||||
Kind: "transcript_raw", LocalPath: "transcripts/raw/alice.json",
|
||||
}})
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), m); err != nil {
|
||||
t.Fatalf("save legacy transcribe success: %v", err)
|
||||
}
|
||||
transcribe := mustSelectStage(t, "transcribe")
|
||||
upgrade := &whisperx.FakeClient{}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: upgrade},
|
||||
}); err != nil {
|
||||
t.Fatalf("upgrade executeStages() error = %v", err)
|
||||
}
|
||||
if got := requestedTranscribeSpeakers(upgrade); !slices.Equal(got, []string{"alice"}) {
|
||||
t.Fatalf("upgrade speakers = %#v, want alice", got)
|
||||
}
|
||||
upgraded := loadTranscribeRecoveryManifest(t, store, cfg)
|
||||
if upgraded.Stages["transcribe"].TranscribeStateVersion != manifest.TranscribeStateContractVersion {
|
||||
t.Fatalf("transcribe state version = %d, want current", upgraded.Stages["transcribe"].TranscribeStateVersion)
|
||||
}
|
||||
|
||||
reused := &whisperx.FakeClient{}
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: reused},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reuse executeStages() error = %v", err)
|
||||
}
|
||||
if len(summary.Skipped) != 1 || len(reused.RequestsSnapshot()) != 0 {
|
||||
t.Fatalf("reuse summary = %#v, requests = %#v, want skipped without HTTP", summary, reused.RequestsSnapshot())
|
||||
}
|
||||
}
|
||||
|
||||
type transcribeAggregateSaveFailingStore struct {
|
||||
manifest.Store
|
||||
Err error
|
||||
Failed bool
|
||||
}
|
||||
|
||||
func (store *transcribeAggregateSaveFailingStore) Save(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
m *manifest.Manifest,
|
||||
) error {
|
||||
record := m.Stages["transcribe"]
|
||||
if !store.Failed && record != nil && record.Status == manifest.StatusSucceeded &&
|
||||
record.TranscribeStateVersion == manifest.TranscribeStateContractVersion {
|
||||
store.Failed = true
|
||||
return store.Err
|
||||
}
|
||||
return store.Store.Save(ctx, path, m)
|
||||
}
|
||||
|
||||
type interruptAfterTranscribeCheckpointStore struct {
|
||||
manifest.Store
|
||||
Err error
|
||||
Interrupted bool
|
||||
}
|
||||
|
||||
func (store *interruptAfterTranscribeCheckpointStore) Save(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
m *manifest.Manifest,
|
||||
) error {
|
||||
if store.Interrupted {
|
||||
return store.Err
|
||||
}
|
||||
record := m.Stages["transcribe"]
|
||||
err := store.Store.Save(ctx, path, m)
|
||||
if err == nil && record != nil && record.Status == manifest.StatusRunning && len(record.TranscribeSpeakers) == 1 {
|
||||
store.Interrupted = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func setupTranscribeRecoveryFixture(t *testing.T, speakers []string) (*config.Config, *manifest.LocalStore) {
|
||||
t.Helper()
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
||||
paths, err := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare session layout: %v", err)
|
||||
}
|
||||
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
m.Campaign = cfg.Session.Campaign
|
||||
m.Inputs = make([]manifest.InputRecord, 0, len(speakers))
|
||||
for _, speaker := range speakers {
|
||||
relative := filepath.ToSlash(filepath.Join("audio", speaker+".flac"))
|
||||
if err := os.WriteFile(filepath.Join(paths.AudioDir, speaker+".flac"), []byte("audio-"+speaker), 0o644); err != nil {
|
||||
t.Fatalf("write audio fixture: %v", err)
|
||||
}
|
||||
m.Inputs = append(m.Inputs, manifest.InputRecord{Kind: "audio", Path: relative})
|
||||
}
|
||||
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
||||
m.MarkStageSucceeded("merge", time.Now().UTC(), nil)
|
||||
if err := store.Save(context.Background(), paths.ManifestPath, m); err != nil {
|
||||
t.Fatalf("save recovery fixture: %v", err)
|
||||
}
|
||||
return cfg, store
|
||||
}
|
||||
|
||||
func writeTranscribeRecoveryResult(req whisperx.TranscribeRequest) (whisperx.TranscribeResult, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(req.OutputRawTranscriptPath), 0o755); err != nil {
|
||||
return whisperx.TranscribeResult{}, err
|
||||
}
|
||||
if err := os.WriteFile(req.OutputRawTranscriptPath, []byte(`{"segments":[]}`), 0o644); err != nil {
|
||||
return whisperx.TranscribeResult{}, err
|
||||
}
|
||||
return whisperx.TranscribeResult{OutputRawTranscriptPath: req.OutputRawTranscriptPath}, nil
|
||||
}
|
||||
|
||||
func loadTranscribeRecoveryManifest(t *testing.T, store manifest.Store, cfg *config.Config) *manifest.Manifest {
|
||||
t.Helper()
|
||||
m, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("load transcribe recovery manifest: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func transcribeSpeakerKeys(m *manifest.Manifest) []string {
|
||||
keys := make([]string, 0, len(m.Stages["transcribe"].TranscribeSpeakers))
|
||||
for key := range m.Stages["transcribe"].TranscribeSpeakers {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func requestedTranscribeSpeakers(fake *whisperx.FakeClient) []string {
|
||||
requests := fake.RequestsSnapshot()
|
||||
speakers := make([]string, 0, len(requests))
|
||||
for _, request := range requests {
|
||||
speakers = append(speakers, request.SpeakerID)
|
||||
}
|
||||
return speakers
|
||||
}
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
@@ -118,6 +121,86 @@ func TestExecuteStagesDefaultWiringWhisperXFailureMarksManifestFailed(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestExhaustedWhisperXFailurePreservesEarlierSpeakerCheckpoint(t *testing.T) {
|
||||
var successful atomic.Bool
|
||||
var requestMu sync.Mutex
|
||||
var requested []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
t.Errorf("FormFile(file) error = %v", err)
|
||||
http.Error(w, "invalid multipart", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, readErr := io.Copy(io.Discard, file)
|
||||
_ = file.Close()
|
||||
if readErr != nil {
|
||||
t.Errorf("read uploaded file: %v", readErr)
|
||||
return
|
||||
}
|
||||
speaker := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||
requestMu.Lock()
|
||||
requested = append(requested, speaker)
|
||||
requestMu.Unlock()
|
||||
if speaker == "b" && !successful.Load() {
|
||||
http.Error(w, "temporary", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"segments":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg, store := setupTranscribeRecoveryFixture(t, []string{"a", "b", "c"})
|
||||
setWhisperXConfig(cfg, srv.URL, 1)
|
||||
concurrency := 1
|
||||
cfg.Pipeline.WhisperX.Concurrency = &concurrency
|
||||
httpClient, err := whisperx.NewHTTPClient(whisperx.HTTPClientConfig{
|
||||
TranscribeURL: srv.URL,
|
||||
Language: "en",
|
||||
Timeout: 2 * time.Second,
|
||||
Retries: 1,
|
||||
RetryDelay: time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPClient() error = %v", err)
|
||||
}
|
||||
transcribe := mustSelectStage(t, "transcribe")
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: httpClient},
|
||||
}); err == nil {
|
||||
t.Fatal("executeStages() error = nil, want exhausted HTTP failure")
|
||||
}
|
||||
failed := loadTranscribeRecoveryManifest(t, store, cfg)
|
||||
if got := transcribeSpeakerKeys(failed); !slices.Equal(got, []string{"a"}) {
|
||||
t.Fatalf("checkpointed speakers = %#v, want a", got)
|
||||
}
|
||||
requestMu.Lock()
|
||||
firstRequests := append([]string(nil), requested...)
|
||||
requested = nil
|
||||
requestMu.Unlock()
|
||||
if !slices.Equal(firstRequests, []string{"a", "b", "b"}) {
|
||||
t.Fatalf("initial HTTP speakers = %#v, want a then two attempts for b", firstRequests)
|
||||
}
|
||||
|
||||
successful.Store(true)
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{transcribe}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, WhisperX: httpClient},
|
||||
}); err != nil {
|
||||
t.Fatalf("retry executeStages() error = %v", err)
|
||||
}
|
||||
requestMu.Lock()
|
||||
retryRequests := append([]string(nil), requested...)
|
||||
requestMu.Unlock()
|
||||
if !slices.Equal(retryRequests, []string{"b", "c"}) {
|
||||
t.Fatalf("retry HTTP speakers = %#v, want only b and c", retryRequests)
|
||||
}
|
||||
completed := loadTranscribeRecoveryManifest(t, store, cfg)
|
||||
if got := transcribeSpeakerKeys(completed); !slices.Equal(got, []string{"a", "b", "c"}) {
|
||||
t.Fatalf("completed speakers = %#v, want all speakers", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesExplicitWhisperXInjectionOverridesDefaultWiring(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
setWhisperXConfig(cfg, "https://127.0.0.1:1/transcribe", 0)
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
const sha256HexLength = 64
|
||||
|
||||
// SessionPaths contains canonical local paths for one session work directory.
|
||||
type SessionPaths struct {
|
||||
WorkspaceRoot string
|
||||
@@ -28,6 +30,7 @@ type SessionPaths struct {
|
||||
CurrentDir string
|
||||
RunsDir string
|
||||
PreviousDir string
|
||||
CacheDir string
|
||||
PreviousManifestPath string
|
||||
PreviousArtifactsDir string
|
||||
ManifestPath string
|
||||
@@ -108,17 +111,37 @@ func SessionRunNotariusLogPathForCampaign(rootDir, campaign, sessionID, runID st
|
||||
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius.stderr.log")
|
||||
}
|
||||
|
||||
// SessionRunNotariusReferencesDirForCampaign returns the invocation-local
|
||||
// directory containing verified reference snapshots supplied to Notarius.
|
||||
func SessionRunNotariusReferencesDirForCampaign(rootDir, campaign, sessionID, runID string) string {
|
||||
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "references")
|
||||
}
|
||||
|
||||
// SessionRunNotariusOutputRootForCampaign returns the invocation-local Notarius output root.
|
||||
func SessionRunNotariusOutputRootForCampaign(rootDir, campaign, sessionID, runID string) string {
|
||||
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius-output")
|
||||
}
|
||||
|
||||
// SessionNotariusReferenceCacheDir returns the stable cache root for verified
|
||||
// reference snapshots supplied to Notarius.
|
||||
func SessionNotariusReferenceCacheDir(paths SessionPaths) string {
|
||||
return filepath.Join(paths.CacheDir, "notarius", "references")
|
||||
}
|
||||
|
||||
// SessionNotariusReferenceSnapshotPath returns the content-addressed path for
|
||||
// one verified prepared reference snapshot.
|
||||
func SessionNotariusReferenceSnapshotPath(paths SessionPaths, checksum, preparedBasename string) (string, error) {
|
||||
if len(checksum) != sha256HexLength {
|
||||
return "", fmt.Errorf("reference checksum must be a full lowercase SHA-256 digest")
|
||||
}
|
||||
for _, character := range checksum {
|
||||
if (character < '0' || character > '9') && (character < 'a' || character > 'f') {
|
||||
return "", fmt.Errorf("reference checksum must be a full lowercase SHA-256 digest")
|
||||
}
|
||||
}
|
||||
if err := pathsafe.ValidateOpaqueSegment(preparedBasename); err != nil {
|
||||
return "", fmt.Errorf("prepared reference basename: %w", err)
|
||||
}
|
||||
return pathsafe.JoinSlashRelativeUnderRoot(
|
||||
SessionNotariusReferenceCacheDir(paths),
|
||||
checksum+"/"+preparedBasename,
|
||||
)
|
||||
}
|
||||
|
||||
// SessionNotariusBundleDirForCampaign returns one immutable durable bundle destination.
|
||||
func SessionNotariusBundleDirForCampaign(rootDir, campaign, sessionID, runID string) string {
|
||||
return filepath.Join(
|
||||
@@ -268,6 +291,7 @@ func buildSessionPathsFromRoot(workspaceRoot, campaign, sessionID, root string)
|
||||
CurrentDir: filepath.Join(root, config.PathCurrentDirSegment),
|
||||
RunsDir: filepath.Join(root, config.PathRunsDirSegment),
|
||||
PreviousDir: filepath.Join(root, config.PathPreviousDirSegment),
|
||||
CacheDir: filepath.Join(root, config.PathCacheDirSegment),
|
||||
PreviousManifestPath: filepath.Join(root, config.PathPreviousDirSegment, config.PathManifestFile),
|
||||
PreviousArtifactsDir: filepath.Join(root, config.PathPreviousDirSegment, config.PathArtifactsDirSegment),
|
||||
ManifestPath: filepath.Join(root, config.PathManifestFile),
|
||||
|
||||
@@ -2,6 +2,7 @@ package artifacts
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -54,7 +55,6 @@ func TestSessionNotariusPathsForCampaign(t *testing.T) {
|
||||
{name: "extract directory", got: SessionRunExtractDirForCampaign(root, campaign, sessionID, runID), want: extractDir},
|
||||
{name: "receipt", got: SessionRunNotariusReceiptPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.receipt.json")},
|
||||
{name: "stderr", got: SessionRunNotariusLogPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.stderr.log")},
|
||||
{name: "references", got: SessionRunNotariusReferencesDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "references")},
|
||||
{name: "output root", got: SessionRunNotariusOutputRootForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius-output")},
|
||||
{name: "durable bundle", got: SessionNotariusBundleDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(root, "work", campaign, sessionID, "artifacts", "notarius", runID)},
|
||||
}
|
||||
@@ -67,6 +67,43 @@ func TestSessionNotariusPathsForCampaign(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNotariusReferenceSnapshotPath(t *testing.T) {
|
||||
paths := buildSessionPaths("/tmp/workspace", "forsaken", "2026-04-19")
|
||||
checksum := strings.Repeat("a", 64)
|
||||
got, err := SessionNotariusReferenceSnapshotPath(paths, checksum, "party.yml")
|
||||
if err != nil {
|
||||
t.Fatalf("SessionNotariusReferenceSnapshotPath() error = %v", err)
|
||||
}
|
||||
wantRoot := filepath.Join(paths.Root, "cache", "notarius", "references")
|
||||
if root := SessionNotariusReferenceCacheDir(paths); root != wantRoot {
|
||||
t.Fatalf("SessionNotariusReferenceCacheDir() = %q, want %q", root, wantRoot)
|
||||
}
|
||||
if want := filepath.Join(wantRoot, checksum, "party.yml"); got != want {
|
||||
t.Fatalf("SessionNotariusReferenceSnapshotPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionNotariusReferenceSnapshotPathRejectsUnsafeIdentity(t *testing.T) {
|
||||
paths := buildSessionPaths("/tmp/workspace", "forsaken", "2026-04-19")
|
||||
validChecksum := strings.Repeat("a", 64)
|
||||
for _, test := range []struct {
|
||||
name, checksum, basename string
|
||||
}{
|
||||
{name: "short digest", checksum: "abc", basename: "party.yml"},
|
||||
{name: "uppercase digest", checksum: strings.Repeat("A", 64), basename: "party.yml"},
|
||||
{name: "nonhex digest", checksum: strings.Repeat("g", 64), basename: "party.yml"},
|
||||
{name: "empty basename", checksum: validChecksum},
|
||||
{name: "nested basename", checksum: validChecksum, basename: "nested/party.yml"},
|
||||
{name: "parent basename", checksum: validChecksum, basename: ".."},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got, err := SessionNotariusReferenceSnapshotPath(paths, test.checksum, test.basename); err == nil {
|
||||
t.Fatalf("SessionNotariusReferenceSnapshotPath() = %q, want error", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPreviousPathsForCampaign(t *testing.T) {
|
||||
root := "/tmp/workspace"
|
||||
previousDir := SessionPreviousDirForCampaign(root, "forsaken", "2026-04-19")
|
||||
|
||||
@@ -309,6 +309,10 @@ type ScriptoriumInputConfig struct {
|
||||
type NotariusConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
Binary string `yaml:"binary" json:"binary"`
|
||||
LLMAPIKey *configkit.Secret `yaml:"llm_api_key,omitempty" json:"llm_api_key,omitempty"`
|
||||
ConfigProfile *string `yaml:"config_profile,omitempty" json:"config_profile,omitempty"`
|
||||
Resume bool `yaml:"resume,omitempty" json:"resume,omitempty"`
|
||||
ChunkCache string `yaml:"chunk_cache,omitempty" json:"chunk_cache,omitempty"`
|
||||
ConfigPath string `yaml:"config_path" json:"config_path"`
|
||||
PipelineID string `yaml:"pipeline_id" json:"pipeline_id"`
|
||||
Timeout string `yaml:"timeout" json:"timeout"`
|
||||
|
||||
@@ -50,6 +50,31 @@ func (cfg AuditaConfig) MarshalYAML() (any, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarshalYAML exposes only the public Notarius credential reference name.
|
||||
func (cfg NotariusConfig) MarshalYAML() (any, error) {
|
||||
type safeNotarius struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Binary string `yaml:"binary"`
|
||||
LLMAPIKey *string `yaml:"llm_api_key,omitempty"`
|
||||
ConfigProfile *string `yaml:"config_profile,omitempty"`
|
||||
Resume bool `yaml:"resume,omitempty"`
|
||||
ChunkCache string `yaml:"chunk_cache,omitempty"`
|
||||
ConfigPath string `yaml:"config_path"`
|
||||
PipelineID string `yaml:"pipeline_id"`
|
||||
Timeout string `yaml:"timeout"`
|
||||
WorkingDirectory string `yaml:"working_directory"`
|
||||
References map[string]string `yaml:"references"`
|
||||
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
|
||||
}
|
||||
return safeNotarius{
|
||||
Enabled: cfg.Enabled, Binary: cfg.Binary, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey),
|
||||
ConfigProfile: cfg.ConfigProfile,
|
||||
Resume: cfg.Resume, ChunkCache: cfg.ChunkCache,
|
||||
ConfigPath: cfg.ConfigPath, PipelineID: cfg.PipelineID, Timeout: cfg.Timeout,
|
||||
WorkingDirectory: cfg.WorkingDirectory, References: cfg.References, Outputs: cfg.Outputs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func secretReferenceName(secret *configkit.Secret) *string {
|
||||
if secret == nil {
|
||||
return nil
|
||||
|
||||
@@ -78,6 +78,7 @@ const (
|
||||
PathCurrentDirSegment = "current"
|
||||
PathRunsDirSegment = "runs"
|
||||
PathPreviousDirSegment = "previous"
|
||||
PathCacheDirSegment = "cache"
|
||||
PathManifestFile = "manifest.json"
|
||||
PathLockFile = ".lock"
|
||||
PathTranscriptBase = artifactmodel.TranscriptPathBase
|
||||
|
||||
@@ -92,6 +92,7 @@ func TestCredentialValuesNeverEnterEffectiveProjectionOrDigest(t *testing.T) {
|
||||
t.Setenv("NARRATIO_TEST_ACCESS_KEY", firstCanary)
|
||||
t.Setenv("NARRATIO_TEST_SECRET_KEY", firstCanary)
|
||||
t.Setenv("NARRATIO_TEST_AUDITA_KEY", firstCanary)
|
||||
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", firstCanary)
|
||||
pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n llm_api_key: NARRATIO_TEST_AUDITA_KEY\n", 1) + `
|
||||
storage:
|
||||
backend: s3
|
||||
@@ -99,6 +100,10 @@ storage:
|
||||
bucket: test-bucket
|
||||
access_key_id: NARRATIO_TEST_ACCESS_KEY
|
||||
secret_access_key: NARRATIO_TEST_SECRET_KEY
|
||||
notarius:
|
||||
enabled: false
|
||||
llm_api_key: NARRATIO_TEST_NOTARIUS_KEY
|
||||
config_profile: ""
|
||||
`
|
||||
pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
|
||||
|
||||
@@ -130,16 +135,35 @@ storage:
|
||||
if strings.Contains(output, firstCanary) {
|
||||
t.Fatalf("effective output exposed credential value: %q", output)
|
||||
}
|
||||
for _, reference := range []string{"NARRATIO_TEST_ACCESS_KEY", "NARRATIO_TEST_SECRET_KEY", "NARRATIO_TEST_AUDITA_KEY"} {
|
||||
for _, reference := range []string{"NARRATIO_TEST_ACCESS_KEY", "NARRATIO_TEST_SECRET_KEY", "NARRATIO_TEST_AUDITA_KEY", "NARRATIO_TEST_NOTARIUS_KEY"} {
|
||||
if !strings.Contains(output, reference) {
|
||||
t.Fatalf("effective output omitted safe reference %q: %q", reference, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(string(firstYAML), "config_profile: \"\"\n") {
|
||||
t.Fatalf("effective YAML omitted explicit empty Notarius config profile: %q", firstYAML)
|
||||
}
|
||||
var roundTrip struct {
|
||||
Notarius *struct {
|
||||
LLMAPIKey *string `yaml:"llm_api_key"`
|
||||
ConfigProfile *string `yaml:"config_profile"`
|
||||
} `yaml:"notarius"`
|
||||
}
|
||||
if err := yaml.Unmarshal(firstYAML, &roundTrip); err != nil {
|
||||
t.Fatalf("round-trip effective YAML: %v", err)
|
||||
}
|
||||
if roundTrip.Notarius == nil || roundTrip.Notarius.ConfigProfile == nil || *roundTrip.Notarius.ConfigProfile != "" {
|
||||
t.Fatalf("round-trip config profile = %#v, want explicit empty", roundTrip.Notarius)
|
||||
}
|
||||
if roundTrip.Notarius.LLMAPIKey == nil || *roundTrip.Notarius.LLMAPIKey != "NARRATIO_TEST_NOTARIUS_KEY" {
|
||||
t.Fatalf("round-trip credential reference = %#v, want safe public name", roundTrip.Notarius.LLMAPIKey)
|
||||
}
|
||||
|
||||
t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary)
|
||||
t.Setenv("NARRATIO_TEST_SECRET_KEY", secondCanary)
|
||||
t.Setenv("NARRATIO_TEST_AUDITA_KEY", secondCanary)
|
||||
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", secondCanary)
|
||||
second, err := LoadPipeline(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -156,6 +180,15 @@ storage:
|
||||
if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternate) {
|
||||
t.Fatal("credential reference-name change did not alter effective digest")
|
||||
}
|
||||
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY_ALTERNATE", secondCanary)
|
||||
alternateNotariusPath, _ := writeConfigFiles(t, strings.Replace(pipelineYAML, "NARRATIO_TEST_NOTARIUS_KEY", "NARRATIO_TEST_NOTARIUS_KEY_ALTERNATE", 1), testSessionBaseYAML)
|
||||
alternateNotarius, err := LoadPipeline(alternateNotariusPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternateNotarius) {
|
||||
t.Fatal("Notarius credential reference-name change did not alter effective digest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePipelineValuesPreserveNilEmptyAndExactIntegers(t *testing.T) {
|
||||
@@ -252,6 +285,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
|
||||
t.Setenv("OBJECT_STORAGE_KEY_ID", "synthetic-example-access-key")
|
||||
t.Setenv("OBJECT_STORAGE_KEY", "synthetic-example-secret-key")
|
||||
t.Setenv("AUDITA_LLM_API_KEY", "synthetic-example-audita-key")
|
||||
t.Setenv("NOTARIUS_LLM_API_KEY", "synthetic-example-notarius-key")
|
||||
examplesDir := filepath.Join("..", "..", "examples")
|
||||
if got, want := maintainedPipelineRoots(t, examplesDir), []string{
|
||||
"pipeline.extraction-subset.yml",
|
||||
@@ -439,6 +473,7 @@ func TestMaintainedExtractionExamplesPreservePublishedContracts(t *testing.T) {
|
||||
t.Setenv("OBJECT_STORAGE_KEY_ID", "synthetic-example-access-key")
|
||||
t.Setenv("OBJECT_STORAGE_KEY", "synthetic-example-secret-key")
|
||||
t.Setenv("AUDITA_LLM_API_KEY", "synthetic-example-audita-key")
|
||||
t.Setenv("NOTARIUS_LLM_API_KEY", "synthetic-example-notarius-key")
|
||||
examplesDir := filepath.Join("..", "..", "examples")
|
||||
full, err := LoadPipeline(filepath.Join(examplesDir, "pipeline.full.annotated.yml"))
|
||||
if err != nil {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestNotariusOmittedAndDisabledBehavior(t *testing.T) {
|
||||
@@ -45,11 +48,80 @@ notarius:
|
||||
if disabled.Notarius.Binary != DefaultNotariusBinary || disabled.Notarius.Timeout != DefaultNotariusTimeout {
|
||||
t.Fatalf("disabled defaults = %#v", disabled.Notarius)
|
||||
}
|
||||
if disabled.Notarius.Resume || disabled.Notarius.ChunkCache != "" {
|
||||
t.Fatalf("disabled cache-control defaults = %#v", disabled.Notarius)
|
||||
}
|
||||
if disabled.Notarius.ConfigPath != "relative/notarius.yml" || disabled.Notarius.WorkingDirectory != "" {
|
||||
t.Fatalf("disabled paths were resolved unexpectedly: %#v", disabled.Notarius)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialReferenceLoading(t *testing.T) {
|
||||
const envName = "NARRATIO_TEST_NOTARIUS_KEY"
|
||||
const canary = "notarius-credential-canary"
|
||||
t.Setenv(envName, canary)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
credential string
|
||||
wantNil bool
|
||||
}{
|
||||
{name: "omitted", wantNil: true},
|
||||
{name: "null", credential: " llm_api_key: null\n", wantNil: true},
|
||||
{name: "custom reference", credential: " llm_api_key: " + envName + "\n"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n" + test.credential
|
||||
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.wantNil {
|
||||
if cfg.Notarius.LLMAPIKey != nil {
|
||||
t.Fatal("credential reference should be nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != envName ||
|
||||
!cfg.Notarius.LLMAPIKey.IsResolved() || cfg.Notarius.LLMAPIKey.Value() != canary {
|
||||
t.Fatal("credential reference was not loaded and resolved")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialReferenceFailures(t *testing.T) {
|
||||
const missing = "NARRATIO_TEST_NOTARIUS_MISSING"
|
||||
if err := os.Unsetenv(missing); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name, reference, want string
|
||||
}{
|
||||
{name: "missing", reference: missing, want: "secret.unset"},
|
||||
{name: "empty", reference: `""`, want: "secret.invalid_name"},
|
||||
{name: "invalid", reference: "123-NOT-AN-ENV-NAME", want: "secret.invalid_name"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n llm_api_key: " + test.reference + "\n"
|
||||
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := LoadPipeline(path)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("LoadPipeline() error = %v, want classification %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml")
|
||||
@@ -121,6 +193,89 @@ notarius:
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCacheControlNormalizationAndValidation(t *testing.T) {
|
||||
for _, mode := range []string{"", "auto", "bypass", "refresh"} {
|
||||
t.Run("mode "+mode, func(t *testing.T) {
|
||||
cfg := validNotariusConfig()
|
||||
cfg.Resume = true
|
||||
if mode != "" {
|
||||
cfg.ChunkCache = " " + mode + " "
|
||||
}
|
||||
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: cfg}); err != nil {
|
||||
t.Fatalf("NormalizePipelineConfig() error = %v", err)
|
||||
}
|
||||
if cfg.ChunkCache != mode {
|
||||
t.Fatalf("ChunkCache = %q, want %q", cfg.ChunkCache, mode)
|
||||
}
|
||||
if !cfg.Resume {
|
||||
t.Fatal("Resume = false, want true")
|
||||
}
|
||||
if err := validateNotarius(cfg, nil); err != nil {
|
||||
t.Fatalf("validateNotarius() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, mode := range []string{"AUTO", "invalid"} {
|
||||
t.Run("invalid "+mode, func(t *testing.T) {
|
||||
cfg := validNotariusConfig()
|
||||
cfg.ChunkCache = mode
|
||||
err := validateNotarius(cfg, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "pipeline.notarius.chunk_cache") {
|
||||
t.Fatalf("validateNotarius() error = %v, want chunk_cache error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disabled := &NotariusConfig{Enabled: false, Resume: true, ChunkCache: " INVALID "}
|
||||
if err := NormalizePipelineConfig(&PipelineConfig{Notarius: disabled}); err != nil {
|
||||
t.Fatalf("NormalizePipelineConfig(disabled) error = %v", err)
|
||||
}
|
||||
if disabled.ChunkCache != " INVALID " {
|
||||
t.Fatalf("disabled ChunkCache = %q, want unchanged", disabled.ChunkCache)
|
||||
}
|
||||
if err := validateNotarius(disabled, nil); err != nil {
|
||||
t.Fatalf("validateNotarius(disabled) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCacheControlsSafeSerialization(t *testing.T) {
|
||||
cfg := validNotariusConfig()
|
||||
cfg.Resume = true
|
||||
cfg.ChunkCache = "refresh"
|
||||
|
||||
yamlData, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jsonData, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(yamlData), "resume: true") || !strings.Contains(string(yamlData), "chunk_cache: refresh") {
|
||||
t.Fatalf("YAML omitted cache controls: %s", yamlData)
|
||||
}
|
||||
if !strings.Contains(string(jsonData), `"resume":true`) || !strings.Contains(string(jsonData), `"chunk_cache":"refresh"`) {
|
||||
t.Fatalf("JSON omitted cache controls: %s", jsonData)
|
||||
}
|
||||
|
||||
cfg.Resume = false
|
||||
cfg.ChunkCache = ""
|
||||
yamlData, err = yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jsonData, err = json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, serialized := range []string{string(yamlData), string(jsonData)} {
|
||||
if strings.Contains(serialized, "resume") || strings.Contains(serialized, "chunk_cache") {
|
||||
t.Fatalf("zero-value cache controls were serialized: %s", serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusRelativePathsDoNotMakeEffectiveDigestLocationDependent(t *testing.T) {
|
||||
pipelineYAML := testPipelineBaseYAML + `
|
||||
notarius:
|
||||
|
||||
@@ -60,7 +60,8 @@ characters:
|
||||
want string
|
||||
}{
|
||||
{name: "unsupported version", yaml: strings.Replace(valid, PartySchemaVersion, "narratio.party.v2", 1), want: "unsupported"},
|
||||
{name: "non-string version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "version.type"},
|
||||
{name: "unsupported integer version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "unsupported"},
|
||||
{name: "boolean version", yaml: strings.Replace(valid, PartySchemaVersion, "true", 1), want: "version.type"},
|
||||
{name: "unknown top field", yaml: valid + "unknown: true\n", want: "decode.unknown_field"},
|
||||
{name: "unknown nested field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n role: GM\n", 1), want: "decode.unknown_field"},
|
||||
{name: "trailing document", yaml: valid + "---\ncharacters: {}\n", want: "yaml.multiple_documents"},
|
||||
|
||||
@@ -209,11 +209,17 @@ func resolvePipelineConfig(cfg *PipelineConfig, resolution *configkit.Resolution
|
||||
resolution.Record(configkit.Keys("notarius"), configkit.ChangeNormalization, "canonical Notarius configuration")
|
||||
}
|
||||
if cfg.Publish != nil {
|
||||
for _, derived := range deriveMissingPublishDestinations(cfg.Publish, cfg.Scriptorium, cfg.Notarius) {
|
||||
destinations := deriveMissingPublishDestinations(cfg.Publish, cfg.Scriptorium, cfg.Notarius)
|
||||
for _, derived := range destinations {
|
||||
output := cfg.Publish.Outputs[derived.index]
|
||||
configkit.Normalize(resolution, configkit.Keys("publish", "outputs").Index(derived.index).Key("dest"), &output.Dest, derived.dest, "derived from publish source")
|
||||
cfg.Publish.Outputs[derived.index] = output
|
||||
}
|
||||
if len(destinations) > 0 {
|
||||
// ConfigKit compares sequences atomically; child records alone do not
|
||||
// cover the list mutation when requiredness was already explicit.
|
||||
resolution.Record(configkit.Keys("publish", "outputs"), configkit.ChangeNormalization, "derived publish destinations")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -117,6 +118,161 @@ func TestPipelineProfileDoesNotRequireUnusedBaseCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) {
|
||||
const baseKey = "NARRATIO_NOTARIUS_UNUSED_BASE_KEY"
|
||||
const profileKey = "NARRATIO_NOTARIUS_PROFILE_KEY"
|
||||
if err := os.Unsetenv(baseKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(profileKey, "synthetic-notarius-profile-key")
|
||||
|
||||
for _, credential := range []string{profileKey, "null"} {
|
||||
t.Run(credential, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+baseKey+"\n")
|
||||
overlay := writePipelineFixture(t, dir, "profiles/testing.yml", "notarius:\n llm_api_key: "+credential+"\n")
|
||||
profile := "testing"
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if credential == "null" {
|
||||
if cfg.Notarius.LLMAPIKey != nil {
|
||||
t.Fatal("profile did not remove the base credential")
|
||||
}
|
||||
return
|
||||
}
|
||||
if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != profileKey || !cfg.Notarius.LLMAPIKey.IsResolved() {
|
||||
t.Fatal("profile credential was not resolved")
|
||||
}
|
||||
sources, err := EffectivePipelineSources(cfg, ResolvedParty{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, source := range sources {
|
||||
if source.Path == "notarius.llm_api_key" {
|
||||
found = source.Source == overlay
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("Notarius credential provenance did not identify the selected profile")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusConfigProfileComposition(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseValue string
|
||||
overlayName string
|
||||
overlay string
|
||||
want *string
|
||||
}{
|
||||
{name: "omitted"},
|
||||
{name: "explicit empty", baseValue: `""`, want: stringPointer("")},
|
||||
{name: "explicit default", baseValue: "default", want: stringPointer("default")},
|
||||
{name: "base named selection", baseValue: "production", want: stringPointer("production")},
|
||||
{name: "profile replaces named selection", baseValue: "production", overlayName: "testing", overlay: "notarius:\n config_profile: inexpensive\n", want: stringPointer("inexpensive")},
|
||||
{name: "profile clears named selection", baseValue: "production", overlayName: "testing", overlay: "notarius:\n config_profile: null\n"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootYAML := "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n"
|
||||
if test.baseValue != "" {
|
||||
rootYAML += " config_profile: " + test.baseValue + "\n"
|
||||
}
|
||||
root := writePipelineFixture(t, dir, "pipeline.yml", rootYAML)
|
||||
options := PipelineLoadOptions{}
|
||||
if test.overlayName != "" {
|
||||
writePipelineFixture(t, dir, "profiles/"+test.overlayName+".yml", test.overlay)
|
||||
options.Profile = &test.overlayName
|
||||
}
|
||||
cfg, err := LoadPipelineWithOptions(root, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Notarius == nil || !reflect.DeepEqual(cfg.Notarius.ConfigProfile, test.want) {
|
||||
t.Fatalf("config profile = %#v, want %#v", cfg.Notarius.ConfigProfile, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusCredentialRootProvenance(t *testing.T) {
|
||||
const envName = "NARRATIO_NOTARIUS_ROOT_KEY"
|
||||
t.Setenv(envName, "synthetic-notarius-root-key")
|
||||
dir := t.TempDir()
|
||||
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n")
|
||||
cfg, err := LoadPipeline(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources, err := EffectivePipelineSources(cfg, ResolvedParty{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, source := range sources {
|
||||
if source.Path == "notarius.llm_api_key" && source.Source == root {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("Notarius credential provenance did not identify the root pipeline")
|
||||
}
|
||||
|
||||
func TestNotariusCacheControlProfileReplacementAndNullReset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
overlay string
|
||||
wantResume bool
|
||||
wantMode string
|
||||
wantSource bool
|
||||
wantNil bool
|
||||
}{
|
||||
{name: "replacement", overlay: "notarius:\n resume: true\n chunk_cache: refresh\n", wantResume: true, wantMode: "refresh", wantSource: true},
|
||||
{name: "null reset", overlay: "notarius: null\n", wantNil: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n resume: false\n chunk_cache: bypass\n")
|
||||
overlay := writePipelineFixture(t, dir, "profiles/testing.yml", test.overlay)
|
||||
profile := "testing"
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.wantNil {
|
||||
if cfg.Notarius != nil {
|
||||
t.Fatalf("Notarius = %#v, want null reset", cfg.Notarius)
|
||||
}
|
||||
return
|
||||
}
|
||||
if cfg.Notarius == nil || cfg.Notarius.Resume != test.wantResume || cfg.Notarius.ChunkCache != test.wantMode {
|
||||
t.Fatalf("cache controls = resume %v, mode %q; want resume %v, mode %q", cfg.Notarius.Resume, cfg.Notarius.ChunkCache, test.wantResume, test.wantMode)
|
||||
}
|
||||
if !test.wantSource {
|
||||
return
|
||||
}
|
||||
sources, err := EffectivePipelineSources(cfg, ResolvedParty{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, source := range sources {
|
||||
if source.Path == "notarius.resume" || source.Path == "notarius.chunk_cache" {
|
||||
found[source.Path] = source.Source == overlay
|
||||
}
|
||||
}
|
||||
if !found["notarius.resume"] || !found["notarius.chunk_cache"] {
|
||||
t.Fatalf("cache-control provenance did not identify profile: %#v", found)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineConfigKitAllowsOneBaseDeclarationWithProfileOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePipelineFixture(t, dir, "pipeline.yml", `configkit:
|
||||
|
||||
@@ -14,6 +14,7 @@ func NormalizePipelineConfig(cfg *PipelineConfig) error {
|
||||
}
|
||||
notarius := *cfg.Notarius
|
||||
notarius.Binary = strings.TrimSpace(notarius.Binary)
|
||||
notarius.ChunkCache = strings.TrimSpace(notarius.ChunkCache)
|
||||
notarius.ConfigPath = cleanNotariusPath(notarius.ConfigPath)
|
||||
notarius.PipelineID = strings.TrimSpace(notarius.PipelineID)
|
||||
notarius.Timeout = strings.TrimSpace(notarius.Timeout)
|
||||
|
||||
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestStorageS3DefaultsAndValidation(t *testing.T) {
|
||||
@@ -549,6 +551,52 @@ publish:
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishDerivedDestinationsPreserveExplicitRequiredness(t *testing.T) {
|
||||
pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML+`
|
||||
scriptorium:
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
publish:
|
||||
outputs:
|
||||
- {source: narratio.transcript.final, required: true}
|
||||
- {source: narratio.transcript.final_markdown, required: false}
|
||||
- {source: narratio.artifact.session_recap, required: false}
|
||||
- {source: narratio.transcript.final_trimmed, dest: custom/trimmed.json, required: true}
|
||||
`, testSessionBaseYAML)
|
||||
cfg, err := Load(pipelinePath, sessionPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
effective, err := MarshalEffectivePipeline(cfg.Pipeline)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var projected struct {
|
||||
Publish PublishConfig `yaml:"publish"`
|
||||
}
|
||||
if err := yaml.Unmarshal(effective, &projected); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDest := []string{"transcripts/final.json", "transcripts/final.md", "artifacts/session_recap.md", "custom/trimmed.json"}
|
||||
wantRequired := []bool{true, false, false, true}
|
||||
for label, publish := range map[string]*PublishConfig{"loaded": cfg.Pipeline.Publish, "effective": &projected.Publish} {
|
||||
if len(publish.Outputs) != len(wantDest) {
|
||||
t.Fatalf("%s output count = %d, want %d", label, len(publish.Outputs), len(wantDest))
|
||||
}
|
||||
for i, output := range publish.Outputs {
|
||||
if output.Dest != wantDest[i] || output.Required == nil || *output.Required != wantRequired[i] {
|
||||
t.Errorf("%s output %d = %#v, want destination %q and required %t", label, i, output, wantDest[i], wantRequired[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptoriumOutputPathsIgnoreInvalidKeys(t *testing.T) {
|
||||
scriptorium := &ScriptoriumConfig{Artifacts: map[string]ScriptoriumArtifactConfig{
|
||||
" artifact": {OutputPath: "artifacts/invalid.md"},
|
||||
|
||||
@@ -39,10 +39,12 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
|
||||
if len(cfg.References) > MaxNotariusReferenceBindings {
|
||||
return fmt.Errorf("pipeline.notarius.references must contain at most %d bindings", MaxNotariusReferenceBindings)
|
||||
}
|
||||
|
||||
if err := validateNotariusCanonicalScalars(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validNotariusChunkCacheMode(cfg.ChunkCache) {
|
||||
return fmt.Errorf("pipeline.notarius.chunk_cache must be empty, auto, bypass, or refresh")
|
||||
}
|
||||
if err := validateNotariusReferences(cfg.References); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,6 +58,7 @@ func validateNotariusCanonicalScalars(cfg *NotariusConfig) error {
|
||||
want string
|
||||
}{
|
||||
{name: "binary", got: cfg.Binary, want: strings.TrimSpace(cfg.Binary)},
|
||||
{name: "chunk_cache", got: cfg.ChunkCache, want: strings.TrimSpace(cfg.ChunkCache)},
|
||||
{name: "config_path", got: cfg.ConfigPath, want: cleanNotariusPath(cfg.ConfigPath)},
|
||||
{name: "pipeline_id", got: cfg.PipelineID, want: strings.TrimSpace(cfg.PipelineID)},
|
||||
{name: "timeout", got: cfg.Timeout, want: strings.TrimSpace(cfg.Timeout)},
|
||||
@@ -69,6 +72,15 @@ func validateNotariusCanonicalScalars(cfg *NotariusConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validNotariusChunkCacheMode(mode string) bool {
|
||||
switch mode {
|
||||
case "", "auto", "bypass", "refresh":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeNotariusReferences(references map[string]string) (map[string]string, error) {
|
||||
selectors := make([]string, 0, len(references))
|
||||
for selector := range references {
|
||||
|
||||
@@ -50,20 +50,22 @@ type ArtifactRecord struct {
|
||||
|
||||
// StageRecord tracks lifecycle and provenance for one pipeline stage.
|
||||
type StageRecord struct {
|
||||
Name string `json:"name"`
|
||||
Status StageStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Outputs []ArtifactRecord `json:"outputs,omitempty"`
|
||||
Logs []string `json:"logs,omitempty"`
|
||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||
Error *ErrorRecord `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SemanticConfig *SemanticConfigFingerprint `json:"semantic_config,omitempty"`
|
||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status StageStatus `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Outputs []ArtifactRecord `json:"outputs,omitempty"`
|
||||
Logs []string `json:"logs,omitempty"`
|
||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||
Error *ErrorRecord `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SemanticConfig *SemanticConfigFingerprint `json:"semantic_config,omitempty"`
|
||||
TranscribeStateVersion int `json:"transcribe_state_version,omitempty"`
|
||||
TranscribeSpeakers map[string]TranscribeSpeakerRecord `json:"transcribe_speakers,omitempty"`
|
||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveConfigProvenance records bounded non-secret configuration identity
|
||||
|
||||
@@ -119,6 +119,9 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
|
||||
}
|
||||
for name, stage := range m.Stages {
|
||||
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||
if err := validateTranscribeStageState(name, stage.TranscribeStateVersion, stage.TranscribeSpeakers); err != nil {
|
||||
return fmt.Errorf("save manifest: stages.%s: %w", name, err)
|
||||
}
|
||||
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||
return fmt.Errorf("save manifest: stages.%s: %w", name, err)
|
||||
}
|
||||
@@ -160,6 +163,9 @@ func validateLoadedManifest(m *Manifest) error {
|
||||
return err
|
||||
}
|
||||
for name, stage := range m.Stages {
|
||||
if err := validateTranscribeStageState(name, stage.TranscribeStateVersion, stage.TranscribeSpeakers); err != nil {
|
||||
return fmt.Errorf("stages.%s: %w", name, err)
|
||||
}
|
||||
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||
return fmt.Errorf("stages.%s: %w", name, err)
|
||||
}
|
||||
|
||||
128
internal/manifest/transcribe_state.go
Normal file
128
internal/manifest/transcribe_state.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// TranscribeStateContractVersion identifies the supported per-speaker
|
||||
// completion representation owned by the transcribe stage.
|
||||
TranscribeStateContractVersion = 1
|
||||
// TranscribeFingerprintContractVersion identifies the fingerprint
|
||||
// representation stored by the supported transcribe state contract.
|
||||
TranscribeFingerprintContractVersion = 1
|
||||
)
|
||||
|
||||
// TranscribeSpeakerRecord binds one completed raw transcript to the audio and
|
||||
// observable recognition settings that produced it.
|
||||
type TranscribeSpeakerRecord struct {
|
||||
FingerprintVersion int `json:"fingerprint_version"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
AudioPath string `json:"audio_path"`
|
||||
AudioSHA256 string `json:"audio_sha256"`
|
||||
AudioSize int64 `json:"audio_size"`
|
||||
Output ArtifactRecord `json:"output"`
|
||||
OutputSize int64 `json:"output_size"`
|
||||
ProducerRunID string `json:"producer_run_id"`
|
||||
}
|
||||
|
||||
// HasVersionedTranscribeState reports whether a transcribe stage record carries
|
||||
// the supported per-speaker authority. An aggregate-only record returns false.
|
||||
func (s *StageRecord) HasVersionedTranscribeState() bool {
|
||||
return s != nil && s.Name == "transcribe" && s.TranscribeStateVersion == TranscribeStateContractVersion
|
||||
}
|
||||
|
||||
// ValidateTranscribeSpeakerCollection validates one complete session
|
||||
// collection independently of its containing manifest.
|
||||
func ValidateTranscribeSpeakerCollection(version int, records map[string]TranscribeSpeakerRecord) error {
|
||||
if version != TranscribeStateContractVersion {
|
||||
return fmt.Errorf("unsupported transcribe state version %d", version)
|
||||
}
|
||||
for speakerID, record := range records {
|
||||
if err := validateTranscribeSpeakerRecord(speakerID, record); err != nil {
|
||||
return fmt.Errorf("transcribe speaker %q: %w", speakerID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloneTranscribeSpeakerCollection returns a deep copy so checkpoint candidates
|
||||
// and installed authority cannot share mutable artifact metadata.
|
||||
func CloneTranscribeSpeakerCollection(records map[string]TranscribeSpeakerRecord) map[string]TranscribeSpeakerRecord {
|
||||
if records == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make(map[string]TranscribeSpeakerRecord, len(records))
|
||||
for speakerID, record := range records {
|
||||
output := cloneArtifactRecord(&record.Output)
|
||||
record.Output = *output
|
||||
cloned[speakerID] = record
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func validateTranscribeStageState(stageName string, version int, records map[string]TranscribeSpeakerRecord) error {
|
||||
if stageName != "transcribe" {
|
||||
if version != 0 || records != nil {
|
||||
return fmt.Errorf("stage %q cannot contain transcribe-owned state", stageName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if version == 0 && records == nil {
|
||||
return nil
|
||||
}
|
||||
return ValidateTranscribeSpeakerCollection(version, records)
|
||||
}
|
||||
|
||||
func validateTranscribeSpeakerRecord(speakerID string, record TranscribeSpeakerRecord) error {
|
||||
if speakerID == "" {
|
||||
return fmt.Errorf("speaker ID is required")
|
||||
}
|
||||
if record.FingerprintVersion != TranscribeFingerprintContractVersion {
|
||||
return fmt.Errorf("unsupported fingerprint version %d", record.FingerprintVersion)
|
||||
}
|
||||
if err := validateSHA256("fingerprint", record.Fingerprint); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCanonicalRelativePath("audio_path", record.AudioPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSHA256("audio_sha256", record.AudioSHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.AudioSize < 0 {
|
||||
return fmt.Errorf("audio_size must not be negative")
|
||||
}
|
||||
if record.Output.Kind != "transcript_raw" {
|
||||
return fmt.Errorf("output kind %q must be transcript_raw", record.Output.Kind)
|
||||
}
|
||||
if err := validateCanonicalRelativePath("output local_path", record.Output.LocalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSHA256("output checksum", record.Output.Checksum); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.OutputSize <= 0 {
|
||||
return fmt.Errorf("output_size must be positive")
|
||||
}
|
||||
if err := pathsafe.ValidateOpaqueSegment(record.ProducerRunID); err != nil {
|
||||
return fmt.Errorf("producer_run_id is invalid: %w", err)
|
||||
}
|
||||
if record.Output.ProducerRunID != record.ProducerRunID {
|
||||
return fmt.Errorf("output producer_run_id %q does not match record", record.Output.ProducerRunID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCanonicalRelativePath(field, value string) error {
|
||||
normalized, err := pathsafe.NormalizeRelativeDestination(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s is unsafe: %w", field, err)
|
||||
}
|
||||
if normalized != value {
|
||||
return fmt.Errorf("%s %q is not canonical %q", field, value, normalized)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
254
internal/manifest/transcribe_state_test.go
Normal file
254
internal/manifest/transcribe_state_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTranscribeSpeakerStateRoundTripsPopulatedAndVersionedEmptyCollections(t *testing.T) {
|
||||
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
||||
store := &LocalStore{}
|
||||
zeroAudio := validTranscribeSpeakerRecord("Alice Table 1")
|
||||
zeroAudio.AudioSize = 0
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
records map[string]TranscribeSpeakerRecord
|
||||
}{
|
||||
{
|
||||
name: "populated",
|
||||
records: map[string]TranscribeSpeakerRecord{
|
||||
"Alice Table 1": validTranscribeSpeakerRecord("Alice Table 1"),
|
||||
},
|
||||
},
|
||||
{name: "versioned empty", records: map[string]TranscribeSpeakerRecord{}},
|
||||
{name: "zero size audio", records: map[string]TranscribeSpeakerRecord{"Alice Table 1": zeroAudio}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := New("session", now)
|
||||
m.Stages["transcribe"] = &StageRecord{
|
||||
Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||
TranscribeStateVersion: TranscribeStateContractVersion,
|
||||
TranscribeSpeakers: test.records,
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||
if err := store.Save(context.Background(), path, m); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
loaded, err := store.Load(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
stage := loaded.Stages["transcribe"]
|
||||
if !stage.HasVersionedTranscribeState() {
|
||||
t.Fatalf("transcribe state = %#v, want supported version", stage)
|
||||
}
|
||||
if len(stage.TranscribeSpeakers) != len(test.records) {
|
||||
t.Fatalf("speaker count = %d, want %d", len(stage.TranscribeSpeakers), len(test.records))
|
||||
}
|
||||
if len(test.records) != 0 {
|
||||
got := stage.TranscribeSpeakers["Alice Table 1"]
|
||||
if got.AudioSize != test.records["Alice Table 1"].AudioSize {
|
||||
t.Fatalf("audio size = %d, want %d", got.AudioSize, test.records["Alice Table 1"].AudioSize)
|
||||
}
|
||||
if got.AudioPath != "inputs/audio/Alice Table 1.flac" || got.Output.LocalPath != "transcripts/raw/Alice Table 1.json" {
|
||||
t.Fatalf("round-tripped record = %#v", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeSpeakerStateAllowsLegacyAggregateOnlyManifest(t *testing.T) {
|
||||
legacy := `{
|
||||
"schema_version": "narratio.session-manifest.v1",
|
||||
"session_id": "session",
|
||||
"created_at": "2026-09-21T12:00:00Z",
|
||||
"updated_at": "2026-09-21T12:01:00Z",
|
||||
"stages": {
|
||||
"transcribe": {
|
||||
"name": "transcribe",
|
||||
"status": "succeeded",
|
||||
"created_at": "2026-09-21T12:00:00Z",
|
||||
"updated_at": "2026-09-21T12:01:00Z",
|
||||
"outputs": [{"kind":"transcript_raw","local_path":"transcripts/raw/alice.json"}]
|
||||
}
|
||||
}
|
||||
}`
|
||||
loaded, err := (&LocalStore{}).LoadReader(context.Background(), strings.NewReader(legacy))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadReader() error = %v", err)
|
||||
}
|
||||
if loaded.Stages["transcribe"].HasVersionedTranscribeState() {
|
||||
t.Fatal("legacy aggregate-only manifest unexpectedly gained per-speaker authority")
|
||||
}
|
||||
if err := (&LocalStore{}).Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), loaded); err != nil {
|
||||
t.Fatalf("Save(legacy aggregate-only manifest) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTranscribeSpeakerCollectionRejectsMalformedEvidence(t *testing.T) {
|
||||
valid := validTranscribeSpeakerRecord("alice")
|
||||
tests := []struct {
|
||||
name string
|
||||
speakerID string
|
||||
mutate func(*TranscribeSpeakerRecord)
|
||||
want string
|
||||
}{
|
||||
{name: "empty speaker ID", speakerID: "", want: "speaker ID is required"},
|
||||
{name: "unsupported fingerprint version", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.FingerprintVersion = 2 }, want: "unsupported fingerprint version"},
|
||||
{name: "bad fingerprint", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Fingerprint = "bad" }, want: "fingerprint must be"},
|
||||
{name: "unsafe audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "../alice.flac" }, want: "audio_path is unsafe"},
|
||||
{name: "noncanonical audio path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioPath = "inputs/audio/../alice.flac" }, want: "not canonical"},
|
||||
{name: "bad audio checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSHA256 = strings.Repeat("G", 64) }, want: "audio_sha256 must be"},
|
||||
{name: "negative audio size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.AudioSize = -1 }, want: "audio_size"},
|
||||
{name: "bad output kind", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Kind = "transcript" }, want: "must be transcript_raw"},
|
||||
{name: "unsafe output path", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.LocalPath = "/tmp/alice.json" }, want: "output local_path is unsafe"},
|
||||
{name: "bad output checksum", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.Checksum = "bad" }, want: "output checksum must be"},
|
||||
{name: "bad output size", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.OutputSize = 0 }, want: "output_size must be positive"},
|
||||
{name: "bad producer run", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.ProducerRunID = "bad/run" }, want: "producer_run_id is invalid"},
|
||||
{name: "producer mismatch", speakerID: "alice", mutate: func(r *TranscribeSpeakerRecord) { r.Output.ProducerRunID = "other-run" }, want: "does not match record"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
record := valid
|
||||
if test.mutate != nil {
|
||||
test.mutate(&record)
|
||||
}
|
||||
err := ValidateTranscribeSpeakerCollection(TranscribeStateContractVersion, map[string]TranscribeSpeakerRecord{test.speakerID: record})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := ValidateTranscribeSpeakerCollection(99, nil); err == nil || !strings.Contains(err.Error(), "unsupported transcribe state version") {
|
||||
t.Fatalf("unsupported version error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeSpeakerStateValidationAppliesOnSaveAndLoad(t *testing.T) {
|
||||
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
||||
invalid := validTranscribeSpeakerRecord("alice")
|
||||
invalid.AudioSHA256 = "bad"
|
||||
m := New("session", now)
|
||||
m.Stages["transcribe"] = &StageRecord{
|
||||
Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||
TranscribeStateVersion: TranscribeStateContractVersion,
|
||||
TranscribeSpeakers: map[string]TranscribeSpeakerRecord{"alice": invalid},
|
||||
}
|
||||
store := &LocalStore{}
|
||||
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), m); err == nil || !strings.Contains(err.Error(), "audio_sha256") {
|
||||
t.Fatalf("Save() error = %v, want audio evidence validation", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if _, err := store.LoadReader(context.Background(), strings.NewReader(string(data))); err == nil || !strings.Contains(err.Error(), "audio_sha256") {
|
||||
t.Fatalf("LoadReader() error = %v, want audio evidence validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeSpeakerStateOwnershipAndVersionAreValidated(t *testing.T) {
|
||||
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
||||
store := &LocalStore{}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
stage *StageRecord
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "wrong owner",
|
||||
stage: &StageRecord{Name: "merge", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||
TranscribeStateVersion: TranscribeStateContractVersion,
|
||||
TranscribeSpeakers: map[string]TranscribeSpeakerRecord{}},
|
||||
want: "cannot contain transcribe-owned state",
|
||||
},
|
||||
{
|
||||
name: "missing version",
|
||||
stage: &StageRecord{Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||
TranscribeSpeakers: map[string]TranscribeSpeakerRecord{"alice": validTranscribeSpeakerRecord("alice")}},
|
||||
want: "unsupported transcribe state version 0",
|
||||
},
|
||||
{
|
||||
name: "unsupported version",
|
||||
stage: &StageRecord{Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||
TranscribeStateVersion: 2},
|
||||
want: "unsupported transcribe state version 2",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
m := New("session", now)
|
||||
m.Stages[test.stage.Name] = test.stage
|
||||
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "manifest.json"), m); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Save() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneTranscribeSpeakerCollectionIsIsolated(t *testing.T) {
|
||||
original := map[string]TranscribeSpeakerRecord{"alice": validTranscribeSpeakerRecord("alice")}
|
||||
cloned := CloneTranscribeSpeakerCollection(original)
|
||||
record := cloned["alice"]
|
||||
record.AudioPath = "inputs/audio/replacement.flac"
|
||||
record.Output.Checksum = strings.Repeat("c", 64)
|
||||
cloned["alice"] = record
|
||||
|
||||
if got := original["alice"]; got.AudioPath != "inputs/audio/alice.flac" || got.Output.Checksum != strings.Repeat("b", 64) {
|
||||
t.Fatalf("original changed through clone: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeSpeakerStateSurvivesAggregateLifecycleTransitions(t *testing.T) {
|
||||
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
||||
for _, transition := range []struct {
|
||||
name string
|
||||
apply func(*Manifest)
|
||||
}{
|
||||
{name: "running", apply: func(m *Manifest) { m.MarkStageRunning("transcribe", now.Add(time.Minute)) }},
|
||||
{name: "failed", apply: func(m *Manifest) { m.MarkStageFailed("transcribe", now.Add(time.Minute), "request failed") }},
|
||||
{name: "stale", apply: func(m *Manifest) { m.MarkStageStale("transcribe", now.Add(time.Minute), "audio changed") }},
|
||||
} {
|
||||
t.Run(transition.name, func(t *testing.T) {
|
||||
m := New("session", now)
|
||||
m.Stages["transcribe"] = &StageRecord{
|
||||
Name: "transcribe", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||
Outputs: []ArtifactRecord{{Kind: "transcript_raw", LocalPath: "transcripts/raw/old.json"}},
|
||||
Logs: []string{"runs/old/transcribe.log"}, Metadata: map[string]any{"attempts": 1},
|
||||
TranscribeStateVersion: TranscribeStateContractVersion,
|
||||
TranscribeSpeakers: map[string]TranscribeSpeakerRecord{"alice": validTranscribeSpeakerRecord("alice")},
|
||||
}
|
||||
transition.apply(m)
|
||||
stage := m.Stages["transcribe"]
|
||||
if !stage.HasVersionedTranscribeState() || stage.TranscribeSpeakers["alice"].AudioPath != "inputs/audio/alice.flac" {
|
||||
t.Fatalf("speaker completion was lost during %s: %#v", transition.name, stage)
|
||||
}
|
||||
if transition.name != "stale" && (len(stage.Outputs) != 0 || len(stage.Logs) != 0 || len(stage.Metadata) != 0) {
|
||||
t.Fatalf("aggregate details survived %s: %#v", transition.name, stage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validTranscribeSpeakerRecord(speakerID string) TranscribeSpeakerRecord {
|
||||
runID := "run-1"
|
||||
return TranscribeSpeakerRecord{
|
||||
FingerprintVersion: TranscribeFingerprintContractVersion,
|
||||
Fingerprint: strings.Repeat("a", 64),
|
||||
AudioPath: "inputs/audio/" + speakerID + ".flac",
|
||||
AudioSHA256: strings.Repeat("1", 64),
|
||||
AudioSize: 1024,
|
||||
Output: ArtifactRecord{
|
||||
Kind: "transcript_raw", LocalPath: "transcripts/raw/" + speakerID + ".json",
|
||||
Checksum: strings.Repeat("b", 64), ProducerRunID: runID,
|
||||
},
|
||||
OutputSize: 256, ProducerRunID: runID,
|
||||
}
|
||||
}
|
||||
@@ -58,9 +58,12 @@ func TestCleanupStageRemovesOnlySelectedPublishedRunTargets(t *testing.T) {
|
||||
otherSpool := filepath.Join(env.Config.Pipeline.Spool.Root, m.Campaign, m.SessionID, "other-run", "audio")
|
||||
otherWorkspace := filepath.Join(env.Config.Pipeline.Workspace.Root, "work", m.Campaign, m.SessionID, "runs", "other-run")
|
||||
cacheFile := filepath.Join(env.Config.Pipeline.Cache.Root, "s3", "audio.cache")
|
||||
sessionPaths := artifacts.NewLocalStore(env.Config.Pipeline.Workspace.Root).SessionPathsFor(m.Campaign, m.SessionID)
|
||||
referenceCacheFile := filepath.Join(artifacts.SessionNotariusReferenceCacheDir(sessionPaths), strings.Repeat("a", 64), "party.yml")
|
||||
writeStageTestFile(t, filepath.Join(otherSpool, "audio.wav"), "other spool")
|
||||
writeStageTestFile(t, filepath.Join(otherWorkspace, "result.txt"), "other workspace")
|
||||
writeStageTestFile(t, cacheFile, "cache")
|
||||
writeStageTestFile(t, referenceCacheFile, "reference cache")
|
||||
|
||||
result, err := (cleanupStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
@@ -81,7 +84,7 @@ func TestCleanupStageRemovesOnlySelectedPublishedRunTargets(t *testing.T) {
|
||||
t.Fatalf("unselected workspace target changed: %v", err)
|
||||
}
|
||||
}
|
||||
for _, preserved := range []string{otherSpool, otherWorkspace, cacheFile} {
|
||||
for _, preserved := range []string{otherSpool, otherWorkspace, cacheFile, referenceCacheFile} {
|
||||
if _, err := os.Stat(preserved); err != nil {
|
||||
t.Fatalf("preserved path %q: %v", preserved, err)
|
||||
}
|
||||
|
||||
@@ -124,20 +124,13 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (re
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract: resolve durable bundle path: %w", err)
|
||||
}
|
||||
referenceSnapshotRoot, err := absolutePath(artifacts.SessionRunNotariusReferencesDirForCampaign(workspaceRoot, campaign, sessionID, runID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract: resolve reference snapshot path: %w", err)
|
||||
}
|
||||
directories := []string{filepath.Dir(receiptPath), outputRoot, filepath.Dir(durableBundle)}
|
||||
if len(references.Bindings) > 0 {
|
||||
directories = append(directories, referenceSnapshotRoot)
|
||||
}
|
||||
for _, directory := range directories {
|
||||
if err := fileops.EnsureWorkspaceDirectory(directory); err != nil {
|
||||
return nil, fmt.Errorf("extract: create directory %q: %w", directory, err)
|
||||
}
|
||||
}
|
||||
referenceBindings, err := materializeExtractReferenceSnapshots(paths, references, referenceSnapshotRoot)
|
||||
referenceBindings, err := materializeExtractReferenceSnapshots(paths, references)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract: materialize Notarius reference snapshots: %w", err)
|
||||
}
|
||||
@@ -146,10 +139,17 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (re
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err)
|
||||
}
|
||||
resume := notariusConfig.Resume
|
||||
chunkCache := notariusConfig.ChunkCache
|
||||
if env.Force {
|
||||
resume = false
|
||||
chunkCache = "refresh"
|
||||
}
|
||||
request := notarius.RunRequest{
|
||||
Binary: resolvedBinary, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
||||
Binary: resolvedBinary, LLMAPIKey: notariusConfig.LLMAPIKey, ConfigProfile: notariusConfig.ConfigProfile,
|
||||
ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
||||
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
|
||||
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, References: referenceBindings,
|
||||
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, Resume: resume, ChunkCache: chunkCache, References: referenceBindings,
|
||||
}
|
||||
stageSucceeded := false
|
||||
bundleMoved := false
|
||||
@@ -176,7 +176,7 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (re
|
||||
if adapterResult.Receipt.RunID == "" || adapterResult.Receipt.PipelineID != notariusConfig.PipelineID {
|
||||
return nil, fmt.Errorf("extract: notarius receipt identity is missing or incompatible")
|
||||
}
|
||||
if err := verifyExtractReferenceSnapshots(referenceSnapshotRoot, references); err != nil {
|
||||
if err := verifyExtractReferenceSnapshots(paths, references); err != nil {
|
||||
return nil, fmt.Errorf("extract: verify Notarius reference snapshots: %w", err)
|
||||
}
|
||||
|
||||
@@ -450,10 +450,11 @@ type fingerprintOutput struct {
|
||||
}
|
||||
|
||||
type fingerprintDocument struct {
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
Input artifacts.ExtractionInputIdentity `json:"input"`
|
||||
References []extractReferenceIdentity `json:"references"`
|
||||
Outputs []fingerprintOutput `json:"outputs"`
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
ConfigProfile string `json:"config_profile,omitempty"`
|
||||
Input artifacts.ExtractionInputIdentity `json:"input"`
|
||||
References []extractReferenceIdentity `json:"references"`
|
||||
Outputs []fingerprintOutput `json:"outputs"`
|
||||
}
|
||||
|
||||
func extractionFingerprint(
|
||||
@@ -485,7 +486,8 @@ func extractionFingerprint(
|
||||
return sortedReferences[i].Path < sortedReferences[j].Path
|
||||
})
|
||||
payload, err := json.Marshal(fingerprintDocument{
|
||||
PipelineID: cfg.PipelineID, Input: input, References: sortedReferences, Outputs: outputs,
|
||||
PipelineID: cfg.PipelineID, ConfigProfile: notariusConfigProfileReuseIdentity(cfg.ConfigProfile),
|
||||
Input: input, References: sortedReferences, Outputs: outputs,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -3,6 +3,7 @@ package stage
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
type extractReferenceIdentity struct {
|
||||
@@ -29,83 +31,204 @@ type extractReferenceIdentity struct {
|
||||
func materializeExtractReferenceSnapshots(
|
||||
paths artifacts.SessionPaths,
|
||||
references resolvedExtractReferences,
|
||||
snapshotRoot string,
|
||||
) ([]notarius.ReferenceBinding, error) {
|
||||
var err error
|
||||
paths, err = absoluteExtractSnapshotPaths(paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(references.Bindings) != len(references.Identities) {
|
||||
return nil, fmt.Errorf("reference bindings and identities have different lengths")
|
||||
}
|
||||
bindings := make([]notarius.ReferenceBinding, len(references.Bindings))
|
||||
copied := make(map[string]string, len(references.Bindings))
|
||||
destinationOwners := make(map[string]string, len(references.Bindings))
|
||||
materialized := make(map[string]extractReferenceIdentity, len(references.Bindings))
|
||||
for index, binding := range references.Bindings {
|
||||
identity := references.Identities[index]
|
||||
destination, ok := copied[identity.SourceID]
|
||||
if !ok {
|
||||
filename := filepath.Base(filepath.FromSlash(identity.Path))
|
||||
if filename == "" || filename == "." || filename == string(filepath.Separator) {
|
||||
return nil, fmt.Errorf("reference %q source %q has invalid prepared filename", identity.Selector, identity.SourceID)
|
||||
}
|
||||
destination = filepath.Join(snapshotRoot, filename)
|
||||
if owner, duplicate := destinationOwners[destination]; duplicate && owner != identity.SourceID {
|
||||
return nil, fmt.Errorf("reference sources %q and %q resolve to the same snapshot path", owner, identity.SourceID)
|
||||
}
|
||||
destinationOwners[destination] = identity.SourceID
|
||||
|
||||
file, err := os.Open(filepath.Join(paths.Root, filepath.FromSlash(identity.Path)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open reference %q source %q for snapshot: %w", identity.Selector, identity.SourceID, err)
|
||||
}
|
||||
checksum, copyErr := fileops.WriteReaderAtomicWithChecksum(destination, file, fileops.WorkspaceFileMode)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return nil, fmt.Errorf("snapshot reference %q source %q: %w", identity.Selector, identity.SourceID, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("close reference %q source %q: %w", identity.Selector, identity.SourceID, closeErr)
|
||||
}
|
||||
info, err := os.Lstat(destination)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect reference %q snapshot: %w", identity.Selector, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || !strings.EqualFold(checksum, identity.Checksum) || info.Size() != identity.SizeBytes {
|
||||
return nil, fmt.Errorf("reference %q source %q changed while its verified snapshot was created", identity.Selector, identity.SourceID)
|
||||
}
|
||||
copied[identity.SourceID] = destination
|
||||
destination, err := extractReferenceSnapshotDestination(paths, identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reference %q source %q snapshot path: %w", identity.Selector, identity.SourceID, err)
|
||||
}
|
||||
if previous, ok := materialized[destination]; ok {
|
||||
if previous.Checksum != identity.Checksum || previous.SizeBytes != identity.SizeBytes {
|
||||
return nil, fmt.Errorf("references %q and %q have conflicting identities for snapshot %q", previous.Selector, identity.Selector, destination)
|
||||
}
|
||||
bindings[index] = notarius.ReferenceBinding{Selector: binding.Selector, Path: destination}
|
||||
continue
|
||||
}
|
||||
if err := materializeExtractReferenceSnapshot(paths, identity, destination); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
materialized[destination] = identity
|
||||
bindings[index] = notarius.ReferenceBinding{Selector: binding.Selector, Path: destination}
|
||||
}
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
func verifyExtractReferenceSnapshots(snapshotRoot string, references resolvedExtractReferences) error {
|
||||
verified := make(map[string]struct{}, len(references.Identities))
|
||||
for _, identity := range references.Identities {
|
||||
if _, ok := verified[identity.SourceID]; ok {
|
||||
continue
|
||||
func materializeExtractReferenceSnapshot(paths artifacts.SessionPaths, identity extractReferenceIdentity, destination string) error {
|
||||
if err := checkExtractSnapshotDirectory(paths.Root, filepath.Dir(destination), true); err != nil {
|
||||
return fmt.Errorf("reference %q snapshot cache: %w", identity.Selector, err)
|
||||
}
|
||||
info, err := os.Lstat(destination)
|
||||
if err == nil && !info.Mode().IsRegular() {
|
||||
return unsafeExtractSnapshotPath(destination, "the destination is not a regular file")
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("inspect reference %q snapshot %q: %w", identity.Selector, destination, err)
|
||||
}
|
||||
if err == nil {
|
||||
if verifyErr := verifyExtractReferenceFile(destination, identity); verifyErr == nil {
|
||||
return nil
|
||||
}
|
||||
filename := filepath.Base(filepath.FromSlash(identity.Path))
|
||||
file, err := os.Open(filepath.Join(snapshotRoot, filename))
|
||||
if err != nil {
|
||||
return fmt.Errorf("reopen reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, err)
|
||||
}
|
||||
digest := sha256.New()
|
||||
size, readErr := io.Copy(digest, file)
|
||||
closeErr := file.Close()
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("revalidate reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, closeErr)
|
||||
}
|
||||
checksum := hex.EncodeToString(digest.Sum(nil))
|
||||
if !strings.EqualFold(checksum, identity.Checksum) || size != identity.SizeBytes {
|
||||
return fmt.Errorf("reference %q source %q snapshot changed while Notarius was running", identity.Selector, identity.SourceID)
|
||||
}
|
||||
verified[identity.SourceID] = struct{}{}
|
||||
}
|
||||
|
||||
source, err := pathsafe.JoinSlashRelativeUnderRoot(paths.Root, identity.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve reference %q prepared source: %w", identity.Selector, err)
|
||||
}
|
||||
if err := verifyExtractReferenceFile(source, identity); err != nil {
|
||||
return fmt.Errorf("reference %q prepared source %q no longer matches its manifest identity; rerun prepare with --force: %w", identity.Selector, source, err)
|
||||
}
|
||||
checksum, err := fileops.CopyFileAtomicWithChecksum(source, destination, fileops.WorkspaceFileMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("snapshot reference %q source %q to %q: %w", identity.Selector, identity.SourceID, destination, err)
|
||||
}
|
||||
if checksum != identity.Checksum {
|
||||
return cleanupInvalidExtractSnapshot(paths, identity, destination,
|
||||
fmt.Errorf("copied checksum %q does not match manifest checksum %q", checksum, identity.Checksum))
|
||||
}
|
||||
if err := verifyExtractReferenceFile(destination, identity); err != nil {
|
||||
return cleanupInvalidExtractSnapshot(paths, identity, destination, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupInvalidExtractSnapshot(paths artifacts.SessionPaths, identity extractReferenceIdentity, destination string, verifyErr error) error {
|
||||
err := fmt.Errorf("reference %q source %q changed while snapshot %q was created; rerun prepare with --force: %w", identity.Selector, identity.SourceID, destination, verifyErr)
|
||||
if cleanupErr := fileops.RemoveFileUnderRoot(artifacts.SessionNotariusReferenceCacheDir(paths), destination); cleanupErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("remove invalid newly written snapshot %q: %w", destination, cleanupErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func verifyExtractReferenceSnapshots(paths artifacts.SessionPaths, references resolvedExtractReferences) error {
|
||||
var err error
|
||||
paths, err = absoluteExtractSnapshotPaths(paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified := make(map[string]extractReferenceIdentity, len(references.Identities))
|
||||
for _, identity := range references.Identities {
|
||||
destination, err := extractReferenceSnapshotDestination(paths, identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reference %q source %q snapshot path: %w", identity.Selector, identity.SourceID, err)
|
||||
}
|
||||
if previous, ok := verified[destination]; ok {
|
||||
if previous.Checksum != identity.Checksum || previous.SizeBytes != identity.SizeBytes {
|
||||
return fmt.Errorf("references %q and %q have conflicting identities for snapshot %q", previous.Selector, identity.Selector, destination)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := checkExtractSnapshotDirectory(paths.Root, filepath.Dir(destination), false); err != nil {
|
||||
return fmt.Errorf("reference %q snapshot cache: %w", identity.Selector, err)
|
||||
}
|
||||
if err := verifyExtractReferenceFile(destination, identity); err != nil {
|
||||
return fmt.Errorf("reference %q source %q snapshot changed while Notarius was running at %q: %w", identity.Selector, identity.SourceID, destination, err)
|
||||
}
|
||||
verified[destination] = identity
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve both roots against the caller's working directory before comparing
|
||||
// paths. A session-prefixed relative destination is not relative to Root.
|
||||
func absoluteExtractSnapshotPaths(paths artifacts.SessionPaths) (artifacts.SessionPaths, error) {
|
||||
var err error
|
||||
paths.Root, err = filepath.Abs(paths.Root)
|
||||
if err != nil {
|
||||
return paths, fmt.Errorf("resolve snapshot session root: %w", err)
|
||||
}
|
||||
paths.CacheDir, err = filepath.Abs(paths.CacheDir)
|
||||
if err != nil {
|
||||
return paths, fmt.Errorf("resolve snapshot cache root: %w", err)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func extractReferenceSnapshotDestination(paths artifacts.SessionPaths, identity extractReferenceIdentity) (string, error) {
|
||||
basename := filepath.Base(filepath.FromSlash(identity.Path))
|
||||
return artifacts.SessionNotariusReferenceSnapshotPath(paths, identity.Checksum, basename)
|
||||
}
|
||||
|
||||
func verifyExtractReferenceFile(path string, identity extractReferenceIdentity) error {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return unsafeExtractSnapshotPath(path, "the path is not a regular file")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest := sha256.New()
|
||||
size, readErr := io.Copy(digest, file)
|
||||
closeErr := file.Close()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
checksum := hex.EncodeToString(digest.Sum(nil))
|
||||
if checksum != identity.Checksum || size != identity.SizeBytes || info.Size() != identity.SizeBytes {
|
||||
return fmt.Errorf("content identity is checksum %q size %d, want checksum %q size %d", checksum, size, identity.Checksum, identity.SizeBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkExtractSnapshotDirectory(sessionRoot, directory string, create bool) error {
|
||||
relative, err := pathsafe.SlashRelativeFromRoot(sessionRoot, directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve snapshot directory %q below session root: %w", directory, err)
|
||||
}
|
||||
root, err := os.OpenRoot(sessionRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open session root %q: %w", sessionRoot, err)
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
|
||||
current := ""
|
||||
for _, component := range strings.Split(relative, "/") {
|
||||
if current == "" {
|
||||
current = component
|
||||
} else {
|
||||
current += "/" + component
|
||||
}
|
||||
info, err := root.Lstat(current)
|
||||
if errors.Is(err, os.ErrNotExist) && create {
|
||||
if err := root.Mkdir(current, fileops.WorkspaceDirectoryMode.Perm()); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("create snapshot cache directory %q: %w", filepath.Join(sessionRoot, filepath.FromSlash(current)), err)
|
||||
}
|
||||
info, err = root.Lstat(current)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect snapshot cache directory %q: %w", filepath.Join(sessionRoot, filepath.FromSlash(current)), err)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return unsafeExtractSnapshotPath(filepath.Join(sessionRoot, filepath.FromSlash(current)), "a path component is not a real directory")
|
||||
}
|
||||
if err := root.Chmod(current, fileops.WorkspaceDirectoryMode); err != nil {
|
||||
return fmt.Errorf("set snapshot cache directory permissions %q: %w", filepath.Join(sessionRoot, filepath.FromSlash(current)), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsafeExtractSnapshotPath(path, reason string) error {
|
||||
return fmt.Errorf("unsafe Notarius reference snapshot cache path %q: %s; remove or replace it only while the session is inactive", path, reason)
|
||||
}
|
||||
|
||||
type resolvedExtractReferences struct {
|
||||
Bindings []notarius.ReferenceBinding
|
||||
Identities []extractReferenceIdentity
|
||||
|
||||
@@ -2,6 +2,8 @@ package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -51,7 +53,27 @@ func TestExtractStageDisabledReturnsExplicitSkip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtractStageResolvesManifestInputAndBuildsExactRequest(t *testing.T) {
|
||||
const credentialName = "NARRATIO_EXTRACT_NOTARIUS_KEY"
|
||||
const credentialCanary = "extract-notarius-credential-canary"
|
||||
t.Setenv(credentialName, credentialCanary)
|
||||
credentialPipelinePath := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
if err := os.WriteFile(credentialPipelinePath, []byte(`whisperx:
|
||||
transcribe_url: https://transcription.example.test/transcribe
|
||||
notarius:
|
||||
enabled: false
|
||||
llm_api_key: NARRATIO_EXTRACT_NOTARIUS_KEY
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loadedPipeline, err := config.LoadPipeline(credentialPipelinePath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPipeline() error = %v", err)
|
||||
}
|
||||
|
||||
env, m, fake := setupExtractEnv(t)
|
||||
env.Config.Pipeline.Notarius.LLMAPIKey = loadedPipeline.Notarius.LLMAPIKey
|
||||
configProfile := "inexpensive"
|
||||
env.Config.Pipeline.Notarius.ConfigProfile = &configProfile
|
||||
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
@@ -78,14 +100,70 @@ func TestExtractStageResolvesManifestInputAndBuildsExactRequest(t *testing.T) {
|
||||
if len(req.References) != 0 {
|
||||
t.Fatalf("references = %#v, want none", req.References)
|
||||
}
|
||||
if req.Resume || req.ChunkCache != "" {
|
||||
t.Fatalf("default cache controls = resume %v, mode %q", req.Resume, req.ChunkCache)
|
||||
}
|
||||
if req.LLMAPIKey != loadedPipeline.Notarius.LLMAPIKey || req.LLMAPIKey.EnvName() != credentialName || !req.LLMAPIKey.IsResolved() {
|
||||
t.Fatal("request did not retain the loaded Notarius credential reference")
|
||||
}
|
||||
if req.ConfigProfile == nil || *req.ConfigProfile != configProfile {
|
||||
t.Fatalf("request config profile = %#v, want %q", req.ConfigProfile, configProfile)
|
||||
}
|
||||
if result.Metadata["reference_count"] != 0 || len(result.Metadata["references"].([]map[string]any)) != 0 {
|
||||
t.Fatalf("reference metadata = count %#v references %#v", result.Metadata["reference_count"], result.Metadata["references"])
|
||||
}
|
||||
metadata, err := json.Marshal(result.Metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(metadata), credentialCanary) || strings.Contains(string(metadata), credentialName) {
|
||||
t.Fatal("result metadata contains the Notarius credential")
|
||||
}
|
||||
if len(result.Outputs) != 2 {
|
||||
t.Fatalf("outputs = %#v, want lane and index", result.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStageAppliesNotariusCacheControlsAndForcePrecedence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configuredResume bool
|
||||
configuredMode string
|
||||
force bool
|
||||
wantResume bool
|
||||
wantMode string
|
||||
}{
|
||||
{name: "ordinary defaults"},
|
||||
{name: "ordinary configured", configuredResume: true, configuredMode: "bypass", wantResume: true, wantMode: "bypass"},
|
||||
{name: "force overrides configured", configuredResume: true, configuredMode: "bypass", force: true, wantMode: "refresh"},
|
||||
{name: "force overrides defaults", force: true, wantMode: "refresh"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env, m, fake := setupExtractEnv(t)
|
||||
configProfile := "inexpensive"
|
||||
env.Config.Pipeline.Notarius.ConfigProfile = &configProfile
|
||||
env.Config.Pipeline.Notarius.Resume = test.configuredResume
|
||||
env.Config.Pipeline.Notarius.ChunkCache = test.configuredMode
|
||||
env.Force = test.force
|
||||
|
||||
if _, err := (extractStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(fake.Requests) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(fake.Requests))
|
||||
}
|
||||
request := fake.Requests[0]
|
||||
if request.Resume != test.wantResume || request.ChunkCache != test.wantMode {
|
||||
t.Fatalf("cache controls = resume %v, mode %q; want resume %v, mode %q", request.Resume, request.ChunkCache, test.wantResume, test.wantMode)
|
||||
}
|
||||
if request.ConfigProfile == nil || *request.ConfigProfile != configProfile {
|
||||
t.Fatalf("config profile = %#v, want %q", request.ConfigProfile, configProfile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T) {
|
||||
env, m, fake := setupExtractEnv(t)
|
||||
configured := []struct {
|
||||
@@ -121,12 +199,16 @@ func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T)
|
||||
}
|
||||
for index, selector := range wantSelectors {
|
||||
binding := fake.Requests[0].References[index]
|
||||
wantPath := filepath.Join(
|
||||
artifacts.SessionRunNotariusReferencesDirForCampaign(
|
||||
env.Config.Pipeline.Workspace.Root, m.Campaign, m.SessionID, m.RunID,
|
||||
),
|
||||
filepath.Base(preparedPaths[selector]),
|
||||
checksum, err := artifacts.SHA256File(preparedPaths[selector])
|
||||
if err != nil {
|
||||
t.Fatalf("SHA256File(%q) error = %v", selector, err)
|
||||
}
|
||||
wantPath, err := artifacts.SessionNotariusReferenceSnapshotPath(
|
||||
sessionPathsForEnv(env, m.SessionID), checksum, filepath.Base(preparedPaths[selector]),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("SessionNotariusReferenceSnapshotPath(%q) error = %v", selector, err)
|
||||
}
|
||||
if binding.Selector != selector || binding.Path != wantPath || binding.Path == preparedPaths[selector] || !filepath.IsAbs(binding.Path) {
|
||||
t.Fatalf("reference[%d] = %#v, want selector %q verified snapshot %q", index, binding, selector, wantPath)
|
||||
}
|
||||
@@ -163,11 +245,11 @@ func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T)
|
||||
}
|
||||
|
||||
type referenceMutationRunner struct {
|
||||
delegate notarius.Runner
|
||||
preparedPath string
|
||||
preparedBytes []byte
|
||||
mutateSnapshot bool
|
||||
snapshotBytes []byte
|
||||
delegate notarius.Runner
|
||||
preparedPath string
|
||||
preparedBytes []byte
|
||||
mutate func(string) error
|
||||
snapshotBytes []byte
|
||||
}
|
||||
|
||||
func (r *referenceMutationRunner) Run(ctx context.Context, req notarius.RunRequest) (notarius.RunResult, error) {
|
||||
@@ -182,8 +264,8 @@ func (r *referenceMutationRunner) Run(ctx context.Context, req notarius.RunReque
|
||||
return notarius.RunResult{}, err
|
||||
}
|
||||
r.snapshotBytes = data
|
||||
if r.mutateSnapshot {
|
||||
if err := os.WriteFile(req.References[0].Path, []byte("tampered snapshot\n"), 0o664); err != nil {
|
||||
if r.mutate != nil {
|
||||
if err := r.mutate(req.References[0].Path); err != nil {
|
||||
return notarius.RunResult{}, err
|
||||
}
|
||||
}
|
||||
@@ -206,21 +288,292 @@ func TestExtractStageInvokesNotariusWithVerifiedReferenceSnapshot(t *testing.T)
|
||||
t.Fatalf("Notarius reference bytes = %q, want verified snapshot", runner.snapshotBytes)
|
||||
}
|
||||
if len(fake.Requests) != 1 || fake.Requests[0].References[0].Path == preparedPath {
|
||||
t.Fatalf("adapter requests = %#v, want a run-local reference snapshot", fake.Requests)
|
||||
t.Fatalf("adapter requests = %#v, want a stable reference snapshot", fake.Requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStageRejectsReferenceSnapshotChangedDuringNotariusRun(t *testing.T) {
|
||||
env, m, fake := setupExtractEnv(t)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(string) error
|
||||
}{
|
||||
{name: "content", mutate: func(path string) error { return os.WriteFile(path, []byte("tampered snapshot\n"), 0o664) }},
|
||||
{name: "missing", mutate: os.Remove},
|
||||
{name: "symlink", mutate: func(path string) error {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(path+".outside", path)
|
||||
}},
|
||||
{name: "directory", mutate: func(path string) error {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Mkdir(path, 0o775)
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env, m, fake := setupExtractEnv(t)
|
||||
env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty}
|
||||
preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "verified party\n")
|
||||
env.Notarius = &referenceMutationRunner{
|
||||
delegate: fake, preparedPath: preparedPath, preparedBytes: []byte("changed after snapshot\n"), mutate: test.mutate,
|
||||
}
|
||||
|
||||
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "snapshot changed while Notarius was running") || result != nil {
|
||||
t.Fatalf("Run() result = %#v error = %v, want changed-snapshot failure", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReferenceSnapshotsAreStableReusableAndRecoverable(t *testing.T) {
|
||||
env, m, _ := setupExtractEnv(t)
|
||||
env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty}
|
||||
preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "verified party\n")
|
||||
env.Notarius = &referenceMutationRunner{
|
||||
delegate: fake, preparedPath: preparedPath, preparedBytes: []byte("changed after snapshot\n"), mutateSnapshot: true,
|
||||
preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "stable party\n")
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
references, err := resolveExtractReferences(paths, m, env.Config.Pipeline.Notarius, m.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveExtractReferences() error = %v", err)
|
||||
}
|
||||
first, err := materializeExtractReferenceSnapshots(paths, references)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeExtractReferenceSnapshots(first) error = %v", err)
|
||||
}
|
||||
if len(first) != 1 {
|
||||
t.Fatalf("first bindings = %#v", first)
|
||||
}
|
||||
firstPath := first[0].Path
|
||||
|
||||
oldTime := time.Unix(123, 0)
|
||||
if err := os.Chtimes(firstPath, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("Chtimes(snapshot) error = %v", err)
|
||||
}
|
||||
m.RunID = "20260810T020304Z-fedcba98"
|
||||
second, err := materializeExtractReferenceSnapshots(paths, references)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeExtractReferenceSnapshots(second run) error = %v", err)
|
||||
}
|
||||
if second[0].Path != firstPath {
|
||||
t.Fatalf("snapshot path changed across run IDs: %q != %q", second[0].Path, firstPath)
|
||||
}
|
||||
info, err := os.Stat(firstPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !info.ModTime().Equal(oldTime) {
|
||||
t.Fatalf("verified snapshot was rewritten: modtime = %v, want %v", info.ModTime(), oldTime)
|
||||
}
|
||||
|
||||
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "snapshot changed while Notarius was running") || result != nil {
|
||||
t.Fatalf("Run() result = %#v error = %v, want changed-snapshot failure", result, err)
|
||||
if err := os.Remove(firstPath); err != nil {
|
||||
t.Fatalf("Remove(snapshot) error = %v", err)
|
||||
}
|
||||
if _, err := materializeExtractReferenceSnapshots(paths, references); err != nil {
|
||||
t.Fatalf("reconstruct missing snapshot error = %v", err)
|
||||
}
|
||||
assertFileContents(t, firstPath, "stable party\n")
|
||||
|
||||
if err := os.WriteFile(firstPath, []byte("corrupt snapshot\n"), 0o664); err != nil {
|
||||
t.Fatalf("WriteFile(corrupt snapshot) error = %v", err)
|
||||
}
|
||||
if _, err := materializeExtractReferenceSnapshots(paths, references); err != nil {
|
||||
t.Fatalf("repair corrupt snapshot error = %v", err)
|
||||
}
|
||||
assertFileContents(t, firstPath, "stable party\n")
|
||||
|
||||
if err := os.WriteFile(preparedPath, []byte("changed party bytes\n"), 0o664); err != nil {
|
||||
t.Fatalf("WriteFile(changed prepared source) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(firstPath, []byte("corrupt before source mismatch\n"), 0o664); err != nil {
|
||||
t.Fatalf("WriteFile(corrupt snapshot) error = %v", err)
|
||||
}
|
||||
if _, err := materializeExtractReferenceSnapshots(paths, references); err == nil || !strings.Contains(err.Error(), "prepared source") {
|
||||
t.Fatalf("materialize with changed prepared source error = %v, want prepared-source mismatch", err)
|
||||
}
|
||||
assertFileContents(t, firstPath, "corrupt before source mismatch\n")
|
||||
|
||||
changedChecksum, err := artifacts.SHA256File(preparedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("SHA256File(changed prepared source) error = %v", err)
|
||||
}
|
||||
descriptor, _ := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty)
|
||||
for index := range m.Inputs {
|
||||
if m.Inputs[index].Kind == descriptor.ManifestKind {
|
||||
m.Inputs[index].Checksum = changedChecksum
|
||||
}
|
||||
}
|
||||
changed, err := resolveExtractReferences(paths, m, env.Config.Pipeline.Notarius, m.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveExtractReferences(changed) error = %v", err)
|
||||
}
|
||||
changedBindings, err := materializeExtractReferenceSnapshots(paths, changed)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeExtractReferenceSnapshots(changed) error = %v", err)
|
||||
}
|
||||
if changedBindings[0].Path == firstPath || filepath.Base(changedBindings[0].Path) != filepath.Base(firstPath) {
|
||||
t.Fatalf("changed bytes paths = old %q new %q, want new digest path with same basename", firstPath, changedBindings[0].Path)
|
||||
}
|
||||
assertFileContents(t, firstPath, "corrupt before source mismatch\n")
|
||||
assertFileContents(t, changedBindings[0].Path, "changed party bytes\n")
|
||||
}
|
||||
|
||||
func TestExtractReferenceSnapshotsDeduplicateEquivalentDestinations(t *testing.T) {
|
||||
env, m, _ := setupExtractEnv(t)
|
||||
path := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "shared bytes\n")
|
||||
checksum, err := artifacts.SHA256File(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity := extractReferenceIdentity{
|
||||
Selector: "party", SourceID: artifactpolicy.SourceInputParty, Path: "inputs/party.yml",
|
||||
Checksum: checksum, SizeBytes: int64(len("shared bytes\n")),
|
||||
}
|
||||
references := resolvedExtractReferences{
|
||||
Bindings: []notarius.ReferenceBinding{{Selector: "party"}, {Selector: "alternate"}},
|
||||
Identities: []extractReferenceIdentity{
|
||||
identity,
|
||||
{Selector: "alternate", SourceID: artifactpolicy.SourceInputGlossary, Path: identity.Path, Checksum: checksum, SizeBytes: identity.SizeBytes},
|
||||
},
|
||||
}
|
||||
bindings, err := materializeExtractReferenceSnapshots(sessionPathsForEnv(env, m.SessionID), references)
|
||||
if err != nil {
|
||||
t.Fatalf("materializeExtractReferenceSnapshots() error = %v", err)
|
||||
}
|
||||
if len(bindings) != 2 || bindings[0].Path != bindings[1].Path {
|
||||
t.Fatalf("equivalent bindings = %#v, want shared destination", bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReferenceSnapshotsWithRelativeWorkspace(t *testing.T) {
|
||||
env, m, _ := setupExtractEnv(t)
|
||||
prepared := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "party bytes\n")
|
||||
env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty}
|
||||
absPaths := sessionPathsForEnv(env, m.SessionID)
|
||||
references, err := resolveExtractReferences(absPaths, m, env.Config.Pipeline.Notarius, m.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(env.Config.Pipeline.Workspace.Root)
|
||||
paths := artifacts.NewLocalStore(".").SessionPathsFor(m.Campaign, m.SessionID)
|
||||
first, err := materializeExtractReferenceSnapshots(paths, references)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !filepath.IsAbs(first[0].Path) {
|
||||
t.Fatalf("adapter reference remains relative: %q", first[0].Path)
|
||||
}
|
||||
m.RunID = "20260809T020203Z-abcdef13"
|
||||
second, err := materializeExtractReferenceSnapshots(paths, references)
|
||||
if err != nil || !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("second invocation bindings = %#v, error = %v", second, err)
|
||||
}
|
||||
if err := verifyExtractReferenceSnapshots(paths, references); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(absPaths.Root, paths.Root)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("duplicated session prefix exists or cannot be inspected: %v", err)
|
||||
}
|
||||
|
||||
// A controlled executable proves the production adapter accepts the paths.
|
||||
// It intentionally produces no receipt; receipt validation is covered elsewhere.
|
||||
marker := filepath.Join(t.TempDir(), "started")
|
||||
binary := filepath.Join(t.TempDir(), "notarius")
|
||||
if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf started > '"+marker+"'\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diagnostics := t.TempDir()
|
||||
_, err = notarius.NewSubprocessRunner().Run(context.Background(), notarius.RunRequest{
|
||||
Binary: binary, PipelineID: "dnd-session", ConfigPath: env.Config.Pipeline.Notarius.ConfigPath,
|
||||
InputPath: prepared, OutputRoot: t.TempDir(), WorkingDirectory: env.Config.Pipeline.Workspace.Root,
|
||||
Timeout: time.Second * 10, References: first,
|
||||
ReceiptPath: filepath.Join(diagnostics, "receipt.json"), LogPath: filepath.Join(diagnostics, "stderr.log"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing receipt error")
|
||||
}
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("adapter did not launch with the snapshot bindings: %v", err)
|
||||
}
|
||||
|
||||
// A symlink on the actual cache path must not be missed by a duplicated walk.
|
||||
if err := os.Remove(first[0].Path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digestDir := filepath.Dir(first[0].Path)
|
||||
if err := os.Remove(digestDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.Symlink(outside, digestDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := materializeExtractReferenceSnapshots(paths, references); err == nil {
|
||||
t.Fatal("accepted symlink on actual cache path")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, filepath.Base(first[0].Path))); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unsafe cache path wrote outside session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractReferenceSnapshotsRejectUnsafeConflicts(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
setup func(*testing.T, artifacts.SessionPaths, string)
|
||||
}{
|
||||
{name: "leaf symlink", setup: func(t *testing.T, _ artifacts.SessionPaths, destination string) {
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o775); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(t.TempDir(), "outside"), destination); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "leaf directory", setup: func(t *testing.T, _ artifacts.SessionPaths, destination string) {
|
||||
if err := os.MkdirAll(destination, 0o775); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "digest symlink", setup: func(t *testing.T, paths artifacts.SessionPaths, destination string) {
|
||||
cacheRoot := artifacts.SessionNotariusReferenceCacheDir(paths)
|
||||
if err := os.MkdirAll(cacheRoot, 0o775); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(t.TempDir(), filepath.Dir(destination)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env, m, _ := setupExtractEnv(t)
|
||||
env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty}
|
||||
recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "party bytes\n")
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
references, err := resolveExtractReferences(paths, m, env.Config.Pipeline.Notarius, m.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
destination, err := extractReferenceSnapshotDestination(paths, references.Identities[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test.setup(t, paths, destination)
|
||||
_, err = materializeExtractReferenceSnapshots(paths, references)
|
||||
if err == nil || !strings.Contains(err.Error(), artifacts.SessionNotariusReferenceCacheDir(paths)) || !strings.Contains(err.Error(), "session is inactive") {
|
||||
t.Fatalf("materializeExtractReferenceSnapshots() error = %v, want actionable unsafe-path failure for %q", err, destination)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileContents(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v", path, err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("ReadFile(%q) = %q, want %q", path, data, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -630,6 +983,60 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionFingerprintBaseSelectorsPreservePriorPayloadShape(t *testing.T) {
|
||||
input := artifacts.ExtractionInputIdentity{
|
||||
Checksum: "checksum", SourceID: artifacts.ArtifactTranscriptFinalTrimmed,
|
||||
ProducerStage: "trim", OutputKind: artifacts.TranscriptOutputKindFinalTrimmed,
|
||||
ProducerRunID: "trim-run", Provenance: "manifest.trim.outputs",
|
||||
}
|
||||
references := []extractReferenceIdentity{{
|
||||
Selector: "party", SourceID: artifactpolicy.SourceInputParty,
|
||||
Path: "inputs/party.yml", Checksum: "party-checksum", SizeBytes: 34,
|
||||
}}
|
||||
cfg := &config.NotariusConfig{
|
||||
PipelineID: "pipeline",
|
||||
Outputs: map[string]config.NotariusOutputConfig{
|
||||
"alpha": {LaneID: "a", MediaType: "application/json", SchemaID: "a", SchemaVersion: "v1"},
|
||||
},
|
||||
}
|
||||
legacyPayload, err := json.Marshal(struct {
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
Input artifacts.ExtractionInputIdentity `json:"input"`
|
||||
References []extractReferenceIdentity `json:"references"`
|
||||
Outputs []fingerprintOutput `json:"outputs"`
|
||||
}{
|
||||
PipelineID: cfg.PipelineID,
|
||||
Input: input,
|
||||
References: references,
|
||||
Outputs: []fingerprintOutput{{
|
||||
Key: "alpha", LaneID: "a", MediaType: "application/json", SchemaID: "a", SchemaVersion: "v1",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyDigest := sha256.Sum256(legacyPayload)
|
||||
want := hex.EncodeToString(legacyDigest[:])
|
||||
for _, profile := range []*string{nil, stringPointerForExtractTest(""), stringPointerForExtractTest("default")} {
|
||||
cfg.ConfigProfile = profile
|
||||
got, err := extractionFingerprint(cfg, input, references)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("base selection fingerprint = %q, want prior no-profile payload digest %q", got, want)
|
||||
}
|
||||
}
|
||||
cfg.ConfigProfile = stringPointerForExtractTest("inexpensive")
|
||||
named, err := extractionFingerprint(cfg, input, references)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if named == want {
|
||||
t.Fatal("named selection retained prior no-profile fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T) {
|
||||
env, m, _ := setupExtractEnv(t)
|
||||
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||
@@ -648,6 +1055,31 @@ func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStageResumeValidationTracksConfigProfile(t *testing.T) {
|
||||
env, m, _ := setupExtractEnv(t)
|
||||
selected := "inexpensive"
|
||||
env.Config.Pipeline.Notarius.ConfigProfile = &selected
|
||||
seedSucceededExtractResult(t, env, m)
|
||||
|
||||
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateResume(same selection) error = %v", err)
|
||||
}
|
||||
if !validation.Resumable {
|
||||
t.Fatalf("same selection validation = %#v, want reusable recorded bundle", validation)
|
||||
}
|
||||
|
||||
changed := "experimental"
|
||||
env.Config.Pipeline.Notarius.ConfigProfile = &changed
|
||||
validation, err = (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateResume(changed selection) error = %v", err)
|
||||
}
|
||||
if validation.Resumable || !strings.Contains(validation.Reason, "invocation contract changed") {
|
||||
t.Fatalf("changed selection validation = %#v, want non-resumable invocation evidence", validation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractStageResumeValidationRejectsChangedPreparedReferenceIdentity(t *testing.T) {
|
||||
env, m, _ := setupExtractEnv(t)
|
||||
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||
@@ -936,6 +1368,8 @@ type extractFixture struct {
|
||||
inputPath string
|
||||
}
|
||||
|
||||
func stringPointerForExtractTest(value string) *string { return &value }
|
||||
|
||||
func extractFixtureFromEnv(t *testing.T, env *Env, m *manifest.Manifest) extractFixture {
|
||||
t.Helper()
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
|
||||
@@ -306,6 +306,12 @@ func normalizeMergeInputs(
|
||||
}
|
||||
|
||||
func discoverRawTranscripts(m *manifest.Manifest, paths artifacts.SessionPaths) ([]string, error) {
|
||||
if record, versioned, err := transcribeManifestState(m); err != nil {
|
||||
return nil, err
|
||||
} else if versioned {
|
||||
return discoverVersionedRawTranscripts(record, paths)
|
||||
}
|
||||
|
||||
fromManifest := make([]string, 0)
|
||||
if m != nil && m.Stages != nil {
|
||||
if tr := m.Stages["transcribe"]; tr != nil {
|
||||
@@ -350,6 +356,64 @@ func discoverRawTranscripts(m *manifest.Manifest, paths artifacts.SessionPaths)
|
||||
return dedupeAndSortPaths(out), nil
|
||||
}
|
||||
|
||||
func discoverVersionedRawTranscripts(
|
||||
record *manifest.StageRecord,
|
||||
paths artifacts.SessionPaths,
|
||||
) ([]string, error) {
|
||||
if record.Status != manifest.StatusSucceeded {
|
||||
return nil, fmt.Errorf("versioned transcribe state is not aggregate-successful (status %q)", record.Status)
|
||||
}
|
||||
if err := manifest.ValidateTranscribeSpeakerCollection(
|
||||
record.TranscribeStateVersion,
|
||||
record.TranscribeSpeakers,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("validate versioned transcribe state: %w", err)
|
||||
}
|
||||
if len(record.TranscribeSpeakers) == 0 {
|
||||
return nil, fmt.Errorf("versioned transcribe state has no completed speakers")
|
||||
}
|
||||
|
||||
expected := make(map[string]string, len(record.TranscribeSpeakers))
|
||||
for speaker, speakerRecord := range record.TranscribeSpeakers {
|
||||
path := filepath.Clean(artifacts.ResolveSessionLocalPathForRead(paths, speakerRecord.Output.LocalPath))
|
||||
if other, exists := expected[path]; exists {
|
||||
return nil, fmt.Errorf("versioned transcribe speakers %q and %q declare the same output", other, speaker)
|
||||
}
|
||||
expected[path] = speaker
|
||||
}
|
||||
inputs := make([]string, 0, len(expected))
|
||||
for _, output := range record.Outputs {
|
||||
if output.Kind != "transcript_raw" {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(output.LocalPath)
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("versioned transcribe aggregate output has an empty local path")
|
||||
}
|
||||
path = filepath.Clean(artifacts.ResolveSessionLocalPathForRead(paths, path))
|
||||
speaker, declared := expected[path]
|
||||
if !declared {
|
||||
return nil, fmt.Errorf("versioned transcribe aggregate output %q is not declared by current speaker state", output.LocalPath)
|
||||
}
|
||||
if speaker == "" {
|
||||
return nil, fmt.Errorf("versioned transcribe aggregate output %q is duplicated", output.LocalPath)
|
||||
}
|
||||
expected[path] = ""
|
||||
inputs = append(inputs, path)
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
for _, speaker := range expected {
|
||||
if speaker != "" {
|
||||
missing = append(missing, speaker)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
return nil, fmt.Errorf("versioned transcribe aggregate outputs are incomplete for speakers: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return dedupeAndSortPaths(inputs), nil
|
||||
}
|
||||
|
||||
func dedupeAndSortPaths(paths []string) []string {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -191,6 +192,70 @@ func TestMergeStageFallsBackToRawDirectoryWhenTranscribeOutputsMissing(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeStageRejectsPartialVersionedTranscribeState(t *testing.T) {
|
||||
env, m := setupMergeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeFile(t, filepath.Join(paths.TranscriptsRawDir, "alice.json"), `{"segments":[]}`)
|
||||
writeFile(t, filepath.Join(paths.TranscriptsRawDir, "uncommitted.json"), `{"segments":[]}`)
|
||||
writeFile(t, filepath.Join(paths.InputsDir, "speakers.yml"), "match: []\n")
|
||||
writeFile(t, filepath.Join(paths.InputsDir, "autocorrect.yml"), "rules: []\n")
|
||||
setVersionedTranscribeMergeState(t, m, manifest.StatusFailed, map[string]string{
|
||||
"alice": "transcripts/raw/alice.json",
|
||||
})
|
||||
fake := &seriatim.FakeRunner{}
|
||||
env.Seriatim = fake
|
||||
|
||||
_, err := (mergeStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "not aggregate-successful") {
|
||||
t.Fatalf("merge.Run() error = %v, want incomplete aggregate rejection", err)
|
||||
}
|
||||
if len(fake.Requests) != 0 || len(fake.NormalizeRequests) != 0 {
|
||||
t.Fatalf("adapter requests = %#v / %#v, want none", fake.Requests, fake.NormalizeRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeStageUsesOnlyCurrentVersionedAggregateOutputs(t *testing.T) {
|
||||
env, m := setupMergeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
alice := filepath.Join(paths.TranscriptsRawDir, "alice.json")
|
||||
writeFile(t, alice, `{"segments":[]}`)
|
||||
writeFile(t, filepath.Join(paths.TranscriptsRawDir, "removed.json"), `{"segments":[]}`)
|
||||
writeFile(t, filepath.Join(paths.InputsDir, "speakers.yml"), "match: []\n")
|
||||
writeFile(t, filepath.Join(paths.InputsDir, "autocorrect.yml"), "rules: []\n")
|
||||
setVersionedTranscribeMergeState(t, m, manifest.StatusSucceeded, map[string]string{
|
||||
"alice": "transcripts/raw/alice.json",
|
||||
})
|
||||
fake := &seriatim.FakeRunner{}
|
||||
env.Seriatim = fake
|
||||
|
||||
if _, err := (mergeStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("merge.Run() error = %v", err)
|
||||
}
|
||||
if len(fake.NormalizeRequests) != 1 || fake.NormalizeRequests[0].InputTranscriptPath != alice {
|
||||
t.Fatalf("normalize requests = %#v, want only current alice", fake.NormalizeRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeStageRejectsIncompleteVersionedAggregateOutputs(t *testing.T) {
|
||||
env, m := setupMergeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeFile(t, filepath.Join(paths.TranscriptsRawDir, "alice.json"), `{"segments":[]}`)
|
||||
writeFile(t, filepath.Join(paths.TranscriptsRawDir, "bob.json"), `{"segments":[]}`)
|
||||
writeFile(t, filepath.Join(paths.InputsDir, "speakers.yml"), "match: []\n")
|
||||
writeFile(t, filepath.Join(paths.InputsDir, "autocorrect.yml"), "rules: []\n")
|
||||
setVersionedTranscribeMergeState(t, m, manifest.StatusSucceeded, map[string]string{
|
||||
"alice": "transcripts/raw/alice.json",
|
||||
"bob": "transcripts/raw/bob.json",
|
||||
})
|
||||
m.Stages["transcribe"].Outputs = m.Stages["transcribe"].Outputs[:1]
|
||||
env.Seriatim = &seriatim.FakeRunner{}
|
||||
|
||||
_, err := (mergeStage{}).Run(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "incomplete for speakers: bob") {
|
||||
t.Fatalf("merge.Run() error = %v, want incomplete aggregate output rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeStageResolvesWorkspaceQualifiedManifestOutputsWithoutDuplication(t *testing.T) {
|
||||
env, m := setupMergeEnvWithRelativeWorkspaceRoot(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
@@ -414,3 +479,44 @@ func setupMergeEnvWithWorkspace(t *testing.T, workspace string) (*Env, *manifest
|
||||
ArtifactStore: store,
|
||||
}, manifest.New("2026-05-03", time.Now().UTC())
|
||||
}
|
||||
|
||||
func setVersionedTranscribeMergeState(
|
||||
t *testing.T,
|
||||
m *manifest.Manifest,
|
||||
status manifest.StageStatus,
|
||||
outputs map[string]string,
|
||||
) {
|
||||
t.Helper()
|
||||
if m.RunID == "" {
|
||||
m.RunID = "20260503T010203Z-a1b2c3d4"
|
||||
}
|
||||
aggregate := make([]manifest.ArtifactRecord, 0, len(outputs))
|
||||
records := make(map[string]manifest.TranscribeSpeakerRecord, len(outputs))
|
||||
for speaker, outputPath := range outputs {
|
||||
output := manifest.ArtifactRecord{
|
||||
Kind: "transcript_raw", LocalPath: outputPath,
|
||||
ProducerRunID: m.RunID, Checksum: strings.Repeat("b", 64),
|
||||
}
|
||||
aggregate = append(aggregate, output)
|
||||
records[speaker] = manifest.TranscribeSpeakerRecord{
|
||||
FingerprintVersion: manifest.TranscribeFingerprintContractVersion,
|
||||
Fingerprint: strings.Repeat("c", 64),
|
||||
AudioPath: "audio/" + speaker + ".flac",
|
||||
AudioSHA256: strings.Repeat("a", 64),
|
||||
AudioSize: 1,
|
||||
Output: output,
|
||||
OutputSize: 1,
|
||||
ProducerRunID: m.RunID,
|
||||
}
|
||||
}
|
||||
slices.SortFunc(aggregate, func(a, b manifest.ArtifactRecord) int {
|
||||
return strings.Compare(a.LocalPath, b.LocalPath)
|
||||
})
|
||||
m.MarkStageSucceeded("transcribe", time.Now().UTC(), aggregate)
|
||||
record := m.Stages["transcribe"]
|
||||
record.TranscribeStateVersion = manifest.TranscribeStateContractVersion
|
||||
record.TranscribeSpeakers = records
|
||||
if status != manifest.StatusSucceeded {
|
||||
m.MarkStageFailed("transcribe", time.Now().UTC(), "incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
379
internal/stage/notarius_cache_integration_test.go
Normal file
379
internal/stage/notarius_cache_integration_test.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
const notariusIntegrationBinaryEnv = "NARRATIO_TEST_NOTARIUS_BINARY"
|
||||
|
||||
// Run with:
|
||||
// NARRATIO_TEST_NOTARIUS_BINARY=/absolute/path/to/notarius GOWORK=off go test ./internal/stage -run '^TestNotariusCacheReuseIntegration$' -count=1 -v
|
||||
func TestNotariusCacheReuseIntegration(t *testing.T) {
|
||||
binary := strings.TrimSpace(os.Getenv(notariusIntegrationBinaryEnv))
|
||||
if binary == "" {
|
||||
t.Skipf("set %s to an absolute Notarius v0.7.0-compatible executable to run this integration test", notariusIntegrationBinaryEnv)
|
||||
}
|
||||
if !filepath.IsAbs(binary) {
|
||||
t.Fatalf("%s = %q, want an absolute path", notariusIntegrationBinaryEnv, binary)
|
||||
}
|
||||
info, err := os.Stat(binary)
|
||||
if err != nil {
|
||||
t.Fatalf("inspect %s: %v", notariusIntegrationBinaryEnv, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 {
|
||||
t.Fatalf("%s = %q, want an executable regular file", notariusIntegrationBinaryEnv, binary)
|
||||
}
|
||||
|
||||
t.Run("reuse reference identity and forced refresh", func(t *testing.T) {
|
||||
provider := newNotariusIntegrationProvider(t)
|
||||
env, runManifest, fixture := newNotariusIntegrationFixture(t, binary, provider.URL(), "dnd-session")
|
||||
|
||||
first := runNotariusIntegrationExtraction(t, env, runManifest, "20260916T010101Z-a0000001", false)
|
||||
if got := provider.Calls(); got != 2 {
|
||||
t.Fatalf("first provider calls = %d, want 2", got)
|
||||
}
|
||||
if first.ChunkPlan == nil || first.ChunkPlan.Action != "generated" {
|
||||
t.Fatalf("first chunk plan = %#v, want generated", first.ChunkPlan)
|
||||
}
|
||||
|
||||
env.Config.Pipeline.Notarius.Resume = true
|
||||
second := runNotariusIntegrationExtraction(t, env, runManifest, "20260916T010102Z-a0000002", false)
|
||||
if got := provider.Calls(); got != 2 {
|
||||
t.Fatalf("resumed provider calls = %d, want unchanged count 2", got)
|
||||
}
|
||||
assertNotariusIntegrationDecision(t, second, "extract", "spells-first", "reused", "checkpoint_reused")
|
||||
assertNotariusIntegrationDecision(t, second, "extract", "spells-second", "reused", "checkpoint_reused")
|
||||
|
||||
oldSnapshot := notariusIntegrationSnapshotPath(t, env, runManifest, artifactpolicy.SourceInputParty)
|
||||
setNotariusIntegrationReference(t, runManifest, fixture.partyPath, "The party now travels with a silver dragon.\n")
|
||||
third := runNotariusIntegrationExtraction(t, env, runManifest, "20260916T010103Z-a0000003", false)
|
||||
if got := provider.Calls(); got != 4 {
|
||||
t.Fatalf("changed-reference provider calls = %d, want 4", got)
|
||||
}
|
||||
assertNotariusIntegrationDecision(t, third, "extract", "spells-first", "executed", "checkpoint_missing")
|
||||
newSnapshot := notariusIntegrationSnapshotPath(t, env, runManifest, artifactpolicy.SourceInputParty)
|
||||
if newSnapshot == oldSnapshot {
|
||||
t.Fatalf("changed reference retained snapshot %q", newSnapshot)
|
||||
}
|
||||
for _, path := range []string{oldSnapshot, newSnapshot} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("snapshot %q is unavailable: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
setNotariusIntegrationTranscript(t, runManifest, fixture.transcriptPath, notariusIntegrationTranscript("session-unrelated", "The wizard studies Magic Missile."))
|
||||
unrelated := runNotariusIntegrationExtraction(t, env, runManifest, "20260916T010104Z-a0000004", false)
|
||||
if unrelated.ChunkPlan == nil || unrelated.ChunkPlan.Action != "generated" {
|
||||
t.Fatalf("unrelated chunk plan = %#v, want generated", unrelated.ChunkPlan)
|
||||
}
|
||||
unrelatedPlan := filepath.Join(fixture.chunkRoot, strings.TrimPrefix(unrelated.ChunkPlan.SourceDigest, "sha256:"), "plan.json")
|
||||
unrelatedBefore, err := os.ReadFile(unrelatedPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("read unrelated chunk plan: %v", err)
|
||||
}
|
||||
|
||||
setNotariusIntegrationTranscript(t, runManifest, fixture.transcriptPath, notariusIntegrationTranscript("session-cache", "Aria casts Cure Wounds."))
|
||||
forced := runNotariusIntegrationExtraction(t, env, runManifest, "20260916T010105Z-a0000005", true)
|
||||
if got := provider.Calls(); got != 8 {
|
||||
t.Fatalf("forced provider calls = %d, want 8", got)
|
||||
}
|
||||
if len(forced.CheckpointDecisions) != 0 {
|
||||
t.Fatalf("forced extraction loaded checkpoint state: %#v", forced.CheckpointDecisions)
|
||||
}
|
||||
if forced.ChunkPlan == nil || forced.ChunkPlan.Action != "refreshed" {
|
||||
t.Fatalf("forced chunk plan = %#v, want refreshed", forced.ChunkPlan)
|
||||
}
|
||||
unrelatedAfter, err := os.ReadFile(unrelatedPlan)
|
||||
if err != nil {
|
||||
t.Fatalf("read unrelated chunk plan after refresh: %v", err)
|
||||
}
|
||||
if string(unrelatedAfter) != string(unrelatedBefore) {
|
||||
t.Fatal("forced refresh changed the unrelated input's cached plan")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partial failure resumes accepted predecessor", func(t *testing.T) {
|
||||
provider := newNotariusIntegrationProvider(t)
|
||||
provider.FailRequest(2)
|
||||
env, runManifest, _ := newNotariusIntegrationFixture(t, binary, provider.URL(), "dnd-recovery")
|
||||
env.Config.Pipeline.Notarius.Resume = true
|
||||
|
||||
runManifest.RunID = "20260916T020101Z-b0000001"
|
||||
env.Force = false
|
||||
if result, err := (extractStage{}).Run(context.Background(), env, runManifest); err == nil || result != nil {
|
||||
t.Fatalf("partial extraction result = %#v error = %v, want failure", result, err)
|
||||
}
|
||||
if got := provider.Calls(); got != 2 {
|
||||
t.Fatalf("failed invocation provider calls = %d, want 2", got)
|
||||
}
|
||||
|
||||
provider.FailRequest(0)
|
||||
recovered := runNotariusIntegrationExtraction(t, env, runManifest, "20260916T020102Z-b0000002", false)
|
||||
if got := provider.Calls(); got != 3 {
|
||||
t.Fatalf("recovery provider calls = %d, want one additional call (3 total)", got)
|
||||
}
|
||||
assertNotariusIntegrationDecision(t, recovered, "extract", "spells-first", "reused", "checkpoint_reused")
|
||||
assertNotariusIntegrationDecision(t, recovered, "extract", "spells-second", "executed", "")
|
||||
})
|
||||
}
|
||||
|
||||
type notariusIntegrationProvider struct {
|
||||
server *httptest.Server
|
||||
calls atomic.Int64
|
||||
failAt atomic.Int64
|
||||
}
|
||||
|
||||
func newNotariusIntegrationProvider(t *testing.T) *notariusIntegrationProvider {
|
||||
t.Helper()
|
||||
provider := ¬ariusIntegrationProvider{}
|
||||
provider.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
call := provider.calls.Add(1)
|
||||
if request.Method != http.MethodPost || request.URL.Path != "/v1/chat/completions" {
|
||||
t.Errorf("provider request = %s %s, want POST /v1/chat/completions", request.Method, request.URL.Path)
|
||||
http.Error(w, "unexpected request", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if provider.failAt.Load() == call {
|
||||
http.Error(w, "controlled provider failure", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"local-completion","object":"chat.completion","created":1,"model":"local-model","choices":[{"index":0,"message":{"role":"assistant","content":"{\"spell_casts\":[]}"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}}`))
|
||||
}))
|
||||
t.Cleanup(provider.server.Close)
|
||||
return provider
|
||||
}
|
||||
|
||||
func (p *notariusIntegrationProvider) URL() string { return p.server.URL }
|
||||
func (p *notariusIntegrationProvider) Calls() int64 { return p.calls.Load() }
|
||||
func (p *notariusIntegrationProvider) FailRequest(request int64) { p.failAt.Store(request) }
|
||||
|
||||
type notariusIntegrationFixture struct {
|
||||
transcriptPath string
|
||||
partyPath string
|
||||
chunkRoot string
|
||||
}
|
||||
|
||||
func newNotariusIntegrationFixture(t *testing.T, binary, endpoint, pipelineID string) (*Env, *manifest.Manifest, notariusIntegrationFixture) {
|
||||
t.Helper()
|
||||
env, runManifest, _ := setupExtractEnv(t)
|
||||
initialOutput := artifacts.SessionRunNotariusOutputRootForCampaign(
|
||||
env.Config.Pipeline.Workspace.Root, runManifest.Campaign, runManifest.SessionID, runManifest.RunID,
|
||||
)
|
||||
if err := os.RemoveAll(initialOutput); err != nil {
|
||||
t.Fatalf("remove synthetic output fixture: %v", err)
|
||||
}
|
||||
|
||||
root := env.Config.Pipeline.Workspace.Root
|
||||
profilePath := filepath.Join(root, "local-profile.yml")
|
||||
if err := os.WriteFile(profilePath, []byte("id: local-test\nbackend: local\nmodel: local-model\ntimeout_seconds: 30\n"), 0o600); err != nil {
|
||||
t.Fatalf("write local profile: %v", err)
|
||||
}
|
||||
chunkRoot := filepath.Join(root, "notarius-cache", "chunk-plans")
|
||||
checkpointRoot := filepath.Join(root, "notarius-cache", "checkpoints")
|
||||
configPath := filepath.Join(root, "notarius-integration.yml")
|
||||
content := fmt.Sprintf(`version: 5
|
||||
promptkit:
|
||||
profile_file: %q
|
||||
local_backend:
|
||||
endpoint: %q
|
||||
concurrency_limit: 1
|
||||
concurrency:
|
||||
total_llm: 1
|
||||
stage_workers:
|
||||
extract: 1
|
||||
output:
|
||||
directory: %q
|
||||
cache:
|
||||
chunk_plans:
|
||||
mode: auto
|
||||
directory: %q
|
||||
checkpoints:
|
||||
enabled: true
|
||||
directory: %q
|
||||
pipelines:
|
||||
%s:
|
||||
llm_profile: local-test
|
||||
input:
|
||||
module: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
output:
|
||||
module: json
|
||||
steps:
|
||||
- id: collect-first
|
||||
artifacts:
|
||||
spells-first:
|
||||
extract:
|
||||
module: dnd/spells
|
||||
normalize:
|
||||
module: dnd/spells
|
||||
- id: collect-second
|
||||
artifacts:
|
||||
spells-second:
|
||||
extract:
|
||||
module: dnd/spells
|
||||
normalize:
|
||||
module: dnd/spells
|
||||
`, profilePath, endpoint+"/v1", filepath.Join(root, "unused-output"), chunkRoot, checkpointRoot, pipelineID)
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write Notarius integration config: %v", err)
|
||||
}
|
||||
|
||||
transcriptPath := filepath.Join(sessionPathsForEnv(env, runManifest.SessionID).ArtifactsDir, "trimmed.from-manifest.json")
|
||||
setNotariusIntegrationTranscript(t, runManifest, transcriptPath, notariusIntegrationTranscript("session-cache", "Aria casts Cure Wounds."))
|
||||
partyPath := recordPreparedExtractInput(t, env, runManifest, artifactpolicy.SourceInputParty, "The party includes Aria the cleric.\n")
|
||||
env.Notarius = notarius.NewSubprocessRunner()
|
||||
env.Config.Pipeline.Notarius = &config.NotariusConfig{
|
||||
Enabled: true, Binary: binary, ConfigPath: configPath, PipelineID: pipelineID,
|
||||
Timeout: "2m", WorkingDirectory: root, ChunkCache: "auto",
|
||||
References: map[string]string{"party": artifactpolicy.SourceInputParty},
|
||||
Outputs: map[string]config.NotariusOutputConfig{
|
||||
"spells_first": {
|
||||
LaneID: "spells-first", MediaType: "application/json", ModuleKey: "dnd/spells",
|
||||
SchemaID: "notarius.dnd.spells", SchemaVersion: "v1",
|
||||
},
|
||||
"spells_second": {
|
||||
LaneID: "spells-second", MediaType: "application/json", ModuleKey: "dnd/spells",
|
||||
SchemaID: "notarius.dnd.spells", SchemaVersion: "v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
return env, runManifest, notariusIntegrationFixture{transcriptPath: transcriptPath, partyPath: partyPath, chunkRoot: chunkRoot}
|
||||
}
|
||||
|
||||
func notariusIntegrationTranscript(id, text string) []byte {
|
||||
return []byte(fmt.Sprintf(`{"metadata":{"id":%q,"title":"Cache integration"},"segments":[{"id":1,"start":0,"end":4,"speaker":"Aria","text":%q}]}`, id, text))
|
||||
}
|
||||
|
||||
func setNotariusIntegrationTranscript(t *testing.T, runManifest *manifest.Manifest, path string, content []byte) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
t.Fatalf("write integration transcript: %v", err)
|
||||
}
|
||||
checksum, err := artifacts.SHA256File(path)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum integration transcript: %v", err)
|
||||
}
|
||||
record := runManifest.Stages["trim"]
|
||||
if record == nil || len(record.Outputs) != 1 {
|
||||
t.Fatalf("trim record = %#v, want one output", record)
|
||||
}
|
||||
record.Outputs[0].Kind = artifactmodel.TranscriptOutputKindFinalTrimmed
|
||||
record.Outputs[0].SourceID = artifactmodel.SourceTranscriptFinalTrimmed
|
||||
record.Outputs[0].LocalPath = path
|
||||
record.Outputs[0].Checksum = checksum
|
||||
}
|
||||
|
||||
func setNotariusIntegrationReference(t *testing.T, runManifest *manifest.Manifest, path, content string) {
|
||||
t.Helper()
|
||||
descriptor, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty)
|
||||
if !ok {
|
||||
t.Fatal("prepared party input descriptor is unavailable")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write integration reference: %v", err)
|
||||
}
|
||||
checksum, err := artifacts.SHA256File(path)
|
||||
if err != nil {
|
||||
t.Fatalf("checksum integration reference: %v", err)
|
||||
}
|
||||
for index := range runManifest.Inputs {
|
||||
if runManifest.Inputs[index].Kind == descriptor.ManifestKind {
|
||||
runManifest.Inputs[index].Checksum = checksum
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("prepared party input not found in %#v", runManifest.Inputs)
|
||||
}
|
||||
|
||||
func notariusIntegrationSnapshotPath(t *testing.T, env *Env, runManifest *manifest.Manifest, sourceID string) string {
|
||||
t.Helper()
|
||||
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
|
||||
if !ok {
|
||||
t.Fatalf("prepared input descriptor %q is unavailable", sourceID)
|
||||
}
|
||||
for _, input := range runManifest.Inputs {
|
||||
if input.Kind != descriptor.ManifestKind {
|
||||
continue
|
||||
}
|
||||
path, err := artifacts.SessionNotariusReferenceSnapshotPath(
|
||||
sessionPathsForEnv(env, runManifest.SessionID), input.Checksum, filepath.Base(input.Path),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve integration snapshot: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
t.Fatalf("prepared input %q not found", sourceID)
|
||||
return ""
|
||||
}
|
||||
|
||||
func runNotariusIntegrationExtraction(t *testing.T, env *Env, runManifest *manifest.Manifest, runID string, force bool) notariusIntegrationRunManifest {
|
||||
t.Helper()
|
||||
runManifest.RunID = runID
|
||||
env.Force = force
|
||||
result, err := (extractStage{}).Run(context.Background(), env, runManifest)
|
||||
if err != nil {
|
||||
receiptPath := artifacts.SessionRunNotariusReceiptPathForCampaign(env.Config.Pipeline.Workspace.Root, runManifest.Campaign, runManifest.SessionID, runID)
|
||||
if data, readErr := os.ReadFile(receiptPath); readErr == nil {
|
||||
t.Logf("failed Notarius receipt: %s", data)
|
||||
}
|
||||
t.Fatalf("extract run %q: %v", runID, err)
|
||||
}
|
||||
bundleRoot, ok := result.Metadata["bundle_root"].(string)
|
||||
if !ok || bundleRoot == "" {
|
||||
t.Fatalf("extract metadata bundle_root = %#v", result.Metadata["bundle_root"])
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(bundleRoot, "manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read Notarius manifest: %v", err)
|
||||
}
|
||||
var published notariusIntegrationRunManifest
|
||||
if err := json.Unmarshal(data, &published); err != nil {
|
||||
t.Fatalf("decode Notarius manifest: %v", err)
|
||||
}
|
||||
return published
|
||||
}
|
||||
|
||||
type notariusIntegrationRunManifest struct {
|
||||
ChunkPlan *struct {
|
||||
Action string `json:"action"`
|
||||
SourceDigest string `json:"source_digest"`
|
||||
} `json:"chunk_plan"`
|
||||
CheckpointDecisions []struct {
|
||||
Stage string `json:"stage"`
|
||||
LaneID string `json:"lane_id"`
|
||||
Category string `json:"category"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
} `json:"checkpoint_decisions"`
|
||||
}
|
||||
|
||||
func assertNotariusIntegrationDecision(t *testing.T, published notariusIntegrationRunManifest, stageName, laneID, category, reasonCode string) {
|
||||
t.Helper()
|
||||
for _, decision := range published.CheckpointDecisions {
|
||||
if decision.Stage == stageName && decision.LaneID == laneID {
|
||||
if decision.Category != category || reasonCode != "" && decision.ReasonCode != reasonCode {
|
||||
t.Fatalf("checkpoint decision for %s/%s = %#v, want category %q reason %q", stageName, laneID, decision, category, reasonCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("checkpoint decision for %s/%s not found in %#v", stageName, laneID, published.CheckpointDecisions)
|
||||
}
|
||||
@@ -324,6 +324,9 @@ func TestPublishRejectsDeclaredDirectoryBeforeUploading(t *testing.T) {
|
||||
func TestPublishDoesNotUploadUndeclaredRunFile(t *testing.T) {
|
||||
env, m, runRoot := publishFixture(t)
|
||||
writeStageTestFile(t, filepath.Join(runRoot, "unlisted.txt"), "outside of manifest\n")
|
||||
paths := artifacts.NewLocalStore(env.Config.Pipeline.Workspace.Root).SessionPathsFor(m.Campaign, m.SessionID)
|
||||
cacheFile := filepath.Join(artifacts.SessionNotariusReferenceCacheDir(paths), strings.Repeat("a", 64), "party.yml")
|
||||
writeStageTestFile(t, cacheFile, "local Notarius reference cache\n")
|
||||
|
||||
if _, err := (publishStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
@@ -333,8 +336,8 @@ func TestPublishDoesNotUploadUndeclaredRunFile(t *testing.T) {
|
||||
t.Fatal("undeclared run file was uploaded")
|
||||
}
|
||||
for _, object := range fake.Objects {
|
||||
if strings.Contains(string(object.Data), "outside of manifest") {
|
||||
t.Fatalf("undeclared bytes were uploaded in %q", object.Key)
|
||||
if strings.Contains(string(object.Data), "outside of manifest") || strings.Contains(string(object.Data), "local Notarius reference cache") {
|
||||
t.Fatalf("undeclared or cache bytes were uploaded in %q", object.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,18 @@ const (
|
||||
)
|
||||
|
||||
type extractSemanticConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
References []extractSemanticReference `json:"references"`
|
||||
Outputs []extractSemanticOutput `json:"outputs"`
|
||||
Enabled bool `json:"enabled"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
ConfigProfile string `json:"config_profile,omitempty"`
|
||||
References []extractSemanticReference `json:"references"`
|
||||
Outputs []extractSemanticOutput `json:"outputs"`
|
||||
}
|
||||
|
||||
func notariusConfigProfileReuseIdentity(profile *string) string {
|
||||
if profile == nil || *profile == "" || *profile == "default" {
|
||||
return ""
|
||||
}
|
||||
return *profile
|
||||
}
|
||||
|
||||
type extractSemanticReference struct {
|
||||
@@ -77,6 +85,7 @@ func (extractStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfig
|
||||
}
|
||||
payload.Enabled = true
|
||||
payload.PipelineID = strings.TrimSpace(cfg.PipelineID)
|
||||
payload.ConfigProfile = notariusConfigProfileReuseIdentity(cfg.ConfigProfile)
|
||||
for rawSelector, rawSourceID := range cfg.References {
|
||||
selector, err := notariusref.NormalizeSelector(rawSelector)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,11 +2,14 @@ package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func testSecretReference(name string) *configkit.Secret {
|
||||
@@ -74,6 +77,94 @@ func TestExtractSemanticConfigSensitivityAndOperationalExclusions(t *testing.T)
|
||||
if !want.Equal(got) {
|
||||
t.Fatalf("operational settings changed fingerprint: got %#v want %#v", got, want)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*config.NotariusConfig)
|
||||
}{
|
||||
{name: "resume", mutate: func(cfg *config.NotariusConfig) { cfg.Resume = true }},
|
||||
{name: "chunk cache", mutate: func(cfg *config.NotariusConfig) { cfg.ChunkCache = "refresh" }},
|
||||
} {
|
||||
t.Run("operational "+test.name, func(t *testing.T) {
|
||||
env := downstreamSemanticEnv()
|
||||
test.mutate(env.Config.Pipeline.Notarius)
|
||||
got, err := (extractStage{}).SemanticConfigFingerprint(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !want.Equal(got) {
|
||||
t.Fatalf("%s changed semantic fingerprint: got %#v want %#v", test.name, got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
profilePointer := func(value string) *string { return &value }
|
||||
baseFingerprints := make([]manifest.SemanticConfigFingerprint, 0, 3)
|
||||
for _, profile := range []*string{nil, profilePointer(""), profilePointer("default")} {
|
||||
env := downstreamSemanticEnv()
|
||||
env.Config.Pipeline.Notarius.ConfigProfile = profile
|
||||
fingerprint, err := (extractStage{}).SemanticConfigFingerprint(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseFingerprints = append(baseFingerprints, fingerprint)
|
||||
}
|
||||
for index := 1; index < len(baseFingerprints); index++ {
|
||||
if !baseFingerprints[0].Equal(baseFingerprints[index]) {
|
||||
t.Fatal("nil, empty, and default Notarius selections must share base reuse evidence")
|
||||
}
|
||||
}
|
||||
profiled := downstreamSemanticEnv()
|
||||
profiled.Config.Pipeline.Notarius.ConfigProfile = profilePointer("inexpensive")
|
||||
firstProfile, err := (extractStage{}).SemanticConfigFingerprint(profiled)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if baseFingerprints[0].Equal(firstProfile) {
|
||||
t.Fatal("base to named Notarius selection retained semantic fingerprint")
|
||||
}
|
||||
unchangedProfile, err := (extractStage{}).SemanticConfigFingerprint(profiled)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !firstProfile.Equal(unchangedProfile) {
|
||||
t.Fatal("unchanged Notarius selection changed semantic fingerprint")
|
||||
}
|
||||
profiled.Config.Pipeline.Notarius.ConfigProfile = profilePointer("experimental")
|
||||
secondProfile, err := (extractStage{}).SemanticConfigFingerprint(profiled)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstProfile.Equal(secondProfile) {
|
||||
t.Fatal("named to different named Notarius selection retained semantic fingerprint")
|
||||
}
|
||||
profiled.Config.Pipeline.Notarius.ConfigProfile = nil
|
||||
returnedToBase, err := (extractStage{}).SemanticConfigFingerprint(profiled)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !baseFingerprints[0].Equal(returnedToBase) {
|
||||
t.Fatal("named to base Notarius selection did not restore base semantic evidence")
|
||||
}
|
||||
if secondProfile.Equal(returnedToBase) {
|
||||
t.Fatal("named to base Notarius selection retained named semantic evidence")
|
||||
}
|
||||
disabledBase := downstreamSemanticEnv()
|
||||
disabledBase.Config.Pipeline.Notarius.Enabled = false
|
||||
disabledNamed := downstreamSemanticEnv()
|
||||
disabledNamed.Config.Pipeline.Notarius.Enabled = false
|
||||
disabledNamed.Config.Pipeline.Notarius.ConfigProfile = profilePointer("ignored")
|
||||
disabledBaseFingerprint, err := (extractStage{}).SemanticConfigFingerprint(disabledBase)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabledNamedFingerprint, err := (extractStage{}).SemanticConfigFingerprint(disabledNamed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !disabledBaseFingerprint.Equal(disabledNamedFingerprint) {
|
||||
t.Fatal("disabled extraction became sensitive to unused Notarius selection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResumeValidationExcludesOperationalSettings(t *testing.T) {
|
||||
@@ -83,6 +174,8 @@ func TestExtractResumeValidationExcludesOperationalSettings(t *testing.T) {
|
||||
env.Config.Pipeline.Notarius.ConfigPath = "/not/present/private.yml"
|
||||
env.Config.Pipeline.Notarius.Timeout = "not-a-duration"
|
||||
env.Config.Pipeline.Notarius.WorkingDirectory = "/not/present/work"
|
||||
env.Config.Pipeline.Notarius.Resume = true
|
||||
env.Config.Pipeline.Notarius.ChunkCache = "bypass"
|
||||
|
||||
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
@@ -93,6 +186,55 @@ func TestExtractResumeValidationExcludesOperationalSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSemanticConfigExcludesLoadedCredentialChanges(t *testing.T) {
|
||||
const firstName = "NARRATIO_SEMANTIC_NOTARIUS_KEY"
|
||||
const secondName = "NARRATIO_SEMANTIC_NOTARIUS_KEY_TWO"
|
||||
first := loadNotariusCredentialForSemanticTest(t, firstName, "first-value")
|
||||
rotated := loadNotariusCredentialForSemanticTest(t, firstName, "rotated-value")
|
||||
renamed := loadNotariusCredentialForSemanticTest(t, secondName, "second-value")
|
||||
|
||||
baseline := downstreamSemanticEnv()
|
||||
baseline.Config.Pipeline.Notarius.LLMAPIKey = first
|
||||
want, err := (extractStage{}).SemanticConfigFingerprint(baseline)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
credential *configkit.Secret
|
||||
}{
|
||||
{name: "removed", credential: nil},
|
||||
{name: "renamed", credential: renamed},
|
||||
{name: "value rotated", credential: rotated},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env := downstreamSemanticEnv()
|
||||
env.Config.Pipeline.Notarius.LLMAPIKey = test.credential
|
||||
got, err := (extractStage{}).SemanticConfigFingerprint(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !want.Equal(got) {
|
||||
t.Fatalf("credential-only change altered extract semantic fingerprint")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func loadNotariusCredentialForSemanticTest(t *testing.T, name, value string) *configkit.Secret {
|
||||
t.Helper()
|
||||
t.Setenv(name, value)
|
||||
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||
if err := os.WriteFile(path, []byte("whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+name+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pipeline, err := config.LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pipeline.Notarius.LLMAPIKey
|
||||
}
|
||||
|
||||
func TestPublishSemanticConfigSensitivityAndOperationalExclusions(t *testing.T) {
|
||||
want, err := (publishStage{}).SemanticConfigFingerprint(downstreamSemanticEnv())
|
||||
if err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
@@ -28,11 +27,14 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
||||
if env.ArtifactStore == nil {
|
||||
return nil, fmt.Errorf("transcribe: artifact store is required")
|
||||
}
|
||||
if env.ManifestStore == nil {
|
||||
return nil, fmt.Errorf("transcribe: manifest store is required")
|
||||
}
|
||||
if env.Config.Pipeline == nil || env.Config.Session == nil {
|
||||
return nil, fmt.Errorf("transcribe: resolved config must include pipeline and session")
|
||||
}
|
||||
if env.WhisperX == nil {
|
||||
return nil, fmt.Errorf("transcribe: whisperx adapter is required")
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("transcribe: session manifest is required")
|
||||
}
|
||||
|
||||
var sessionID string
|
||||
@@ -47,59 +49,40 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
||||
}
|
||||
|
||||
paths := sessionPathsForEnv(env, sessionID)
|
||||
runLayout, err := resolveRunStageLayout(env, m, paths, sessionID, "transcribe")
|
||||
reconciliation, err := reconcileTranscribeSpeakers(ctx, env, m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transcribe: resolve run-stage layout: %w", err)
|
||||
return nil, fmt.Errorf("transcribe: reconcile speakers: %w", err)
|
||||
}
|
||||
audioFiles, err := discoverPreparedAudio(m, paths.AudioDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transcribe: resolve audio inputs: %w", err)
|
||||
state := newTranscribeExecutionState(env, m, paths, reconciliation.current)
|
||||
if err := state.checkpointStartingCollection(ctx); err != nil {
|
||||
return nil, fmt.Errorf("transcribe: checkpoint reconciled speaker collection before execution: %w", err)
|
||||
}
|
||||
if len(audioFiles) == 0 {
|
||||
return nil, fmt.Errorf("transcribe: no prepared audio files found")
|
||||
|
||||
jobs := append([]transcribeReconciliationJob(nil), reconciliation.work...)
|
||||
if len(jobs) > 0 && env.WhisperX == nil {
|
||||
return nil, fmt.Errorf("transcribe: whisperx adapter is required")
|
||||
}
|
||||
if len(jobs) > 0 {
|
||||
runLayout, err := resolveRunStageLayout(env, m, paths, sessionID, "transcribe")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transcribe: resolve run-stage layout: %w", err)
|
||||
}
|
||||
for index := range jobs {
|
||||
jobs[index].runOutputPath, err = runLocalPathForCanonical(runLayout, paths, jobs[index].canonicalOutputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transcribe: resolve run-local output path for %q: %w", jobs[index].speakerID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
concurrency := 1
|
||||
if env.Config.Pipeline.WhisperX.Concurrency != nil && *env.Config.Pipeline.WhisperX.Concurrency > 0 {
|
||||
concurrency = *env.Config.Pipeline.WhisperX.Concurrency
|
||||
}
|
||||
if concurrency > len(audioFiles) {
|
||||
concurrency = len(audioFiles)
|
||||
if len(jobs) > 0 && concurrency > len(jobs) {
|
||||
concurrency = len(jobs)
|
||||
}
|
||||
|
||||
type job struct {
|
||||
speakerID string
|
||||
audioPath string
|
||||
outPath string
|
||||
}
|
||||
jobs := make([]job, 0, len(audioFiles))
|
||||
seenSpeaker := map[string]string{}
|
||||
for _, audioPath := range audioFiles {
|
||||
base := strings.TrimSuffix(filepath.Base(audioPath), filepath.Ext(audioPath))
|
||||
if base == "" {
|
||||
return nil, fmt.Errorf("transcribe: could not derive speaker id from %q", audioPath)
|
||||
}
|
||||
if prev, ok := seenSpeaker[base]; ok {
|
||||
return nil, fmt.Errorf("transcribe: duplicate speaker/audio basename %q from %q and %q", base, prev, audioPath)
|
||||
}
|
||||
seenSpeaker[base] = audioPath
|
||||
canonicalOut := filepath.Join(paths.TranscriptsRawDir, base+".json")
|
||||
runOut, err := runLocalPathForCanonical(runLayout, paths, canonicalOut)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transcribe: resolve run-local output path for %q: %w", base, err)
|
||||
}
|
||||
jobs = append(jobs, job{
|
||||
speakerID: base,
|
||||
audioPath: audioPath,
|
||||
outPath: runOut,
|
||||
})
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
completed int
|
||||
perFile = map[string]map[string]any{}
|
||||
)
|
||||
group, groupCtx := errgroup.WithContext(ctx)
|
||||
group.SetLimit(concurrency)
|
||||
for _, job := range jobs {
|
||||
@@ -108,40 +91,36 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
||||
if err := groupCtx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := state.checkpointFailure(); err != nil {
|
||||
return err
|
||||
}
|
||||
res, runErr := env.WhisperX.Transcribe(groupCtx, whisperx.TranscribeRequest{
|
||||
SpeakerID: job.speakerID,
|
||||
AudioPath: job.audioPath,
|
||||
OutputRawTranscriptPath: job.outPath,
|
||||
OutputRawTranscriptPath: job.runOutputPath,
|
||||
})
|
||||
if runErr != nil {
|
||||
return fmt.Errorf("speaker %q (%s): %w", job.speakerID, filepath.Base(job.audioPath), runErr)
|
||||
}
|
||||
|
||||
if _, err := authoritativeOutputPath(job.outPath, res.OutputRawTranscriptPath); err != nil {
|
||||
return fmt.Errorf("speaker %q: %w", job.speakerID, err)
|
||||
}
|
||||
if err := validateTranscriptJSONFile(job.outPath); err != nil {
|
||||
return fmt.Errorf("speaker %q output %q invalid: %w", job.speakerID, job.outPath, err)
|
||||
}
|
||||
|
||||
meta := map[string]any{
|
||||
"attempts": res.Attempts,
|
||||
"http_status": res.HTTPStatus,
|
||||
"duration_ms": res.Duration.Milliseconds(),
|
||||
"output_path": job.outPath,
|
||||
"output_path": job.runOutputPath,
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
perFile[job.speakerID] = meta
|
||||
completed++
|
||||
mu.Unlock()
|
||||
if err := state.acceptSpeaker(ctx, job, res.OutputRawTranscriptPath, meta); err != nil {
|
||||
return fmt.Errorf("speaker %q: %w", job.speakerID, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
groupErr := group.Wait()
|
||||
mu.Lock()
|
||||
completedCount := completed
|
||||
mu.Unlock()
|
||||
if err := state.checkpointFailure(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
completedCount := state.completedCount()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("transcribe: canceled after completing %d of %d audio jobs: %w", completedCount, len(jobs), err)
|
||||
}
|
||||
@@ -151,35 +130,9 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
||||
if completedCount != len(jobs) {
|
||||
return nil, fmt.Errorf("transcribe: completed %d of %d planned audio jobs", completedCount, len(jobs))
|
||||
}
|
||||
|
||||
speakers := make([]string, 0, len(perFile))
|
||||
for speaker := range perFile {
|
||||
speakers = append(speakers, speaker)
|
||||
}
|
||||
sort.Strings(speakers)
|
||||
|
||||
outputs := make([]artifacts.Ref, 0, len(speakers))
|
||||
runOutputPaths := make([]string, 0, len(speakers))
|
||||
outputPaths := make([]string, 0, len(speakers))
|
||||
orderedPerFile := make(map[string]any, len(speakers))
|
||||
for _, speaker := range speakers {
|
||||
var runOut string
|
||||
for _, job := range jobs {
|
||||
if job.speakerID == speaker {
|
||||
runOut = job.outPath
|
||||
break
|
||||
}
|
||||
}
|
||||
runOutputPaths = append(runOutputPaths, runOut)
|
||||
canonicalOut := filepath.Join(paths.TranscriptsRawDir, speaker+".json")
|
||||
ref := artifacts.Ref{Kind: "transcript_raw", Category: "transcripts", SessionID: sessionID, AbsolutePath: runOut}
|
||||
materialized, err := materializeRunLocalOutput(env.ArtifactStore, runOut, canonicalOut, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transcribe: materialize %q output: %w", speaker, err)
|
||||
}
|
||||
outputs = append(outputs, materialized)
|
||||
outputPaths = append(outputPaths, canonicalOut)
|
||||
orderedPerFile[speaker] = perFile[speaker]
|
||||
outputs, outputPaths, runOutputPaths, orderedPerFile, err := state.successDetails()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("transcribe: assemble completed outputs: %w", err)
|
||||
}
|
||||
|
||||
retries := 0
|
||||
@@ -191,7 +144,7 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
||||
Outputs: outputs,
|
||||
Metadata: map[string]any{
|
||||
"stage": "transcribe",
|
||||
"audio_files_count": len(audioFiles),
|
||||
"audio_files_count": len(reconciliation.jobs),
|
||||
"language": env.Config.Pipeline.WhisperX.Language,
|
||||
"concurrency": concurrency,
|
||||
"retries": retries,
|
||||
@@ -204,7 +157,39 @@ func (transcribeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func discoverPreparedAudio(m *manifest.Manifest, audioDir string) ([]string, error) {
|
||||
type transcribeJob struct {
|
||||
speakerID string
|
||||
audioPath string
|
||||
canonicalOutputPath string
|
||||
runOutputPath string
|
||||
}
|
||||
|
||||
func buildTranscribeJobs(m *manifest.Manifest, paths artifacts.SessionPaths) ([]transcribeJob, error) {
|
||||
audioFiles, err := discoverPreparedAudio(m, paths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs := make([]transcribeJob, 0, len(audioFiles))
|
||||
seenSpeaker := map[string]string{}
|
||||
for _, audioPath := range audioFiles {
|
||||
base := strings.TrimSuffix(filepath.Base(audioPath), filepath.Ext(audioPath))
|
||||
if base == "" {
|
||||
return nil, fmt.Errorf("could not derive speaker id from %q", audioPath)
|
||||
}
|
||||
if previous, exists := seenSpeaker[base]; exists {
|
||||
return nil, fmt.Errorf("duplicate speaker/audio basename %q from %q and %q", base, previous, audioPath)
|
||||
}
|
||||
seenSpeaker[base] = audioPath
|
||||
jobs = append(jobs, transcribeJob{
|
||||
speakerID: base,
|
||||
audioPath: audioPath,
|
||||
canonicalOutputPath: filepath.Join(paths.TranscriptsRawDir, base+".json"),
|
||||
})
|
||||
}
|
||||
return jobs, nil
|
||||
}
|
||||
|
||||
func discoverPreparedAudio(m *manifest.Manifest, paths artifacts.SessionPaths) ([]string, error) {
|
||||
fromManifest := make([]string, 0)
|
||||
if m != nil {
|
||||
for _, in := range m.Inputs {
|
||||
@@ -215,7 +200,7 @@ func discoverPreparedAudio(m *manifest.Manifest, audioDir string) ([]string, err
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
fromManifest = append(fromManifest, filepath.Clean(p))
|
||||
fromManifest = append(fromManifest, artifacts.ResolveSessionLocalPathForRead(paths, p))
|
||||
}
|
||||
}
|
||||
if len(fromManifest) > 0 {
|
||||
@@ -226,9 +211,9 @@ func discoverPreparedAudio(m *manifest.Manifest, audioDir string) ([]string, err
|
||||
return fromManifest, nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(audioDir)
|
||||
entries, err := os.ReadDir(paths.AudioDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read audio directory %q: %w", audioDir, err)
|
||||
return nil, fmt.Errorf("read audio directory %q: %w", paths.AudioDir, err)
|
||||
}
|
||||
|
||||
out := make([]string, 0)
|
||||
@@ -236,7 +221,7 @@ func discoverPreparedAudio(m *manifest.Manifest, audioDir string) ([]string, err
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(audioDir, entry.Name())
|
||||
p := filepath.Join(paths.AudioDir, entry.Name())
|
||||
if !isFlac(p) {
|
||||
continue
|
||||
}
|
||||
|
||||
330
internal/stage/transcribe_checkpoint_test.go
Normal file
330
internal/stage/transcribe_checkpoint_test.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestTranscribeFailureCheckpointsEarlierSpeakersAndRetryRunsOnlyRemaining(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"a.flac", "b.flac", "c.flac", "d.flac"})
|
||||
concurrency := 1
|
||||
env.Config.Pipeline.WhisperX.Concurrency = &concurrency
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
cause := errors.New("speaker c failed")
|
||||
env.WhisperX = &whisperx.FakeClient{TranscribeFn: func(
|
||||
_ context.Context,
|
||||
req whisperx.TranscribeRequest,
|
||||
) (whisperx.TranscribeResult, error) {
|
||||
if req.SpeakerID == "c" {
|
||||
return whisperx.TranscribeResult{}, cause
|
||||
}
|
||||
if req.SpeakerID == "d" {
|
||||
t.Fatalf("speaker d was dispatched after failure")
|
||||
}
|
||||
if err := writeJSONFile(req.OutputRawTranscriptPath, map[string]any{"speaker": req.SpeakerID}); err != nil {
|
||||
return whisperx.TranscribeResult{}, err
|
||||
}
|
||||
return whisperx.TranscribeResult{OutputRawTranscriptPath: req.OutputRawTranscriptPath}, nil
|
||||
}}
|
||||
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); !errors.Is(err, cause) {
|
||||
t.Fatalf("Run() error = %v, want %v", err, cause)
|
||||
}
|
||||
durable, err := env.ManifestStore.Load(context.Background(), sessionPathsForEnv(env, m.SessionID).ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load durable manifest: %v", err)
|
||||
}
|
||||
if got := sortedTranscribeSpeakerKeys(durable.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a", "b"}) {
|
||||
t.Fatalf("durable speakers = %#v, want a and b", got)
|
||||
}
|
||||
|
||||
retry := &whisperx.FakeClient{}
|
||||
env.WhisperX = retry
|
||||
result, err := (transcribeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("retry Run() error = %v", err)
|
||||
}
|
||||
requests := retry.RequestsSnapshot()
|
||||
gotRequests := make([]string, 0, len(requests))
|
||||
for _, request := range requests {
|
||||
gotRequests = append(gotRequests, request.SpeakerID)
|
||||
}
|
||||
if !slices.Equal(gotRequests, []string{"c", "d"}) {
|
||||
t.Fatalf("retry speakers = %#v, want c and d", gotRequests)
|
||||
}
|
||||
if len(result.Outputs) != 4 {
|
||||
t.Fatalf("retry outputs = %d, want all four speakers", len(result.Outputs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeConcurrentCheckpointsDoNotLoseCompletions(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"a.flac", "b.flac", "c.flac", "d.flac"})
|
||||
concurrency := 4
|
||||
env.Config.Pipeline.WhisperX.Concurrency = &concurrency
|
||||
env.WhisperX = &whisperx.FakeClient{}
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
durable, err := env.ManifestStore.Load(context.Background(), sessionPathsForEnv(env, m.SessionID).ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load durable manifest: %v", err)
|
||||
}
|
||||
if got := sortedTranscribeSpeakerKeys(durable.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a", "b", "c", "d"}) {
|
||||
t.Fatalf("durable speakers = %#v, want all completions", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeStartingCheckpointFailurePreventsRequestsAndCanonicalWrites(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"alice.flac"})
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
cause := errors.New("starting checkpoint failed")
|
||||
base := env.ManifestStore
|
||||
env.ManifestStore = &failingTranscribeManifestStore{Store: base, FailAt: 1, Err: cause}
|
||||
fake := &whisperx.FakeClient{}
|
||||
env.WhisperX = fake
|
||||
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); !errors.Is(err, cause) {
|
||||
t.Fatalf("Run() error = %v, want %v", err, cause)
|
||||
}
|
||||
if got := len(fake.RequestsSnapshot()); got != 0 {
|
||||
t.Fatalf("adapter requests = %d, want none", got)
|
||||
}
|
||||
canonical := sessionPathsForEnv(env, m.SessionID).TranscriptsRawDir + "/alice.json"
|
||||
if _, err := os.Stat(canonical); !os.IsNotExist(err) {
|
||||
t.Fatalf("canonical output exists after starting checkpoint failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeCheckpointFailureRetainsEarlierAuthority(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"a.flac", "b.flac", "c.flac"})
|
||||
concurrency := 1
|
||||
env.Config.Pipeline.WhisperX.Concurrency = &concurrency
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
cause := errors.New("speaker checkpoint failed")
|
||||
base := env.ManifestStore
|
||||
env.ManifestStore = &failingTranscribeManifestStore{Store: base, FailAt: 3, Err: cause}
|
||||
fake := &whisperx.FakeClient{}
|
||||
env.WhisperX = fake
|
||||
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); !errors.Is(err, cause) {
|
||||
t.Fatalf("Run() error = %v, want %v", err, cause)
|
||||
}
|
||||
requests := fake.RequestsSnapshot()
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("adapter requests = %d, want a and b only", len(requests))
|
||||
}
|
||||
durable, err := base.Load(context.Background(), sessionPathsForEnv(env, m.SessionID).ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load durable manifest: %v", err)
|
||||
}
|
||||
if got := sortedTranscribeSpeakerKeys(durable.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a"}) {
|
||||
t.Fatalf("durable speakers = %#v, want only a", got)
|
||||
}
|
||||
if got := sortedTranscribeSpeakerKeys(m.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a"}) {
|
||||
t.Fatalf("live speakers = %#v, want last committed collection", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeFailedForceRetryDoesNotResurrectOldSpeakers(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"a.flac", "b.flac"})
|
||||
concurrency := 1
|
||||
env.Config.Pipeline.WhisperX.Concurrency = &concurrency
|
||||
env.WhisperX = &whisperx.FakeClient{}
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("seed Run() error = %v", err)
|
||||
}
|
||||
|
||||
m.RunID = "20260503T020304Z-b2c3d4e5"
|
||||
env.Force = true
|
||||
cause := errors.New("forced b failed")
|
||||
env.WhisperX = &whisperx.FakeClient{TranscribeFn: func(
|
||||
_ context.Context,
|
||||
req whisperx.TranscribeRequest,
|
||||
) (whisperx.TranscribeResult, error) {
|
||||
if req.SpeakerID == "b" {
|
||||
return whisperx.TranscribeResult{}, cause
|
||||
}
|
||||
if err := writeJSONFile(req.OutputRawTranscriptPath, map[string]any{"replacement": req.SpeakerID}); err != nil {
|
||||
return whisperx.TranscribeResult{}, err
|
||||
}
|
||||
return whisperx.TranscribeResult{OutputRawTranscriptPath: req.OutputRawTranscriptPath}, nil
|
||||
}}
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); !errors.Is(err, cause) {
|
||||
t.Fatalf("forced Run() error = %v, want %v", err, cause)
|
||||
}
|
||||
if got := sortedTranscribeSpeakerKeys(m.Stages["transcribe"].TranscribeSpeakers); !slices.Equal(got, []string{"a"}) {
|
||||
t.Fatalf("speakers after failed force = %#v, want only replacement a", got)
|
||||
}
|
||||
|
||||
env.Force = false
|
||||
retry := &whisperx.FakeClient{}
|
||||
env.WhisperX = retry
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("retry Run() error = %v", err)
|
||||
}
|
||||
requests := retry.RequestsSnapshot()
|
||||
if len(requests) != 1 || requests[0].SpeakerID != "b" {
|
||||
t.Fatalf("retry requests = %#v, want only b", requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRejectsAudioChangedDuringRequest(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"alice.flac"})
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
env.WhisperX = &whisperx.FakeClient{TranscribeFn: func(
|
||||
_ context.Context,
|
||||
req whisperx.TranscribeRequest,
|
||||
) (whisperx.TranscribeResult, error) {
|
||||
if err := writeJSONFile(req.OutputRawTranscriptPath, map[string]any{"speaker": req.SpeakerID}); err != nil {
|
||||
return whisperx.TranscribeResult{}, err
|
||||
}
|
||||
if err := os.WriteFile(req.AudioPath, []byte("changed"), 0o644); err != nil {
|
||||
return whisperx.TranscribeResult{}, err
|
||||
}
|
||||
return whisperx.TranscribeResult{OutputRawTranscriptPath: req.OutputRawTranscriptPath}, nil
|
||||
}}
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err == nil {
|
||||
t.Fatal("Run() error = nil, want changed-audio failure")
|
||||
}
|
||||
if got := len(m.Stages["transcribe"].TranscribeSpeakers); got != 0 {
|
||||
t.Fatalf("checkpointed speakers = %d, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeResumeValidationUsesSpeakerEvidence(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"alice.flac"})
|
||||
env.WhisperX = &whisperx.FakeClient{}
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
validation, err := (transcribeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil || validation.Resumable {
|
||||
t.Fatalf("ValidateResume() = %#v, %v, want missing aggregate repaired", validation, err)
|
||||
}
|
||||
if len(m.Stages["transcribe"].Outputs) != 0 {
|
||||
t.Fatal("resume inspection mutated aggregate outputs")
|
||||
}
|
||||
m.Stages["transcribe"].Outputs = []manifest.ArtifactRecord{m.Stages["transcribe"].TranscribeSpeakers["alice"].Output}
|
||||
validation, err = (transcribeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil || !validation.Resumable {
|
||||
t.Fatalf("ValidateResume() = %#v, %v, want resumable", validation, err)
|
||||
}
|
||||
if err := os.WriteFile(m.Inputs[0].Path, []byte("changed"), 0o644); err != nil {
|
||||
t.Fatalf("change audio: %v", err)
|
||||
}
|
||||
validation, err = (transcribeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil || validation.Resumable {
|
||||
t.Fatalf("ValidateResume() = %#v, %v, want execution required", validation, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeRebuildsAggregateFromCurrentSpeakersWithoutRequests(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"alice.flac", "bob.flac"})
|
||||
fake := &whisperx.FakeClient{}
|
||||
env.WhisperX = fake
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
seed, err := (transcribeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("seed Run() error = %v", err)
|
||||
}
|
||||
originalRunID := m.RunID
|
||||
m.MarkStageFailed("transcribe", m.UpdatedAt.Add(1), "aggregate save failed")
|
||||
m.RunID = "20260503T030405Z-c3d4e5f6"
|
||||
fake = &whisperx.FakeClient{}
|
||||
env.WhisperX = fake
|
||||
|
||||
result, err := (transcribeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("rebuild Run() error = %v", err)
|
||||
}
|
||||
if got := len(fake.RequestsSnapshot()); got != 0 {
|
||||
t.Fatalf("adapter requests = %d, want none", got)
|
||||
}
|
||||
if len(result.Outputs) != len(seed.Outputs) {
|
||||
t.Fatalf("rebuilt outputs = %d, want %d", len(result.Outputs), len(seed.Outputs))
|
||||
}
|
||||
for index, output := range result.Outputs {
|
||||
if output.ProducerRunID != originalRunID {
|
||||
t.Fatalf("output %d producer = %q, want %q", index, output.ProducerRunID, originalRunID)
|
||||
}
|
||||
if output.Checksum != seed.Outputs[index].Checksum {
|
||||
t.Fatalf("output %d checksum = %q, want %q", index, output.Checksum, seed.Outputs[index].Checksum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeReuseDoesNotDependOnRunDirectory(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"alice.flac"})
|
||||
env.WhisperX = &whisperx.FakeClient{}
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
if _, err := (transcribeStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
m.Stages["transcribe"].Outputs = []manifest.ArtifactRecord{m.Stages["transcribe"].TranscribeSpeakers["alice"].Output}
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
if err := os.RemoveAll(paths.RunsDir); err != nil {
|
||||
t.Fatalf("remove run directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(paths.TranscriptsRawDir + "/alice.json"); err != nil {
|
||||
t.Fatalf("canonical transcript missing after run cleanup: %v", err)
|
||||
}
|
||||
validation, err := (transcribeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil || !validation.Resumable {
|
||||
t.Fatalf("ValidateResume() = %#v, %v, want canonical reuse", validation, err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingTranscribeManifestStore struct {
|
||||
manifest.Store
|
||||
Saves atomic.Int64
|
||||
FailAt int64
|
||||
Err error
|
||||
}
|
||||
|
||||
func (store *failingTranscribeManifestStore) Save(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
m *manifest.Manifest,
|
||||
) error {
|
||||
if store.Saves.Add(1) == store.FailAt {
|
||||
return store.Err
|
||||
}
|
||||
return store.Store.Save(ctx, path, m)
|
||||
}
|
||||
|
||||
func sortedTranscribeSpeakerKeys(records map[string]manifest.TranscribeSpeakerRecord) []string {
|
||||
keys := make([]string, 0, len(records))
|
||||
for key := range records {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
return keys
|
||||
}
|
||||
292
internal/stage/transcribe_execution_state.go
Normal file
292
internal/stage/transcribe_execution_state.go
Normal file
@@ -0,0 +1,292 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
type transcribeExecutionState struct {
|
||||
env *Env
|
||||
manifest *manifest.Manifest
|
||||
paths artifacts.SessionPaths
|
||||
|
||||
mu sync.Mutex
|
||||
records map[string]manifest.TranscribeSpeakerRecord
|
||||
checkpointErr error
|
||||
completed int
|
||||
perFile map[string]map[string]any
|
||||
runOutputs map[string]string
|
||||
}
|
||||
|
||||
func newTranscribeExecutionState(
|
||||
env *Env,
|
||||
m *manifest.Manifest,
|
||||
paths artifacts.SessionPaths,
|
||||
current map[string]manifest.TranscribeSpeakerRecord,
|
||||
) *transcribeExecutionState {
|
||||
return &transcribeExecutionState{
|
||||
env: env,
|
||||
manifest: m,
|
||||
paths: paths,
|
||||
records: manifest.CloneTranscribeSpeakerCollection(current),
|
||||
perFile: make(map[string]map[string]any),
|
||||
runOutputs: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (state *transcribeExecutionState) checkpointStartingCollection(ctx context.Context) error {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if record := transcribeStageRecord(state.manifest); record != nil &&
|
||||
record.TranscribeStateVersion == manifest.TranscribeStateContractVersion &&
|
||||
reflect.DeepEqual(record.TranscribeSpeakers, state.records) {
|
||||
return nil
|
||||
}
|
||||
if err := state.checkpointCollectionLocked(ctx, state.records); err != nil {
|
||||
state.checkpointErr = err
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *transcribeExecutionState) acceptSpeaker(
|
||||
ctx context.Context,
|
||||
job transcribeReconciliationJob,
|
||||
adapterOutputPath string,
|
||||
metadata map[string]any,
|
||||
) error {
|
||||
if _, err := authoritativeOutputPath(job.runOutputPath, adapterOutputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTranscriptJSONFile(job.runOutputPath); err != nil {
|
||||
return fmt.Errorf("output %q invalid: %w", job.runOutputPath, err)
|
||||
}
|
||||
audioSHA256, audioSize, err := hashRegularFileWithContext(ctx, job.audioPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recheck audio %q: %w", job.audioPath, err)
|
||||
}
|
||||
if audioSHA256 != job.audioSHA256 || audioSize != job.audioSize {
|
||||
return fmt.Errorf("audio %q changed during transcription", job.audioPath)
|
||||
}
|
||||
if err := state.checkpointFailure(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
producerRunID := strings.TrimSpace(state.manifest.RunID)
|
||||
ref := artifacts.Ref{
|
||||
Kind: "transcript_raw",
|
||||
Category: "transcripts",
|
||||
SessionID: state.manifest.SessionID,
|
||||
AbsolutePath: job.runOutputPath,
|
||||
ProducerRunID: producerRunID,
|
||||
}
|
||||
materialized, err := materializeRunLocalOutput(
|
||||
state.env.ArtifactStore,
|
||||
job.runOutputPath,
|
||||
job.canonicalOutputPath,
|
||||
ref,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("materialize output: %w", err)
|
||||
}
|
||||
outputSHA256, outputSize, err := hashRegularFileWithContext(ctx, job.canonicalOutputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect materialized output %q: %w", job.canonicalOutputPath, err)
|
||||
}
|
||||
if materialized.Checksum != outputSHA256 {
|
||||
return fmt.Errorf("materialized output checksum changed while inspecting")
|
||||
}
|
||||
record := manifest.TranscribeSpeakerRecord{
|
||||
FingerprintVersion: manifest.TranscribeFingerprintContractVersion,
|
||||
Fingerprint: job.fingerprint,
|
||||
AudioPath: job.audioRelativePath,
|
||||
AudioSHA256: job.audioSHA256,
|
||||
AudioSize: job.audioSize,
|
||||
Output: manifest.ArtifactRecord{
|
||||
Kind: "transcript_raw",
|
||||
LocalPath: job.outputRelativePath,
|
||||
ArchivePath: materialized.ArchivePath,
|
||||
ProducerRunID: producerRunID,
|
||||
Checksum: outputSHA256,
|
||||
},
|
||||
OutputSize: outputSize,
|
||||
ProducerRunID: producerRunID,
|
||||
}
|
||||
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.checkpointErr != nil {
|
||||
return state.checkpointErr
|
||||
}
|
||||
candidate := manifest.CloneTranscribeSpeakerCollection(state.records)
|
||||
candidate[job.speakerID] = record
|
||||
if err := state.checkpointCollectionLocked(ctx, candidate); err != nil {
|
||||
state.checkpointErr = fmt.Errorf("transcribe: checkpoint speaker %q: %w", job.speakerID, err)
|
||||
return state.checkpointErr
|
||||
}
|
||||
state.completed++
|
||||
state.perFile[job.speakerID] = cloneStringAnyMap(metadata)
|
||||
state.runOutputs[job.speakerID] = job.runOutputPath
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *transcribeExecutionState) checkpointCollectionLocked(
|
||||
ctx context.Context,
|
||||
records map[string]manifest.TranscribeSpeakerRecord,
|
||||
) error {
|
||||
if state.manifest == nil {
|
||||
return fmt.Errorf("session manifest is required")
|
||||
}
|
||||
if state.env == nil || state.env.ManifestStore == nil {
|
||||
return fmt.Errorf("manifest store is required")
|
||||
}
|
||||
if err := manifest.ValidateTranscribeSpeakerCollection(
|
||||
manifest.TranscribeStateContractVersion,
|
||||
records,
|
||||
); err != nil {
|
||||
return fmt.Errorf("validate transcribe speaker collection: %w", err)
|
||||
}
|
||||
|
||||
candidate := cloneManifestForTranscribeCheckpoint(state.manifest)
|
||||
if candidate.Stages == nil {
|
||||
candidate.Stages = make(map[string]*manifest.StageRecord)
|
||||
}
|
||||
record := candidate.Stages["transcribe"]
|
||||
if record == nil {
|
||||
now := time.Now().UTC()
|
||||
record = &manifest.StageRecord{
|
||||
Name: "transcribe", Status: manifest.StatusRunning, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
candidate.Stages["transcribe"] = record
|
||||
}
|
||||
record.TranscribeStateVersion = manifest.TranscribeStateContractVersion
|
||||
record.TranscribeSpeakers = manifest.CloneTranscribeSpeakerCollection(records)
|
||||
record.UpdatedAt = time.Now().UTC()
|
||||
if err := state.env.ManifestStore.Save(ctx, state.paths.ManifestPath, candidate); err != nil {
|
||||
return fmt.Errorf("save session manifest %q: %w", state.paths.ManifestPath, err)
|
||||
}
|
||||
|
||||
live := transcribeStageRecord(state.manifest)
|
||||
if live == nil {
|
||||
live = &manifest.StageRecord{}
|
||||
if state.manifest.Stages == nil {
|
||||
state.manifest.Stages = make(map[string]*manifest.StageRecord)
|
||||
}
|
||||
state.manifest.Stages["transcribe"] = live
|
||||
}
|
||||
live.TranscribeStateVersion = candidate.Stages["transcribe"].TranscribeStateVersion
|
||||
live.TranscribeSpeakers = manifest.CloneTranscribeSpeakerCollection(
|
||||
candidate.Stages["transcribe"].TranscribeSpeakers,
|
||||
)
|
||||
live.UpdatedAt = candidate.Stages["transcribe"].UpdatedAt
|
||||
state.manifest.UpdatedAt = candidate.UpdatedAt
|
||||
state.records = manifest.CloneTranscribeSpeakerCollection(live.TranscribeSpeakers)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (state *transcribeExecutionState) checkpointFailure() error {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
return state.checkpointErr
|
||||
}
|
||||
|
||||
func (state *transcribeExecutionState) completedCount() int {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
return state.completed
|
||||
}
|
||||
|
||||
func (state *transcribeExecutionState) successDetails() (
|
||||
[]artifacts.Ref,
|
||||
[]string,
|
||||
[]string,
|
||||
map[string]any,
|
||||
error,
|
||||
) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
|
||||
speakers := make([]string, 0, len(state.records))
|
||||
for speaker := range state.records {
|
||||
speakers = append(speakers, speaker)
|
||||
}
|
||||
sort.Strings(speakers)
|
||||
outputs := make([]artifacts.Ref, 0, len(speakers))
|
||||
outputPaths := make([]string, 0, len(speakers))
|
||||
runOutputPaths := make([]string, 0, len(state.runOutputs))
|
||||
orderedPerFile := make(map[string]any, len(state.perFile))
|
||||
for _, speaker := range speakers {
|
||||
record := state.records[speaker]
|
||||
absolute, err := pathsafe.JoinSlashRelativeUnderRoot(state.paths.Root, record.Output.LocalPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("speaker %q output path: %w", speaker, err)
|
||||
}
|
||||
outputs = append(outputs, artifacts.Ref{
|
||||
Kind: record.Output.Kind,
|
||||
Category: "transcripts",
|
||||
SessionID: state.manifest.SessionID,
|
||||
RelativePath: record.Output.LocalPath,
|
||||
AbsolutePath: absolute,
|
||||
ArchivePath: record.Output.ArchivePath,
|
||||
RemoteKey: record.Output.RemoteKey,
|
||||
Checksum: record.Output.Checksum,
|
||||
ProducerRunID: record.ProducerRunID,
|
||||
})
|
||||
outputPaths = append(outputPaths, absolute)
|
||||
if runPath, ok := state.runOutputs[speaker]; ok {
|
||||
runOutputPaths = append(runOutputPaths, runPath)
|
||||
}
|
||||
if metadata, ok := state.perFile[speaker]; ok {
|
||||
orderedPerFile[speaker] = cloneStringAnyMap(metadata)
|
||||
}
|
||||
}
|
||||
return outputs, outputPaths, runOutputPaths, orderedPerFile, nil
|
||||
}
|
||||
|
||||
func transcribeStageRecord(m *manifest.Manifest) *manifest.StageRecord {
|
||||
if m == nil || m.Stages == nil {
|
||||
return nil
|
||||
}
|
||||
return m.Stages["transcribe"]
|
||||
}
|
||||
|
||||
func cloneManifestForTranscribeCheckpoint(source *manifest.Manifest) *manifest.Manifest {
|
||||
clone := *source
|
||||
clone.Inputs = append([]manifest.InputRecord(nil), source.Inputs...)
|
||||
clone.Artifacts = append([]manifest.ArtifactRecord(nil), source.Artifacts...)
|
||||
clone.Stages = make(map[string]*manifest.StageRecord, len(source.Stages))
|
||||
for name, sourceRecord := range source.Stages {
|
||||
if sourceRecord == nil {
|
||||
clone.Stages[name] = nil
|
||||
continue
|
||||
}
|
||||
record := *sourceRecord
|
||||
record.Outputs = append([]manifest.ArtifactRecord(nil), sourceRecord.Outputs...)
|
||||
record.Logs = append([]string(nil), sourceRecord.Logs...)
|
||||
record.GeneratedConfigs = append([]string(nil), sourceRecord.GeneratedConfigs...)
|
||||
record.TranscribeSpeakers = manifest.CloneTranscribeSpeakerCollection(sourceRecord.TranscribeSpeakers)
|
||||
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(sourceRecord.AnalyzeArtifacts)
|
||||
clone.Stages[name] = &record
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cloneStringAnyMap(source map[string]any) map[string]any {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]any, len(source))
|
||||
for key, value := range source {
|
||||
clone[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
339
internal/stage/transcribe_reconciliation.go
Normal file
339
internal/stage/transcribe_reconciliation.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
type transcribeReconciliationJob struct {
|
||||
transcribeJob
|
||||
audioRelativePath string
|
||||
audioSHA256 string
|
||||
audioSize int64
|
||||
outputRelativePath string
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
type transcribeReconciliation struct {
|
||||
jobs []transcribeReconciliationJob
|
||||
current map[string]manifest.TranscribeSpeakerRecord
|
||||
work []transcribeReconciliationJob
|
||||
}
|
||||
|
||||
type transcribeSpeakerFingerprint struct {
|
||||
SpeakerID string `json:"speaker_id"`
|
||||
AudioPath string `json:"audio_path"`
|
||||
AudioSHA256 string `json:"audio_sha256"`
|
||||
AudioSize int64 `json:"audio_size"`
|
||||
SemanticConfigVersion int `json:"semantic_config_version"`
|
||||
SemanticConfigDigest string `json:"semantic_config_digest"`
|
||||
}
|
||||
|
||||
// reconcileTranscribeSpeakers compares the current prepared audio set with
|
||||
// manifest-owned per-speaker evidence without mutating either.
|
||||
func reconcileTranscribeSpeakers(ctx context.Context, env *Env, m *manifest.Manifest) (transcribeReconciliation, error) {
|
||||
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
|
||||
return transcribeReconciliation{}, fmt.Errorf("transcribe reconciliation requires resolved pipeline and session config")
|
||||
}
|
||||
if env.ArtifactStore == nil {
|
||||
return transcribeReconciliation{}, fmt.Errorf("transcribe reconciliation requires artifact store")
|
||||
}
|
||||
if err := contextError(ctx); err != nil {
|
||||
return transcribeReconciliation{}, err
|
||||
}
|
||||
|
||||
sessionID := strings.TrimSpace(env.Config.Session.SessionID)
|
||||
if m != nil && strings.TrimSpace(m.SessionID) != "" {
|
||||
sessionID = strings.TrimSpace(m.SessionID)
|
||||
}
|
||||
if sessionID == "" {
|
||||
return transcribeReconciliation{}, fmt.Errorf("transcribe reconciliation requires session ID")
|
||||
}
|
||||
paths := sessionPathsForEnv(env, sessionID)
|
||||
baseJobs, err := buildTranscribeJobs(m, paths)
|
||||
if err != nil {
|
||||
return transcribeReconciliation{}, fmt.Errorf("resolve audio inputs: %w", err)
|
||||
}
|
||||
if len(baseJobs) == 0 {
|
||||
return transcribeReconciliation{}, fmt.Errorf("no prepared audio files found")
|
||||
}
|
||||
semantic, err := (transcribeStage{}).SemanticConfigFingerprint(env)
|
||||
if err != nil {
|
||||
return transcribeReconciliation{}, err
|
||||
}
|
||||
|
||||
result := transcribeReconciliation{
|
||||
jobs: make([]transcribeReconciliationJob, 0, len(baseJobs)),
|
||||
current: map[string]manifest.TranscribeSpeakerRecord{},
|
||||
work: make([]transcribeReconciliationJob, 0, len(baseJobs)),
|
||||
}
|
||||
for _, base := range baseJobs {
|
||||
candidate, err := inspectTranscribeJob(ctx, paths, base, semantic)
|
||||
if err != nil {
|
||||
return transcribeReconciliation{}, fmt.Errorf("speaker %q: %w", base.speakerID, err)
|
||||
}
|
||||
result.jobs = append(result.jobs, candidate)
|
||||
}
|
||||
|
||||
stageRecord, versioned, err := transcribeManifestState(m)
|
||||
if err != nil {
|
||||
return transcribeReconciliation{}, err
|
||||
}
|
||||
if env.Force || !versioned {
|
||||
result.work = append(result.work, result.jobs...)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
for _, candidate := range result.jobs {
|
||||
record, exists := stageRecord.TranscribeSpeakers[candidate.speakerID]
|
||||
if !exists {
|
||||
result.work = append(result.work, candidate)
|
||||
continue
|
||||
}
|
||||
if err := manifest.ValidateTranscribeSpeakerCollection(
|
||||
stageRecord.TranscribeStateVersion,
|
||||
map[string]manifest.TranscribeSpeakerRecord{candidate.speakerID: record},
|
||||
); err != nil {
|
||||
return transcribeReconciliation{}, fmt.Errorf("speaker %q manifest evidence: %w", candidate.speakerID, err)
|
||||
}
|
||||
if !sameTranscribeInputEvidence(candidate, record) {
|
||||
result.work = append(result.work, candidate)
|
||||
continue
|
||||
}
|
||||
current, err := inspectTranscribeOutput(ctx, paths, candidate, record)
|
||||
if err != nil {
|
||||
return transcribeReconciliation{}, fmt.Errorf("speaker %q output evidence: %w", candidate.speakerID, err)
|
||||
}
|
||||
if !current {
|
||||
result.work = append(result.work, candidate)
|
||||
continue
|
||||
}
|
||||
result.current[candidate.speakerID] = manifest.CloneTranscribeSpeakerCollection(
|
||||
map[string]manifest.TranscribeSpeakerRecord{candidate.speakerID: record},
|
||||
)[candidate.speakerID]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func inspectTranscribeJob(
|
||||
ctx context.Context,
|
||||
paths artifacts.SessionPaths,
|
||||
job transcribeJob,
|
||||
semantic manifest.SemanticConfigFingerprint,
|
||||
) (transcribeReconciliationJob, error) {
|
||||
audioRelative, err := pathsafe.SlashRelativeFromRoot(paths.Root, job.audioPath)
|
||||
if err != nil {
|
||||
return transcribeReconciliationJob{}, fmt.Errorf("derive session-relative audio path: %w", err)
|
||||
}
|
||||
outputRelative, err := pathsafe.SlashRelativeFromRoot(paths.Root, job.canonicalOutputPath)
|
||||
if err != nil {
|
||||
return transcribeReconciliationJob{}, fmt.Errorf("derive session-relative output path: %w", err)
|
||||
}
|
||||
audioSHA256, audioSize, err := hashRegularFileWithContext(ctx, job.audioPath)
|
||||
if err != nil {
|
||||
return transcribeReconciliationJob{}, fmt.Errorf("inspect audio %q: %w", job.audioPath, err)
|
||||
}
|
||||
fingerprint, err := fingerprintTranscribeSpeaker(transcribeSpeakerFingerprint{
|
||||
SpeakerID: job.speakerID,
|
||||
AudioPath: audioRelative,
|
||||
AudioSHA256: audioSHA256,
|
||||
AudioSize: audioSize,
|
||||
SemanticConfigVersion: semantic.Version,
|
||||
SemanticConfigDigest: semantic.Digest,
|
||||
})
|
||||
if err != nil {
|
||||
return transcribeReconciliationJob{}, err
|
||||
}
|
||||
return transcribeReconciliationJob{
|
||||
transcribeJob: job,
|
||||
audioRelativePath: audioRelative,
|
||||
audioSHA256: audioSHA256,
|
||||
audioSize: audioSize,
|
||||
outputRelativePath: outputRelative,
|
||||
fingerprint: fingerprint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fingerprintTranscribeSpeaker(payload transcribeSpeakerFingerprint) (string, error) {
|
||||
fingerprint, err := FingerprintSemanticConfig(manifest.TranscribeFingerprintContractVersion, payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build per-speaker fingerprint: %w", err)
|
||||
}
|
||||
return fingerprint.Digest, nil
|
||||
}
|
||||
|
||||
func transcribeManifestState(m *manifest.Manifest) (*manifest.StageRecord, bool, error) {
|
||||
if m == nil || m.Stages == nil || m.Stages["transcribe"] == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
record := m.Stages["transcribe"]
|
||||
if record.Name != "transcribe" {
|
||||
return nil, false, fmt.Errorf("transcribe manifest record has stage name %q", record.Name)
|
||||
}
|
||||
if record.TranscribeStateVersion == 0 && record.TranscribeSpeakers == nil {
|
||||
return record, false, nil
|
||||
}
|
||||
if record.TranscribeStateVersion != manifest.TranscribeStateContractVersion {
|
||||
return nil, false, fmt.Errorf("unsupported transcribe state version %d", record.TranscribeStateVersion)
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func sameTranscribeInputEvidence(candidate transcribeReconciliationJob, record manifest.TranscribeSpeakerRecord) bool {
|
||||
return record.FingerprintVersion == manifest.TranscribeFingerprintContractVersion &&
|
||||
record.Fingerprint == candidate.fingerprint &&
|
||||
record.AudioPath == candidate.audioRelativePath &&
|
||||
record.AudioSHA256 == candidate.audioSHA256 &&
|
||||
record.AudioSize == candidate.audioSize &&
|
||||
record.Output.LocalPath == candidate.outputRelativePath
|
||||
}
|
||||
|
||||
func inspectTranscribeOutput(
|
||||
ctx context.Context,
|
||||
paths artifacts.SessionPaths,
|
||||
candidate transcribeReconciliationJob,
|
||||
record manifest.TranscribeSpeakerRecord,
|
||||
) (bool, error) {
|
||||
if record.Output.LocalPath != candidate.outputRelativePath {
|
||||
return false, nil
|
||||
}
|
||||
absolute, err := pathsafe.JoinSlashRelativeUnderRoot(paths.Root, record.Output.LocalPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolve recorded output path: %w", err)
|
||||
}
|
||||
exists, err := requireSafeTranscribeOutput(paths.Root, record.Output.LocalPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
if err := validateTranscriptJSONFile(absolute); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
checksum, size, err := hashRegularFileWithContext(ctx, absolute)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("verify output: %w", err)
|
||||
}
|
||||
return checksum == record.Output.Checksum && size == record.OutputSize, nil
|
||||
}
|
||||
|
||||
func requireSafeTranscribeOutput(rootPath, relativePath string) (bool, error) {
|
||||
normalized, err := pathsafe.NormalizeRelativeDestination(relativePath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("recorded output path is unsafe: %w", err)
|
||||
}
|
||||
root, err := os.OpenRoot(rootPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("open session root: %w", err)
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
|
||||
parts := strings.Split(normalized, "/")
|
||||
current := root
|
||||
opened := make([]*os.Root, 0, len(parts)-1)
|
||||
defer func() {
|
||||
for index := len(opened) - 1; index >= 0; index-- {
|
||||
_ = opened[index].Close()
|
||||
}
|
||||
}()
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
info, err := current.Lstat(part)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect output ancestor %q: %w", part, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return false, fmt.Errorf("output ancestor %q is not a directory without symlinks", part)
|
||||
}
|
||||
next, err := current.OpenRoot(part)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("open output ancestor %q: %w", part, err)
|
||||
}
|
||||
opened = append(opened, next)
|
||||
current = next
|
||||
}
|
||||
leaf := parts[len(parts)-1]
|
||||
info, err := current.Lstat(leaf)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect output %q: %w", normalized, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return false, fmt.Errorf("output %q is not a regular file without symlinks", normalized)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func hashRegularFileWithContext(ctx context.Context, path string) (string, int64, error) {
|
||||
if err := contextError(ctx); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
_ = file.Close()
|
||||
return "", 0, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
_ = file.Close()
|
||||
return "", 0, fmt.Errorf("path is not a regular file")
|
||||
}
|
||||
hash := sha256.New()
|
||||
size, copyErr := io.Copy(hash, &contextReadChecker{ctx: ctx, reader: file})
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return "", 0, copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return "", 0, closeErr
|
||||
}
|
||||
if err := contextError(ctx); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if size != info.Size() {
|
||||
return "", 0, fmt.Errorf("file size changed while reading")
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), size, nil
|
||||
}
|
||||
|
||||
type contextReadChecker struct {
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (r *contextReadChecker) Read(buffer []byte) (int, error) {
|
||||
if err := contextError(r.ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.reader.Read(buffer)
|
||||
}
|
||||
|
||||
func contextError(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
316
internal/stage/transcribe_reconciliation_test.go
Normal file
316
internal/stage/transcribe_reconciliation_test.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestReconcileTranscribeSpeakersReusesUnchangedCompletedEvidence(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"bob.flac", "alice.flac"})
|
||||
reconciled, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileTranscribeSpeakers() error = %v", err)
|
||||
}
|
||||
if got := reconciliationSpeakerIDs(reconciled.jobs); !slices.Equal(got, []string{"alice", "bob"}) {
|
||||
t.Fatalf("jobs = %#v, want sorted speakers", got)
|
||||
}
|
||||
if len(reconciled.current) != 2 || len(reconciled.work) != 0 {
|
||||
t.Fatalf("reconciliation = current %d work %d, want 2/0", len(reconciled.current), len(reconciled.work))
|
||||
}
|
||||
|
||||
// Manifest paths may be session-relative; reconciliation must not resolve
|
||||
// them against the process working directory.
|
||||
for index := range m.Inputs {
|
||||
if m.Inputs[index].Kind == "audio" {
|
||||
relative, relErr := filepath.Rel(sessionPathsForEnv(env, m.SessionID).Root, m.Inputs[index].Path)
|
||||
if relErr != nil {
|
||||
t.Fatal(relErr)
|
||||
}
|
||||
m.Inputs[index].Path = filepath.ToSlash(relative)
|
||||
}
|
||||
}
|
||||
reconciled, err = reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err != nil || len(reconciled.current) != 2 || len(reconciled.work) != 0 {
|
||||
t.Fatalf("session-relative reconciliation = %#v, error %v", reconciled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeResumeAcceptsEquivalentOutputsAndInspectsRemovalReadOnly(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac", "bob.flac"})
|
||||
record := m.Stages["transcribe"]
|
||||
record.Status = manifest.StatusSucceeded
|
||||
// Reverse speaker order and mix the supported local path representations.
|
||||
bob := record.TranscribeSpeakers["bob"].Output
|
||||
bob.LocalPath = filepath.Join(sessionPathsForEnv(env, m.SessionID).Root, filepath.FromSlash(bob.LocalPath))
|
||||
record.Outputs = []manifest.ArtifactRecord{bob, record.TranscribeSpeakers["alice"].Output}
|
||||
validation, err := (transcribeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil || !validation.Resumable {
|
||||
t.Fatalf("equivalent aggregate: %#v, %v", validation, err)
|
||||
}
|
||||
m.Inputs = slices.DeleteFunc(m.Inputs, func(in manifest.InputRecord) bool { return filepath.Base(in.Path) == "bob.flac" })
|
||||
before := cloneManifestForTranscribeCheckpoint(m)
|
||||
validation, err = (transcribeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil || validation.Resumable {
|
||||
t.Fatalf("removed speaker: %#v, %v", validation, err)
|
||||
}
|
||||
if !reflect.DeepEqual(m, before) {
|
||||
t.Fatal("resume inspection mutated manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileTranscribeSpeakersReplacesOnlyChangedAudio(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac", "bob.flac"})
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeFile(t, filepath.Join(paths.AudioDir, "alice.flac"), "replacement audio")
|
||||
|
||||
reconciled, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileTranscribeSpeakers() error = %v", err)
|
||||
}
|
||||
if got := reconciliationSpeakerIDs(reconciled.work); !slices.Equal(got, []string{"alice"}) {
|
||||
t.Fatalf("work = %#v, want alice", got)
|
||||
}
|
||||
if _, current := reconciled.current["bob"]; !current || len(reconciled.current) != 1 {
|
||||
t.Fatalf("current = %#v, want only bob", reconciled.current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileTranscribeSpeakersTracksSemanticAndExcludesOperationalSettings(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*Env)
|
||||
work bool
|
||||
}{
|
||||
{name: "service URL", mutate: func(env *Env) { env.Config.Pipeline.WhisperX.TranscribeURL = "https://other.example/transcribe" }, work: true},
|
||||
{name: "language", mutate: func(env *Env) { env.Config.Pipeline.WhisperX.Language = "fr" }, work: true},
|
||||
{name: "timeout", mutate: func(env *Env) { env.Config.Pipeline.WhisperX.Timeout = "45m" }},
|
||||
{name: "retry policy", mutate: func(env *Env) {
|
||||
retries := 9
|
||||
env.Config.Pipeline.WhisperX.Retries = &retries
|
||||
env.Config.Pipeline.WhisperX.RetryDelay = "15s"
|
||||
}},
|
||||
{name: "concurrency", mutate: func(env *Env) {
|
||||
concurrency := 1
|
||||
env.Config.Pipeline.WhisperX.Concurrency = &concurrency
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac"})
|
||||
test.mutate(env)
|
||||
reconciled, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileTranscribeSpeakers() error = %v", err)
|
||||
}
|
||||
if test.work {
|
||||
if len(reconciled.work) != 1 || len(reconciled.current) != 0 {
|
||||
t.Fatalf("semantic change reconciliation = current %d work %d", len(reconciled.current), len(reconciled.work))
|
||||
}
|
||||
} else if len(reconciled.work) != 0 || len(reconciled.current) != 1 {
|
||||
t.Fatalf("operational change reconciliation = current %d work %d", len(reconciled.current), len(reconciled.work))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileTranscribeSpeakersHandlesAddedAndRemovedInputs(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac", "bob.flac"})
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
caraPath := filepath.Join(paths.AudioDir, "cara.flac")
|
||||
writeFile(t, caraPath, "cara audio")
|
||||
m.Inputs = append(m.Inputs, manifest.InputRecord{Kind: "audio", Path: caraPath})
|
||||
|
||||
reconciled, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile added input error = %v", err)
|
||||
}
|
||||
if got := reconciliationSpeakerIDs(reconciled.work); !slices.Equal(got, []string{"cara"}) || len(reconciled.current) != 2 {
|
||||
t.Fatalf("added input reconciliation = current %#v work %#v", reconciled.current, got)
|
||||
}
|
||||
|
||||
inputs := m.Inputs[:0]
|
||||
for _, input := range m.Inputs {
|
||||
if input.Kind != "audio" || filepath.Base(input.Path) != "bob.flac" {
|
||||
inputs = append(inputs, input)
|
||||
}
|
||||
}
|
||||
m.Inputs = inputs
|
||||
reconciled, err = reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcile removed input error = %v", err)
|
||||
}
|
||||
if got := reconciliationSpeakerIDs(reconciled.jobs); !slices.Equal(got, []string{"alice", "cara"}) {
|
||||
t.Fatalf("jobs after removal = %#v", got)
|
||||
}
|
||||
if _, retained := reconciled.current["bob"]; retained {
|
||||
t.Fatalf("removed bob record retained in current collection: %#v", reconciled.current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileTranscribeSpeakersTreatsMissingAndCorruptRegularOutputsAsWork(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*testing.T, string)
|
||||
}{
|
||||
{name: "missing", mutate: func(t *testing.T, path string) {
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{name: "invalid JSON", mutate: func(t *testing.T, path string) { writeFile(t, path, "not-json") }},
|
||||
{name: "checksum mismatch", mutate: func(t *testing.T, path string) { writeFile(t, path, `{"speaker":"changed"}`) }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac"})
|
||||
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsRawDir, "alice.json")
|
||||
test.mutate(t, path)
|
||||
reconciled, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileTranscribeSpeakers() error = %v", err)
|
||||
}
|
||||
if len(reconciled.current) != 0 || len(reconciled.work) != 1 {
|
||||
t.Fatalf("reconciliation = current %d work %d, want 0/1", len(reconciled.current), len(reconciled.work))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileTranscribeSpeakersRejectsUnsafeEvidenceAndMissingAudio(t *testing.T) {
|
||||
t.Run("unsafe record path", func(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac"})
|
||||
record := m.Stages["transcribe"].TranscribeSpeakers["alice"]
|
||||
record.Output.LocalPath = "../alice.json"
|
||||
m.Stages["transcribe"].TranscribeSpeakers["alice"] = record
|
||||
_, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe") {
|
||||
t.Fatalf("error = %v, want unsafe record path", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-regular output", func(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac"})
|
||||
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsRawDir, "alice.json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||
t.Fatalf("error = %v, want non-regular safety error", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing audio", func(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac"})
|
||||
if err := os.Remove(filepath.Join(sessionPathsForEnv(env, m.SessionID).AudioDir, "alice.flac")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("error = %v, want missing audio input error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReconcileTranscribeSpeakersForceAndLegacyStateSelectAllWork(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*Env, *manifest.Manifest)
|
||||
}{
|
||||
{name: "force", mutate: func(env *Env, _ *manifest.Manifest) { env.Force = true }},
|
||||
{name: "legacy aggregate only", mutate: func(_ *Env, m *manifest.Manifest) {
|
||||
stage := m.Stages["transcribe"]
|
||||
stage.TranscribeStateVersion = 0
|
||||
stage.TranscribeSpeakers = nil
|
||||
}},
|
||||
{name: "versioned empty", mutate: func(_ *Env, m *manifest.Manifest) {
|
||||
m.Stages["transcribe"].TranscribeSpeakers = map[string]manifest.TranscribeSpeakerRecord{}
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac", "bob.flac"})
|
||||
test.mutate(env, m)
|
||||
reconciled, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileTranscribeSpeakers() error = %v", err)
|
||||
}
|
||||
if len(reconciled.current) != 0 || len(reconciled.work) != 2 {
|
||||
t.Fatalf("reconciliation = current %d work %d, want 0/2", len(reconciled.current), len(reconciled.work))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileTranscribeSpeakersHonorsCancellationWhileInspectingInputs(t *testing.T) {
|
||||
env, m := setupCurrentTranscribeReconciliation(t, []string{"alice.flac"})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err := reconcileTranscribeSpeakers(ctx, env, m)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupCurrentTranscribeReconciliation(t *testing.T, audioFiles []string) (*Env, *manifest.Manifest) {
|
||||
t.Helper()
|
||||
env, m := setupTranscribeEnv(t, audioFiles)
|
||||
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
initial, err := reconcileTranscribeSpeakers(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("initial reconciliation error = %v", err)
|
||||
}
|
||||
records := make(map[string]manifest.TranscribeSpeakerRecord, len(initial.work))
|
||||
for _, candidate := range initial.work {
|
||||
if err := writeJSONFile(candidate.canonicalOutputPath, map[string]any{"speaker": candidate.speakerID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checksum, err := artifacts.SHA256File(candidate.canonicalOutputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(candidate.canonicalOutputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records[candidate.speakerID] = manifest.TranscribeSpeakerRecord{
|
||||
FingerprintVersion: manifest.TranscribeFingerprintContractVersion,
|
||||
Fingerprint: candidate.fingerprint,
|
||||
AudioPath: candidate.audioRelativePath,
|
||||
AudioSHA256: candidate.audioSHA256,
|
||||
AudioSize: candidate.audioSize,
|
||||
Output: manifest.ArtifactRecord{
|
||||
Kind: "transcript_raw", LocalPath: candidate.outputRelativePath,
|
||||
Checksum: checksum, ProducerRunID: "run-current",
|
||||
},
|
||||
OutputSize: info.Size(), ProducerRunID: "run-current",
|
||||
}
|
||||
}
|
||||
now := time.Date(2026, 9, 21, 12, 0, 0, 0, time.UTC)
|
||||
m.Stages["transcribe"] = &manifest.StageRecord{
|
||||
Name: "transcribe", Status: manifest.StatusFailed, CreatedAt: now, UpdatedAt: now,
|
||||
TranscribeStateVersion: manifest.TranscribeStateContractVersion,
|
||||
TranscribeSpeakers: records,
|
||||
}
|
||||
return env, m
|
||||
}
|
||||
|
||||
func reconciliationSpeakerIDs(jobs []transcribeReconciliationJob) []string {
|
||||
ids := make([]string, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
ids = append(ids, job.speakerID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
60
internal/stage/transcribe_resume.go
Normal file
60
internal/stage/transcribe_resume.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func (transcribeStage) ValidateResume(
|
||||
ctx context.Context,
|
||||
env *Env,
|
||||
m *manifest.Manifest,
|
||||
) (ResumeValidation, error) {
|
||||
reconciliation, err := reconcileTranscribeSpeakers(ctx, env, m)
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("transcribe resume: reconcile speakers: %w", err)
|
||||
}
|
||||
if len(reconciliation.work) == 0 {
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
if !transcribeAggregateCurrent(m.Stages["transcribe"], reconciliation.current, paths) {
|
||||
return NonResumable("speaker collection or aggregate outputs require reconciliation"), nil
|
||||
}
|
||||
return Resumable(), nil
|
||||
}
|
||||
speakers := make([]string, 0, len(reconciliation.work))
|
||||
for _, job := range reconciliation.work {
|
||||
speakers = append(speakers, job.speakerID)
|
||||
}
|
||||
sort.Strings(speakers)
|
||||
return NonResumable("speaker transcripts require execution: " + strings.Join(speakers, ", ")), nil
|
||||
}
|
||||
|
||||
// No HTTP work does not imply that the aggregate is current. The reconciled
|
||||
// collection is a subset of the stored records, so its cardinality also detects
|
||||
// removed speakers. Compare local output evidence independently of list order
|
||||
// and of the supported absolute/session-relative path representations.
|
||||
func transcribeAggregateCurrent(record *manifest.StageRecord, current map[string]manifest.TranscribeSpeakerRecord, paths artifacts.SessionPaths) bool {
|
||||
if len(record.TranscribeSpeakers) != len(current) || len(record.Outputs) != len(current) {
|
||||
return false
|
||||
}
|
||||
expected := make(map[string]manifest.ArtifactRecord, len(current))
|
||||
for _, speaker := range current {
|
||||
expected[artifacts.ResolveSessionLocalPathForRead(paths, speaker.Output.LocalPath)] = speaker.Output
|
||||
}
|
||||
for _, output := range record.Outputs {
|
||||
path := artifacts.ResolveSessionLocalPathForRead(paths, output.LocalPath)
|
||||
want, ok := expected[path]
|
||||
if !ok || output.Kind != want.Kind || output.Checksum != want.Checksum || output.ProducerRunID != want.ProducerRunID {
|
||||
return false
|
||||
}
|
||||
delete(expected, path)
|
||||
}
|
||||
return len(expected) == 0
|
||||
}
|
||||
|
||||
var _ ResumeValidator = transcribeStage{}
|
||||
@@ -322,7 +322,7 @@ func TestTranscribeStagePreservesDistinctSameBasenameAudio(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscribeStageCancellationDoesNotMaterializePartialOutputs(t *testing.T) {
|
||||
func TestTranscribeStageCancellationPreservesCheckpointedOutputs(t *testing.T) {
|
||||
env, m := setupTranscribeEnv(t, []string{"alice.flac", "bob.flac", "cara.flac"})
|
||||
m.RunID = "20260810T214607Z-a1b2c3d4"
|
||||
fastComplete := make(chan struct{})
|
||||
@@ -351,6 +351,21 @@ func TestTranscribeStageCancellationDoesNotMaterializePartialOutputs(t *testing.
|
||||
}()
|
||||
select {
|
||||
case <-fastComplete:
|
||||
manifestPath := sessionPathsForEnv(env, m.SessionID).ManifestPath
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
durable, err := env.ManifestStore.Load(context.Background(), manifestPath)
|
||||
if err == nil {
|
||||
if _, ok := durable.Stages["transcribe"].TranscribeSpeakers["alice"]; ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
cancel()
|
||||
t.Fatal("alice completion was not checkpointed before cancellation")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
case <-time.After(time.Second):
|
||||
cancel()
|
||||
@@ -365,8 +380,15 @@ func TestTranscribeStageCancellationDoesNotMaterializePartialOutputs(t *testing.
|
||||
}
|
||||
|
||||
canonical := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsRawDir, "alice.json")
|
||||
if _, err := os.Stat(canonical); !os.IsNotExist(err) {
|
||||
t.Fatalf("partial canonical output should not exist, stat err = %v", err)
|
||||
if _, err := os.Stat(canonical); err != nil {
|
||||
t.Fatalf("checkpointed canonical output missing: %v", err)
|
||||
}
|
||||
durable, err := env.ManifestStore.Load(context.Background(), sessionPathsForEnv(env, m.SessionID).ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load checkpointed manifest: %v", err)
|
||||
}
|
||||
if _, ok := durable.Stages["transcribe"].TranscribeSpeakers["alice"]; !ok {
|
||||
t.Fatalf("checkpointed speakers = %#v, want alice", durable.Stages["transcribe"].TranscribeSpeakers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,8 +476,13 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
|
||||
env := &Env{
|
||||
Config: cfg,
|
||||
ArtifactStore: artifacts.NewLocalStore(workspace),
|
||||
ManifestStore: &manifest.LocalStore{},
|
||||
}
|
||||
return env, manifest.New("2026-05-03", time.Now().UTC())
|
||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||
m.Campaign = "sample-campaign"
|
||||
m.RunID = "20260503T010203Z-a1b2c3d4"
|
||||
m.MarkStageRunning("transcribe", time.Now().UTC())
|
||||
return env, m
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, payload any) error {
|
||||
|
||||
Reference in New Issue
Block a user