Integrate cleanup into pipeline execution
This commit is contained in:
11
docs/cli.md
11
docs/cli.md
@@ -153,7 +153,7 @@ Behavior:
|
||||
|
||||
- evaluates one inclusive contiguous range of the canonical stage order;
|
||||
- defaults an omitted `--from` to `prepare` and an omitted `--through` to
|
||||
`notify`, so omitting both retains full-pipeline behavior;
|
||||
`cleanup`, so omitting both retains full-pipeline behavior;
|
||||
- rejects unknown endpoints and a `--from` endpoint after `--through`;
|
||||
- runs `render` before `extract`; an omitted or disabled Notarius
|
||||
configuration records an explicit `notarius_disabled` self-skip;
|
||||
@@ -213,6 +213,7 @@ Valid stage names:
|
||||
- `analyze`
|
||||
- `publish`
|
||||
- `notify`
|
||||
- `cleanup`
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -242,6 +243,10 @@ Equivalent to:
|
||||
narratio run-stage publish <session_id> --force [...common config flags]
|
||||
```
|
||||
|
||||
This command selects only `publish`; it does not invoke cleanup. Use a normal
|
||||
full run to include configured cleanup, or `run-stage cleanup` to retry cleanup
|
||||
from valid publication evidence.
|
||||
|
||||
### `clean`
|
||||
|
||||
```bash
|
||||
@@ -255,8 +260,8 @@ Behavior:
|
||||
- `--all` removes all local session work and spool state;
|
||||
- cache remains unless `--clear-cache` is provided.
|
||||
|
||||
See [Operations: Cleanup](./operations.md#cleanup) for deletion scope and
|
||||
post-publish cleanup behavior.
|
||||
See [Operations: Cleanup](./operations.md#cleanup) for manual and pipeline-stage
|
||||
deletion scope.
|
||||
|
||||
### `session plan`
|
||||
|
||||
|
||||
@@ -26,7 +26,8 @@ The pipeline runner follows one explicit invocation lifecycle: inspect bounded
|
||||
prerequisites without mutation, prepare the workspace layout, acquire one
|
||||
session lock, load or create the authoritative session manifest, compose only
|
||||
the collaborators needed by the selected range, execute each selected stage in
|
||||
order, complete any required post-publish cleanup, and then return the summary.
|
||||
order, and then return the summary. Cleanup, when selected, is the ordinary
|
||||
final stage rather than runner finalization work.
|
||||
The lock remains held from manifest initialization through finalization, and a
|
||||
failed stage or required manifest save prevents later stages from starting.
|
||||
|
||||
@@ -35,7 +36,7 @@ failed stage or required manifest save prevents later stages from starting.
|
||||
| Area | Implemented owners | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| Executable | `cmd/narratio` | Process entry, standard stream wiring, argument handoff, and exit status. |
|
||||
| Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, cleanup gates, and user-facing reporting. |
|
||||
| Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, and user-facing reporting. |
|
||||
| Configuration | [`internal/config`](configuration.md) | Presence-aware root/import/profile composition, canonical party and family expansion, strict YAML loading, defaults, normalization, session templating, and validation. |
|
||||
| Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. |
|
||||
| External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. |
|
||||
@@ -78,6 +79,7 @@ The implemented canonical order is:
|
||||
9. [`analyze`](stage-analyze.md)
|
||||
10. [`publish`](stage-publish.md)
|
||||
11. `notify` (no-op)
|
||||
12. [`cleanup`](stage-cleanup.md)
|
||||
|
||||
`notify` currently has no persisted pipeline outputs and uses the explicit
|
||||
`noop` notification mode. Its versioned semantic evidence records that delivery
|
||||
@@ -91,7 +93,8 @@ registry owns the flat execution sequence. The application orchestration owner
|
||||
uses a fixed, validated dependency relation to find transitive dependents in
|
||||
canonical order. In particular, `render` and `extract` are sibling consumers of
|
||||
trimmed transcript state: neither invalidates the other, while either can stale
|
||||
`analyze`, `publish`, and `notify`.
|
||||
`analyze`, `publish`, `notify`, and `cleanup`. Cleanup is a dependent of notify,
|
||||
so any earlier replacement can stale a prior cleanup result.
|
||||
|
||||
## Focused Documentation
|
||||
|
||||
@@ -118,6 +121,7 @@ trimmed transcript state: neither invalidates the other, while either can stale
|
||||
- [`extract`](stage-extract.md)
|
||||
- [`analyze`](stage-analyze.md)
|
||||
- [`publish`](stage-publish.md)
|
||||
- [`cleanup`](stage-cleanup.md)
|
||||
|
||||
Use this map to find an owner, then read its focused documentation and tests
|
||||
before changing behavior.
|
||||
|
||||
54
docs/internal/stage-cleanup.md
Normal file
54
docs/internal/stage-cleanup.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Stage: cleanup
|
||||
|
||||
## Purpose
|
||||
|
||||
Remove explicitly configured local data for the run selected by a successful
|
||||
publish commit. Cleanup is the twelfth and final canonical stage, after
|
||||
`notify`.
|
||||
|
||||
## Authorization And Targets
|
||||
|
||||
Cleanup is disabled unless at least one of these policies is true:
|
||||
|
||||
- `pipeline.spool.delete_audio_after_publish`
|
||||
- `pipeline.workspace.cleanup_after_publish`
|
||||
|
||||
An enabled cleanup requires the manifest's publish stage to be succeeded with
|
||||
`uploaded=true`, a canonical `published_run_id`, and the exact remote commit and
|
||||
current commit-pointer keys for the manifest campaign/session prefix. The
|
||||
configured campaign and session must match the manifest. Cleanup derives its
|
||||
only possible targets through the canonical artifact path helpers:
|
||||
|
||||
- the published run's session spool-audio directory; and
|
||||
- the published run's workspace run directory.
|
||||
|
||||
It does not scan parent directories, infer the newest run, or read object
|
||||
storage. Every deletion uses the root-confined file operation, which rejects a
|
||||
root itself, traversal, symlink escape, and out-of-root targets.
|
||||
|
||||
## Execution And Resume
|
||||
|
||||
Disabled cleanup records an explicit skipped result. Enabled cleanup removes
|
||||
targets in deterministic policy order and treats an already-missing target as
|
||||
success. A successful record contains the published run ID and completed policy
|
||||
names.
|
||||
|
||||
A previous cleanup success is reusable only while its semantic configuration
|
||||
matches and every selected target remains absent. A recreated target makes the
|
||||
stage non-resumable. A failure is recorded against cleanup; publish remains
|
||||
succeeded and remote current state is unchanged. On a later normal full run,
|
||||
valid upstream stages are skipped and cleanup is retried without republishing.
|
||||
|
||||
`narratio publish` selects publish only and never invokes cleanup. A bounded run
|
||||
through `notify` or any earlier endpoint also excludes it. Operators may run
|
||||
`narratio run-stage cleanup <session_id>` directly; missing, failed, malformed,
|
||||
or identity-mismatched publication evidence fails before deletion.
|
||||
|
||||
## Implementation And Tests
|
||||
|
||||
- Planning and evidence validation: `internal/stage/cleanup_plan.go`
|
||||
- Execution and resume: `internal/stage/cleanup.go`
|
||||
- Root-confined deletion: `internal/fileops/cleanup.go`
|
||||
- Focused tests: `internal/stage/cleanup_plan_test.go`,
|
||||
`internal/stage/cleanup_test.go`, and
|
||||
`internal/app/cleanup_orchestration_test.go`
|
||||
@@ -109,8 +109,8 @@ Includes counts/lists for:
|
||||
- independent publish locks are not overridden by `--force`; every initially
|
||||
unlocked relevant source is reread immediately before current-state
|
||||
selection.
|
||||
- post-commit local cleanup is authorized by the committed publish metadata and
|
||||
is durably recorded by the application lifecycle before any local deletion.
|
||||
- successful uploaded publication records the exact run ID, remote commit key,
|
||||
and current-pointer key consumed by the later cleanup stage.
|
||||
|
||||
## Resume Semantics
|
||||
|
||||
@@ -123,9 +123,12 @@ and run identifiers are excluded. Independent remote locks remain mutable state
|
||||
and are read directly by publish at planning and commit boundaries; semantic
|
||||
evidence does not replace those safety checks.
|
||||
|
||||
The commit boundary and cleanup gate are normative architecture invariants; see
|
||||
The commit boundary and cleanup authorization are normative architecture invariants; see
|
||||
[Architecture](../policy/architecture.md#publish-commit-boundary).
|
||||
|
||||
Publish never deletes local state. The standalone `publish` command selects
|
||||
only this stage; a full run selects notify and cleanup afterward.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
- [Configuration](../config.md#publish-configuration-summary) owns output
|
||||
@@ -141,4 +144,4 @@ The commit boundary and cleanup gate are normative architecture invariants; see
|
||||
`internal/stage/publish_test.go`,
|
||||
`internal/stage/semantic_contracts_delivery.go`,
|
||||
`internal/app/operator_helpers_test.go`, and
|
||||
`internal/app/post_publish_cleanup_test.go`
|
||||
`internal/app/cleanup_orchestration_test.go`
|
||||
|
||||
@@ -47,18 +47,18 @@ lock only while their context remains active, and report a release failure.
|
||||
|
||||
## Cleanup Semantics
|
||||
|
||||
Automatic post-publish cleanup:
|
||||
The canonical cleanup stage:
|
||||
|
||||
- is created only after a successful publish commit with complete publish
|
||||
metadata, then is persisted before any deletion;
|
||||
- requires `uploaded=true`, a remote commit key, and a current commit-pointer
|
||||
key in publish metadata;
|
||||
- runs after notify only when selected by the invocation range;
|
||||
- requires a succeeded publish record with `uploaded=true`, the published run
|
||||
ID, remote commit key, and current commit-pointer key;
|
||||
- consumes the resolved cleanup policy described in
|
||||
[Configuration](../config.md);
|
||||
- refuses unsafe deletes (root delete, traversal, and out-of-root targets);
|
||||
- retries any recorded incomplete target on later invocations even when no
|
||||
publish work is selected. Missing targets are a successful, idempotent
|
||||
cleanup result only after the completion evidence is saved.
|
||||
- treats missing targets as successful, idempotent deletion; and
|
||||
- uses its ordinary failed/succeeded manifest stage record for retry. A later
|
||||
full run can skip reusable upstream stages and retry cleanup without
|
||||
republishing.
|
||||
|
||||
Manual cleanup uses the same root-confined deletion mechanism. Invocation
|
||||
syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
|
||||
@@ -82,9 +82,11 @@ syntax and exact deletion scope belong in [CLI](../cli.md#clean) and
|
||||
- Immutable bundle installation: `internal/stage/extract.go`
|
||||
- Workspace modes: `internal/fileops/modes.go`
|
||||
- Cleanup confinement: `internal/fileops/cleanup.go`,
|
||||
`internal/app/cleanup_targets.go`, `internal/app/post_publish_cleanup.go`
|
||||
`internal/stage/cleanup.go`, `internal/stage/cleanup_plan.go`, and
|
||||
`internal/app/cleanup_targets.go`
|
||||
- Tests: `internal/artifacts/paths_model_test.go`,
|
||||
`internal/artifacts/local_test.go`, `internal/stage/run_local_test.go`,
|
||||
`internal/fileops/modes_posix_test.go`,
|
||||
`internal/fileops/cleanup_test.go`, `internal/app/cleanup_targets_test.go`,
|
||||
`internal/app/post_publish_cleanup_test.go`
|
||||
`internal/stage/cleanup_test.go`, and
|
||||
`internal/app/cleanup_orchestration_test.go`
|
||||
|
||||
@@ -98,6 +98,7 @@ Canonical stage order:
|
||||
9. `analyze`
|
||||
10. `publish`
|
||||
11. `notify`
|
||||
12. `cleanup`
|
||||
|
||||
Execution rules:
|
||||
|
||||
@@ -154,7 +155,7 @@ narratio run 2026-04-04 --from extract --through analyze --force
|
||||
```
|
||||
|
||||
Omitting `--from` selects from `prepare`; omitting `--through` selects through
|
||||
`notify`. Force applies only within the selected range. Repeating `--from`,
|
||||
`cleanup`. Force applies only within the selected range. Repeating `--from`,
|
||||
`--through`, or `--force` is rejected instead of resolving by argument order.
|
||||
The plan command uses the same selection contract and prints only the selected
|
||||
range. Planning is read-only: it clones the loaded manifest, models selected
|
||||
@@ -194,8 +195,8 @@ This command is a transparent expansion to a forced bounded `run` from
|
||||
`extract` through `analyze`. Extraction always rebuilds its complete configured
|
||||
bundle. Analysis rebuilds the selected targets and their required analysis
|
||||
prerequisites, or uses the normal default selection when no artifact names are
|
||||
given. The command does not run publish or notify; delivery remains a separate
|
||||
operator action.
|
||||
given. The command does not run publish, notify, or cleanup; delivery and
|
||||
retention remain separate operator actions.
|
||||
|
||||
Inspect current artifact evidence, then publish explicitly when the regenerated
|
||||
set is ready:
|
||||
@@ -365,6 +366,9 @@ Equivalent:
|
||||
narratio run-stage publish 2026-04-04 --force
|
||||
```
|
||||
|
||||
This standalone command stops after publish and does not invoke cleanup. A
|
||||
default full `run` continues through notify and cleanup.
|
||||
|
||||
Publish commit model:
|
||||
|
||||
- uploads eligible run files under `{session_prefix}/runs/{run_id}/`, excluding
|
||||
@@ -591,16 +595,20 @@ Rules:
|
||||
- cache is preserved unless `--clear-cache` is set;
|
||||
- each deletion is confined beneath its configured workspace, spool, or cache
|
||||
root; root, traversal, and out-of-root targets are refused;
|
||||
- automatic post-publish cleanup is gated by successful publish commit plus:
|
||||
- the final cleanup stage is gated by successful publish evidence plus:
|
||||
- `pipeline.spool.delete_audio_after_publish=true`
|
||||
- `pipeline.workspace.cleanup_after_publish=true`
|
||||
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
|
||||
reports incomplete, the remote committed snapshot remains current; rerun
|
||||
publish to retry only the outstanding confined local cleanup.
|
||||
- the manifest publish record has `uploaded=true`, the published run ID, the
|
||||
exact remote commit key, and the current commit-pointer key.
|
||||
|
||||
Post-publish cleanup is evaluated only when `publish` actually executes in the
|
||||
current invocation. A bounded range that excludes publish does not replay a
|
||||
cleanup obligation as an unrelated side effect.
|
||||
Cleanup derives only the configured run-scoped spool and workspace targets,
|
||||
uses root-confined deletion, and does not read or mutate object storage. A
|
||||
failure leaves publish succeeded and the remote committed snapshot current.
|
||||
Retry with a normal full run: valid upstream work, including publish and notify,
|
||||
is skipped while the failed cleanup stage runs again. `run-stage cleanup` is
|
||||
also available when the manifest has valid publish evidence. A range ending at
|
||||
`notify` or earlier and the standalone `publish` command never delete local
|
||||
state.
|
||||
|
||||
## Operational Caveats
|
||||
|
||||
|
||||
@@ -185,8 +185,8 @@ availability.
|
||||
Writes, moves, replacements, and deletions must use narrow, explicit,
|
||||
root-confined destinations. Symlinks, traversal, broad roots, and ambiguous
|
||||
relative destinations must not expand the scope of an operation. Cleanup is
|
||||
permitted only through explicit operator action or configured post-publish
|
||||
gates, and it must preserve durable cache unless cache removal is explicitly
|
||||
permitted only through explicit operator action or the configured final cleanup
|
||||
stage, and it must preserve durable cache unless cache removal is explicitly
|
||||
requested.
|
||||
|
||||
Physical layout, retention, and operational lifecycle belong in
|
||||
@@ -212,10 +212,16 @@ transaction boundary.
|
||||
|
||||
Automatic local cleanup is permitted only after a successful publish commit,
|
||||
only when explicitly configured, and only through the path-safety guardrails.
|
||||
It is a durable local obligation bound to that committed run and its exact
|
||||
targets, not an inferred side effect of the current stage list. A cleanup
|
||||
failure makes the invocation incomplete while leaving the committed remote
|
||||
snapshot authoritative; later invocations resume the recorded obligation.
|
||||
It is the final ordinary pipeline stage after `notify`. The stage derives its
|
||||
exact run-scoped targets from validated publish metadata and the configured
|
||||
campaign/session identity; it never infers a target from directory contents or
|
||||
rereads remote storage. A cleanup failure makes the invocation incomplete while
|
||||
leaving publish succeeded and the committed remote snapshot authoritative. A
|
||||
normal later full run reuses valid upstream stages and retries cleanup.
|
||||
|
||||
The standalone `publish` command selects only publish and therefore never
|
||||
invokes cleanup. Operators may select cleanup directly only when the manifest
|
||||
contains valid successful publication evidence.
|
||||
|
||||
## Security, Privacy, And Diagnostics
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ filesystem and manifest contracts.
|
||||
|
||||
## Stage 3 — Integrate Cleanup Into Canonical Orchestration
|
||||
|
||||
**Status:** Pending
|
||||
**Status:** Completed
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "executed=4 skipped=8") {
|
||||
if !strings.Contains(out.String(), "executed=5 skipped=9") {
|
||||
t.Fatalf("output = %q, want extract reconsidered and legacy analyze plus delivery rebuilt", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestAssembledFullRunUsesCanonicalOrder(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
canonical := []string{
|
||||
"prepare", "transcribe", "merge", "polish", "normalize", "trim",
|
||||
"render", "extract", "analyze", "publish", "notify",
|
||||
"render", "extract", "analyze", "publish", "notify", "cleanup",
|
||||
}
|
||||
var order []string
|
||||
stages := make([]stage.Stage, 0, len(canonical))
|
||||
@@ -106,7 +106,7 @@ func TestAssembledForcedSiblingIndependenceAndFailureBoundary(t *testing.T) {
|
||||
if loaded.Stages[sibling].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("%s sibling = %#v, want succeeded", sibling, loaded.Stages[sibling])
|
||||
}
|
||||
for _, dependent := range []string{"analyze", "publish", "notify"} {
|
||||
for _, dependent := range []string{"analyze", "publish", "notify", "cleanup"} {
|
||||
if loaded.Stages[dependent].Status != manifest.StatusStale {
|
||||
t.Fatalf("%s status = %q, want stale", dependent, loaded.Stages[dependent].Status)
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func TestAssembledForcedSiblingIndependenceAndFailureBoundary(t *testing.T) {
|
||||
if loaded.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("extract sibling = %#v", loaded.Stages["extract"])
|
||||
}
|
||||
for _, outside := range []string{"analyze", "publish", "notify"} {
|
||||
for _, outside := range []string{"analyze", "publish", "notify", "cleanup"} {
|
||||
if loaded.Stages[outside].Status != manifest.StatusStale {
|
||||
t.Fatalf("outside stage %s = %#v, want stale and unexecuted", outside, loaded.Stages[outside])
|
||||
}
|
||||
|
||||
201
internal/app/cleanup_orchestration_test.go
Normal file
201
internal/app/cleanup_orchestration_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
const cleanupOrchestrationRunID = "20260905T010203Z-a1b2c3d4"
|
||||
|
||||
type cleanupPublishEvidenceStage struct {
|
||||
runs *int
|
||||
skipSpoolTarget bool
|
||||
}
|
||||
|
||||
func (cleanupPublishEvidenceStage) Name() string { return "publish" }
|
||||
|
||||
func (s cleanupPublishEvidenceStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||
if s.runs != nil {
|
||||
*s.runs++
|
||||
}
|
||||
if !s.skipSpoolTarget {
|
||||
spoolTarget := artifacts.SessionSpoolAudioDir(env.Config.Pipeline.Spool.Root, m.Campaign, m.SessionID, m.RunID)
|
||||
if err := os.MkdirAll(spoolTarget, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(spoolTarget, "audio.flac"), []byte("audio"), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
workspaceTarget := artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, m.Campaign, m.SessionID, m.RunID)
|
||||
if err := os.MkdirAll(workspaceTarget, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stage.StageResult{Metadata: cleanupPublishMetadata(m)}, nil
|
||||
}
|
||||
|
||||
func cleanupPublishMetadata(m *manifest.Manifest) map[string]any {
|
||||
return map[string]any{
|
||||
"uploaded": true,
|
||||
"published_run_id": m.RunID,
|
||||
"remote_commit_key": artifacts.S3RunCommitKey(m.S3SessionPrefix, m.RunID),
|
||||
"current_commit_pointer_key": artifacts.S3CurrentCommitPointerKey(m.S3SessionPrefix),
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupStopsAtPublishAndNotifyFailureBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stages func(*int) []stage.Stage
|
||||
}{
|
||||
{
|
||||
name: "publish failure",
|
||||
stages: func(_ *int) []stage.Stage {
|
||||
return []stage.Stage{failingStage{name: "publish", err: errors.New("publish failed")}, selectedCleanupStage(t)}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "notify failure",
|
||||
stages: func(publishRuns *int) []stage.Stage {
|
||||
return []stage.Stage{
|
||||
cleanupPublishEvidenceStage{runs: publishRuns},
|
||||
failingStage{name: "notify", err: errors.New("notify failed")},
|
||||
selectedCleanupStage(t),
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg, remote := cleanupOrchestrationConfig(t)
|
||||
publishRuns := 0
|
||||
_, err := executePlanWithDependencies(
|
||||
context.Background(), cfg, BoundedPlan{stages: test.stages(&publishRuns)},
|
||||
RunOptions{Env: &Env{ObjectStore: remote}}, cleanupOrchestrationDependencies(),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("execution error = nil")
|
||||
}
|
||||
loaded := loadCleanupOrchestrationManifest(t, cfg)
|
||||
if loaded.Stages["cleanup"] != nil {
|
||||
t.Fatalf("cleanup record = %#v, want no execution", loaded.Stages["cleanup"])
|
||||
}
|
||||
if test.name == "notify failure" {
|
||||
workspaceTarget := artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, cleanupOrchestrationRunID)
|
||||
if _, statErr := os.Stat(workspaceTarget); statErr != nil {
|
||||
t.Fatalf("workspace target removed after notify failure: %v", statErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupFailurePreservesPublishAndRetriesWithoutRepublishing(t *testing.T) {
|
||||
cfg, remote := cleanupOrchestrationConfig(t)
|
||||
badSpoolRoot := filepath.Join(t.TempDir(), "spool-file")
|
||||
if err := os.WriteFile(badSpoolRoot, []byte("not a directory"), 0o644); err != nil {
|
||||
t.Fatalf("write invalid spool root: %v", err)
|
||||
}
|
||||
cfg.Pipeline.Spool.Root = badSpoolRoot
|
||||
remote.SeedObject(storage.FakeObject{Key: "sentinel/remote-state", Data: []byte("unchanged")})
|
||||
publishRuns := 0
|
||||
plan := BoundedPlan{stages: []stage.Stage{
|
||||
cleanupPublishEvidenceStage{runs: &publishRuns, skipSpoolTarget: true},
|
||||
resultStage{name: "notify"},
|
||||
selectedCleanupStage(t),
|
||||
}}
|
||||
|
||||
_, err := executePlanWithDependencies(
|
||||
context.Background(), cfg, plan, RunOptions{Env: &Env{ObjectStore: remote}}, cleanupOrchestrationDependencies(),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("cleanup failure = nil")
|
||||
}
|
||||
loaded := loadCleanupOrchestrationManifest(t, cfg)
|
||||
if loaded.Stages["publish"] == nil || loaded.Stages["publish"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("publish record = %#v, want succeeded", loaded.Stages["publish"])
|
||||
}
|
||||
if loaded.Stages["cleanup"] == nil || loaded.Stages["cleanup"].Status != manifest.StatusFailed {
|
||||
t.Fatalf("cleanup record = %#v, want failed", loaded.Stages["cleanup"])
|
||||
}
|
||||
if got := string(remote.Objects["sentinel/remote-state"].Data); got != "unchanged" {
|
||||
t.Fatalf("remote sentinel = %q, want unchanged", got)
|
||||
}
|
||||
if len(remote.Uploads)+len(remote.Downloads)+len(remote.Reads)+len(remote.Deletes)+len(remote.ExistsKeys) != 0 {
|
||||
t.Fatalf("cleanup touched remote storage: %#v", remote)
|
||||
}
|
||||
|
||||
workspaceTarget := artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, cleanupOrchestrationRunID)
|
||||
cfg.Pipeline.Spool.Root = t.TempDir()
|
||||
spoolTarget := artifacts.SessionSpoolAudioDir(cfg.Pipeline.Spool.Root, cfg.Session.Campaign, cfg.Session.SessionID, cleanupOrchestrationRunID)
|
||||
if err := os.MkdirAll(spoolTarget, 0o755); err != nil {
|
||||
t.Fatalf("create retry spool target: %v", err)
|
||||
}
|
||||
summary, err := executePlanWithDependencies(
|
||||
context.Background(), cfg, plan, RunOptions{Env: &Env{ObjectStore: remote}}, cleanupOrchestrationDependencies(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("retry cleanup: %v", err)
|
||||
}
|
||||
if publishRuns != 1 {
|
||||
t.Fatalf("publish runs = %d, want one", publishRuns)
|
||||
}
|
||||
if !reflect.DeepEqual(summary.Executed, []string{"cleanup"}) || !reflect.DeepEqual(summary.Skipped, []string{"publish", "notify"}) {
|
||||
t.Fatalf("retry summary = %#v", summary)
|
||||
}
|
||||
if _, statErr := os.Lstat(workspaceTarget); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("workspace target stat error = %v, want absent", statErr)
|
||||
}
|
||||
if _, statErr := os.Lstat(spoolTarget); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("spool target stat error = %v, want absent", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupOrchestrationConfig(t *testing.T) (*config.Config, *storage.FakeBackend) {
|
||||
t.Helper()
|
||||
cfg := testConfig(t)
|
||||
cfg.Pipeline.Spool.Root = t.TempDir()
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
cfg.Pipeline.Publish = &config.PublishConfig{Enabled: boolPtr(true), UploadRun: boolPtr(true)}
|
||||
cfg.Pipeline.Storage.Backend = config.StorageBackendS3
|
||||
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "archive", RootPrefix: "dnd"}
|
||||
return cfg, &storage.FakeBackend{}
|
||||
}
|
||||
|
||||
func selectedCleanupStage(t *testing.T) stage.Stage {
|
||||
t.Helper()
|
||||
selected, err := BuildSingleStagePlan("cleanup")
|
||||
if err != nil {
|
||||
t.Fatalf("select cleanup: %v", err)
|
||||
}
|
||||
return selected[0]
|
||||
}
|
||||
|
||||
func cleanupOrchestrationDependencies() invocationDependencies {
|
||||
return invocationDependencies{
|
||||
now: func() time.Time { return time.Date(2026, 9, 5, 1, 2, 3, 0, time.UTC) },
|
||||
newRunID: func() (string, error) { return cleanupOrchestrationRunID, nil },
|
||||
}
|
||||
}
|
||||
|
||||
func loadCleanupOrchestrationManifest(t *testing.T, cfg *config.Config) *manifest.Manifest {
|
||||
t.Helper()
|
||||
path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
loaded, err := (&manifest.LocalStore{}).Load(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("load manifest: %v", err)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
@@ -7,6 +7,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type scopedDir struct {
|
||||
RootAbs string
|
||||
TargetAbs string
|
||||
Exists bool
|
||||
}
|
||||
|
||||
func validateScopedDir(root, target, policy string) (scopedDir, error) {
|
||||
return validateScopedTarget(root, target, policy, true)
|
||||
}
|
||||
|
||||
func validateScopedTarget(root, target, policy string, requireDir bool) (scopedDir, error) {
|
||||
cleanRoot := strings.TrimSpace(root)
|
||||
cleanTarget := strings.TrimSpace(target)
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestExecuteValidCommands(t *testing.T) {
|
||||
args []string
|
||||
wantOut string
|
||||
}{
|
||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=11 skipped=1; manifest="},
|
||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=12 skipped=2; manifest="},
|
||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "analyze: skip\n targets: none\n prerequisites: none\n execute: none\n reuse: none\npublish: skip\nnotify: skip"},
|
||||
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||
@@ -336,7 +336,7 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=11 skipped=1; manifest=") {
|
||||
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=12 skipped=2; manifest=") {
|
||||
t.Fatalf("stdout = %q, want successful run output", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ func TestConfigDiffReportsSemanticProfileChanges(t *testing.T) {
|
||||
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
writeInspectionProfiles(t, pipelinePath, `workspace:
|
||||
cleanup_after_publish: true
|
||||
publish:
|
||||
upload_run: true
|
||||
whisperx:
|
||||
language: en
|
||||
audita:
|
||||
|
||||
@@ -32,12 +32,12 @@ func TestPlanDoesNotCreateWorkdir(t *testing.T) {
|
||||
if !strings.Contains(got, "narratio session plan: read-only workdir at") {
|
||||
t.Fatalf("first output = %q, want read-only workdir", got)
|
||||
}
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify", "cleanup"} {
|
||||
if !strings.Contains(got, name+": run") {
|
||||
t.Fatalf("first output = %q, missing stage %q", got, name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "totals: run=11 skip=0") {
|
||||
if !strings.Contains(got, "totals: run=12 skip=0") {
|
||||
t.Fatalf("first output = %q, want totals", got)
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
||||
if !strings.Contains(got, "trim: run") {
|
||||
t.Fatalf("output = %q, want trim run", got)
|
||||
}
|
||||
if !strings.Contains(got, "totals: run=9 skip=2") {
|
||||
t.Fatalf("output = %q, want totals run=9 skip=2", got)
|
||||
if !strings.Contains(got, "totals: run=10 skip=2") {
|
||||
t.Fatalf("output = %q, want totals run=10 skip=2", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
func TestBuildFullPlanOrder(t *testing.T) {
|
||||
got := BuildFullPlan()
|
||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}
|
||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify", "cleanup"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
@@ -22,15 +22,15 @@ func TestBuildFullPlanOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildSingleStagePlan(t *testing.T) {
|
||||
stages, err := BuildSingleStagePlan("polish")
|
||||
stages, err := BuildSingleStagePlan("cleanup")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSingleStagePlan() error = %v", err)
|
||||
}
|
||||
if len(stages) != 1 {
|
||||
t.Fatalf("len(plan) = %d, want 1", len(stages))
|
||||
}
|
||||
if stages[0].Name() != "polish" {
|
||||
t.Fatalf("stage name = %q, want %q", stages[0].Name(), "polish")
|
||||
if stages[0].Name() != "cleanup" {
|
||||
t.Fatalf("stage name = %q, want %q", stages[0].Name(), "cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,19 @@ func TestBuildBoundedPlanDefaultsToFullCanonicalPlan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBoundedPlanThroughNotifyExcludesCleanup(t *testing.T) {
|
||||
plan, err := BuildBoundedPlan("", "notify")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBoundedPlan() error = %v", err)
|
||||
}
|
||||
if plan.Contains("cleanup") {
|
||||
t.Fatalf("plan = %#v, cleanup must be outside a notify-bounded run", plan.Names())
|
||||
}
|
||||
if got := plan.Through(); got != "notify" {
|
||||
t.Fatalf("through = %q, want notify", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBoundedPlanRejectsInvalidBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -99,9 +112,9 @@ func TestBuildBoundedPlanRejectsInvalidBounds(t *testing.T) {
|
||||
through string
|
||||
want []string
|
||||
}{
|
||||
{name: "unknown from", from: "missing", through: "analyze", want: []string{"unknown from stage", "missing", "prepare", "notify"}},
|
||||
{name: "unknown through", from: "extract", through: "missing", want: []string{"unknown through stage", "missing", "prepare", "notify"}},
|
||||
{name: "reversed", from: "publish", through: "render", want: []string{"publish", "occurs after", "render", "prepare", "notify"}},
|
||||
{name: "unknown from", from: "missing", through: "analyze", want: []string{"unknown from stage", "missing", "prepare", "cleanup"}},
|
||||
{name: "unknown through", from: "extract", through: "missing", want: []string{"unknown through stage", "missing", "prepare", "cleanup"}},
|
||||
{name: "reversed", from: "publish", through: "render", want: []string{"publish", "occurs after", "render", "prepare", "cleanup"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func runPostPublishCleanup(
|
||||
ctx context.Context,
|
||||
env *Env,
|
||||
manifestPath string,
|
||||
m *manifest.Manifest,
|
||||
executed []string,
|
||||
removeScopedDir func(root, target, policy string) error,
|
||||
) error {
|
||||
if env == nil || env.Config == nil || env.Config.Pipeline == nil || m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanup := m.PostPublishCleanup
|
||||
if cleanup == nil {
|
||||
var err error
|
||||
cleanup, err = createPostPublishCleanup(env.Config, m, executed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cleanup == nil {
|
||||
return nil
|
||||
}
|
||||
m.PostPublishCleanup = cleanup
|
||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||
return fmt.Errorf("persist post-publish cleanup obligation %q: %w", manifestPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
for index := range cleanup.Targets {
|
||||
target := &cleanup.Targets[index]
|
||||
if target.Completed {
|
||||
continue
|
||||
}
|
||||
if err := removeScopedDir(target.Root, target.Path, target.Policy); err != nil {
|
||||
return fmt.Errorf("complete post-publish cleanup for %q: %w", target.Path, err)
|
||||
}
|
||||
target.Completed = true
|
||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||
target.Completed = false
|
||||
return fmt.Errorf("persist post-publish cleanup completion %q: %w", manifestPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createPostPublishCleanup(cfg *config.Config, m *manifest.Manifest, executed []string) (*manifest.PostPublishCleanup, error) {
|
||||
spoolRequested := cfg.Pipeline.Spool.DeleteAudioAfterPublish
|
||||
workRequested := cfg.Pipeline.Workspace.CleanupAfterPublish
|
||||
if !spoolRequested && !workRequested {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
sr := publishStageRecordForCleanup(m)
|
||||
publishedRunID, eligible, _ := publishCleanupEligible(cfg, m, sr, executed)
|
||||
if !eligible {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
spoolDir := strings.TrimSpace(m.LocalSpoolDir)
|
||||
if spoolDir == "" {
|
||||
spoolDir = artifacts.SessionSpoolAudioDir(
|
||||
cfg.Pipeline.Spool.Root,
|
||||
strings.TrimSpace(m.Campaign),
|
||||
strings.TrimSpace(m.SessionID),
|
||||
publishedRunID,
|
||||
)
|
||||
}
|
||||
workDir := strings.TrimSpace(m.LocalWorkDir)
|
||||
if workDir == "" {
|
||||
workDir = artifacts.SessionRunRootForCampaign(
|
||||
cfg.Pipeline.Workspace.Root,
|
||||
strings.TrimSpace(m.Campaign),
|
||||
strings.TrimSpace(m.SessionID),
|
||||
publishedRunID,
|
||||
)
|
||||
}
|
||||
|
||||
cleanup := &manifest.PostPublishCleanup{
|
||||
CommittedRunID: publishedRunID,
|
||||
RemoteCommitKey: strings.TrimSpace(asString(sr.Metadata["remote_commit_key"])),
|
||||
CurrentCommitPointerKey: strings.TrimSpace(asString(sr.Metadata["current_commit_pointer_key"])),
|
||||
}
|
||||
if spoolRequested {
|
||||
cleanup.Targets = append(cleanup.Targets, manifest.CleanupTarget{
|
||||
Policy: "pipeline.spool.delete_audio_after_publish",
|
||||
Root: strings.TrimSpace(cfg.Pipeline.Spool.Root),
|
||||
Path: filepath.Clean(spoolDir),
|
||||
})
|
||||
}
|
||||
if workRequested {
|
||||
cleanup.Targets = append(cleanup.Targets, manifest.CleanupTarget{
|
||||
Policy: "pipeline.workspace.cleanup_after_publish",
|
||||
Root: strings.TrimSpace(cfg.Pipeline.Workspace.Root),
|
||||
Path: filepath.Clean(workDir),
|
||||
})
|
||||
}
|
||||
return cleanup, nil
|
||||
}
|
||||
|
||||
func publishStageRecordForCleanup(m *manifest.Manifest) *manifest.StageRecord {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
sr := m.Stages["publish"]
|
||||
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
||||
return nil
|
||||
}
|
||||
return sr
|
||||
}
|
||||
|
||||
func publishCleanupEligible(cfg *config.Config, m *manifest.Manifest, sr *manifest.StageRecord, executed []string) (string, bool, string) {
|
||||
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Publish == nil {
|
||||
return "", false, "publish configuration is missing"
|
||||
}
|
||||
enabled := true
|
||||
if cfg.Pipeline.Publish.Enabled != nil {
|
||||
enabled = *cfg.Pipeline.Publish.Enabled
|
||||
}
|
||||
if !enabled {
|
||||
return "", false, "publish.enabled is false"
|
||||
}
|
||||
uploadRun := true
|
||||
if cfg.Pipeline.Publish.UploadRun != nil {
|
||||
uploadRun = *cfg.Pipeline.Publish.UploadRun
|
||||
}
|
||||
if !uploadRun {
|
||||
return "", false, "publish.upload_run is false"
|
||||
}
|
||||
if sr == nil || sr.Metadata == nil {
|
||||
return "", false, "publish metadata is missing"
|
||||
}
|
||||
if skipped, _ := sr.Metadata["skipped"].(bool); skipped {
|
||||
return "", false, "publish stage was skipped"
|
||||
}
|
||||
if uploaded, _ := sr.Metadata["uploaded"].(bool); !uploaded {
|
||||
return "", false, "publish did not complete an upload"
|
||||
}
|
||||
if strings.TrimSpace(asString(sr.Metadata["remote_commit_key"])) == "" {
|
||||
return "", false, "publish remote commit key is missing"
|
||||
}
|
||||
if strings.TrimSpace(asString(sr.Metadata["current_commit_pointer_key"])) == "" {
|
||||
return "", false, "publish current commit pointer key is missing"
|
||||
}
|
||||
publishedRunID := strings.TrimSpace(asString(sr.Metadata["published_run_id"]))
|
||||
if publishedRunID == "" && containsStage(executed, "publish") && m != nil {
|
||||
publishedRunID = strings.TrimSpace(m.RunID)
|
||||
}
|
||||
if publishedRunID == "" {
|
||||
return "", false, "publish run id is missing"
|
||||
}
|
||||
return publishedRunID, true, ""
|
||||
}
|
||||
|
||||
func containsStage(names []string, target string) bool {
|
||||
for _, name := range names {
|
||||
if name == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type scopedDir struct {
|
||||
RootAbs string
|
||||
TargetAbs string
|
||||
Exists bool
|
||||
}
|
||||
|
||||
func removeRunScopedDir(root, target, policy string) error {
|
||||
dir, err := validateScopedDir(root, target, policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !dir.Exists {
|
||||
return nil
|
||||
}
|
||||
if err := fileops.RemoveAllUnderRoot(dir.RootAbs, dir.TargetAbs); err != nil {
|
||||
return fmt.Errorf("cleanup policy %s: remove %q: %w", policy, dir.TargetAbs, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateScopedDir(root, target, policy string) (scopedDir, error) {
|
||||
return validateScopedTarget(root, target, policy, true)
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
type publishSuccessStage struct {
|
||||
metadata map[string]any
|
||||
targets *cleanupSeed
|
||||
}
|
||||
|
||||
func (publishSuccessStage) Name() string { return "publish" }
|
||||
func (s publishSuccessStage) Run(_ context.Context, _ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||
if s.targets != nil {
|
||||
s.targets.runWorkDir = m.LocalWorkDir
|
||||
s.targets.spoolAudioDir = m.LocalSpoolDir
|
||||
if err := os.MkdirAll(filepath.Join(m.LocalWorkDir, "logs"), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(m.LocalWorkDir, "logs", "stage.log"), []byte("log\n"), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(m.LocalSpoolDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(m.LocalSpoolDir, "speaker.flac"), []byte("flac\n"), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
md := map[string]any{
|
||||
"stage": "publish",
|
||||
"uploaded": true,
|
||||
"published_run_id": m.RunID,
|
||||
"remote_commit_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/20260519T010203Z-a1b2c3d4/commit.json",
|
||||
"current_commit_pointer_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/commit-pointer.json",
|
||||
}
|
||||
for k, v := range s.metadata {
|
||||
md[k] = v
|
||||
}
|
||||
return &stage.StageResult{Metadata: md}, nil
|
||||
}
|
||||
|
||||
type notifyFailStage struct{}
|
||||
|
||||
func (notifyFailStage) Name() string { return "notify" }
|
||||
func (notifyFailStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
return nil, errors.New("notify failed")
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupDisabledKeepsLocalDirs(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = false
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = false
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{targets: &seed}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
assertExists(t, seed.localSourceAudio)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupSpoolOnly(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = false
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{targets: &seed}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
assertMissing(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
assertExists(t, seed.localSourceAudio)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupWorkdirOnly(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = false
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{targets: &seed}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
assertExists(t, cfg.Pipeline.Workspace.Root)
|
||||
assertExists(t, seed.otherRunDir)
|
||||
assertExists(t, seed.previousCachePath)
|
||||
assertMissing(t, seed.runWorkDir)
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupBothPolicies(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{targets: &seed}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
assertMissing(t, seed.spoolAudioDir)
|
||||
assertMissing(t, seed.runWorkDir)
|
||||
assertExists(t, seed.otherRunDir)
|
||||
assertExists(t, seed.previousCachePath)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupRetriesWhenInitialObligationSaveFails(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = false
|
||||
|
||||
failed := false
|
||||
store := &cleanupFailingManifestStore{
|
||||
delegate: &manifest.LocalStore{},
|
||||
fail: func(m *manifest.Manifest) error {
|
||||
if !failed && m.PostPublishCleanup != nil {
|
||||
failed = true
|
||||
return errors.New("injected obligation save failure")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{targets: &seed}}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "post-publish cleanup incomplete") {
|
||||
t.Fatalf("executeStages() error = %v, want incomplete cleanup", err)
|
||||
}
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertCleanupPending(t, cfg)
|
||||
|
||||
store.fail = nil
|
||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||
}
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertCleanupPending(t, cfg)
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||
}
|
||||
assertMissing(t, seed.spoolAudioDir)
|
||||
assertCleanupComplete(t, cfg)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupRetriesFailedDeletionWithoutTouchingOtherRuns(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
|
||||
remove := func(root, target, policy string) error {
|
||||
if policy == "pipeline.workspace.cleanup_after_publish" {
|
||||
return errors.New("injected deletion failure")
|
||||
}
|
||||
return removeRunScopedDir(root, target, policy)
|
||||
}
|
||||
|
||||
_, err := executePlanWithDependencies(
|
||||
context.Background(),
|
||||
cfg,
|
||||
BoundedPlan{stages: []stage.Stage{publishSuccessStage{targets: &seed}}},
|
||||
RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}},
|
||||
invocationDependencies{removeScopedDir: remove},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "post-publish cleanup incomplete") {
|
||||
t.Fatalf("executeStages() error = %v, want incomplete cleanup", err)
|
||||
}
|
||||
assertMissing(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
assertCleanupPending(t, cfg)
|
||||
assertExists(t, seed.otherRunDir)
|
||||
assertExists(t, seed.previousCachePath)
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||
}
|
||||
assertExists(t, seed.runWorkDir)
|
||||
assertCleanupPending(t, cfg)
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||
}
|
||||
assertMissing(t, seed.runWorkDir)
|
||||
assertExists(t, seed.otherRunDir)
|
||||
assertExists(t, seed.previousCachePath)
|
||||
assertCleanupComplete(t, cfg)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupRetriesWhenCompletionEvidenceSaveFails(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = false
|
||||
|
||||
failed := false
|
||||
store := &cleanupFailingManifestStore{
|
||||
delegate: &manifest.LocalStore{},
|
||||
fail: func(m *manifest.Manifest) error {
|
||||
if m.PostPublishCleanup == nil {
|
||||
return nil
|
||||
}
|
||||
for _, target := range m.PostPublishCleanup.Targets {
|
||||
if !failed && target.Completed {
|
||||
failed = true
|
||||
return errors.New("injected completion evidence failure")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{targets: &seed}}, RunOptions{
|
||||
Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "post-publish cleanup incomplete") {
|
||||
t.Fatalf("executeStages() error = %v, want incomplete cleanup", err)
|
||||
}
|
||||
assertMissing(t, seed.spoolAudioDir)
|
||||
assertCleanupPending(t, cfg)
|
||||
|
||||
store.fail = nil
|
||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||
}
|
||||
assertCleanupPending(t, cfg)
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||
}
|
||||
assertCleanupComplete(t, cfg)
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("idempotent non-publish executeStages() error = %v", err)
|
||||
}
|
||||
assertMissing(t, seed.spoolAudioDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenPublishFails(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
|
||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{failingStage{name: "publish", err: errors.New("publish failed")}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "stage \"publish\" failed") {
|
||||
t.Fatalf("executeStages() error = %v, want publish failure", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenPublishSkipped(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{metadata: map[string]any{"skipped": true}, targets: &seed}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenCommitPointerMissing(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{metadata: map[string]any{"current_commit_pointer_key": ""}, targets: &seed}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenPublishUploadDisabled(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
cfg.Pipeline.Publish.UploadRun = boolPtr(false)
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{targets: &seed}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupWaitsUntilAllStagesSucceed(t *testing.T) {
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
|
||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}, notifyFailStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "stage \"notify\" failed") {
|
||||
t.Fatalf("executeStages() error = %v, want notify failure", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupFailsOnUnsafePath(t *testing.T) {
|
||||
cfg, _ := cleanupFixtureConfig(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = false
|
||||
|
||||
manifestPath := manifestPathFor(cfg)
|
||||
store := &manifest.LocalStore{}
|
||||
m, err := store.Load(context.Background(), manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
m.MarkStageSucceeded("publish", time.Now().UTC(), nil)
|
||||
m.Stages["publish"].Metadata = map[string]any{
|
||||
"uploaded": true,
|
||||
"published_run_id": m.RunID,
|
||||
"remote_commit_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/20260516T010203Z-1a2b3c4d/commit.json",
|
||||
"current_commit_pointer_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/commit-pointer.json",
|
||||
}
|
||||
m.PostPublishCleanup = &manifest.PostPublishCleanup{
|
||||
CommittedRunID: m.RunID,
|
||||
RemoteCommitKey: m.Stages["publish"].Metadata["remote_commit_key"].(string),
|
||||
CurrentCommitPointerKey: m.Stages["publish"].Metadata["current_commit_pointer_key"].(string),
|
||||
Targets: []manifest.CleanupTarget{{
|
||||
Policy: "pipeline.spool.delete_audio_after_publish",
|
||||
Root: cfg.Pipeline.Spool.Root,
|
||||
Path: filepath.Join(filepath.Dir(cfg.Pipeline.Spool.Root), "outside-spool"),
|
||||
}},
|
||||
}
|
||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err = executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||
}
|
||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
|
||||
t.Fatalf("executeStages() error = %v, want safe-path failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenOutputIsMissing(t *testing.T) {
|
||||
cfg, seed, runID := publishStageCleanupFixture(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
cfg.Pipeline.Publish.Outputs = []config.PublishOutputRule{
|
||||
{Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(true)},
|
||||
}
|
||||
|
||||
publishStageImpl, err := stage.Select("publish")
|
||||
if err != nil {
|
||||
t.Fatalf("Select(publish) error = %v", err)
|
||||
}
|
||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishStageImpl}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "required output source unavailable") {
|
||||
t.Fatalf("executeStages() error = %v, want required output source unavailable failure", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
assertExists(t, artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID))
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenCommittedManifestUploadFails(t *testing.T) {
|
||||
cfg, seed, _ := publishStageCleanupFixture(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
|
||||
publishStageImpl, err := stage.Select("publish")
|
||||
if err != nil {
|
||||
t.Fatalf("Select(publish) error = %v", err)
|
||||
}
|
||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishStageImpl}, RunOptions{
|
||||
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, fail: func(key string) bool {
|
||||
return strings.HasSuffix(key, "/session-manifest.json")
|
||||
}}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "immutable object") {
|
||||
t.Fatalf("executeStages() error = %v, want committed-manifest failure", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
}
|
||||
|
||||
func TestPostPublishCleanupNotRunWhenCommitPointerUploadFails(t *testing.T) {
|
||||
cfg, seed, _ := publishStageCleanupFixture(t)
|
||||
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||
failKey := artifacts.S3CurrentCommitPointerKey(seed.sessionPrefix)
|
||||
|
||||
publishStageImpl, err := stage.Select("publish")
|
||||
if err != nil {
|
||||
t.Fatalf("Select(publish) error = %v", err)
|
||||
}
|
||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishStageImpl}, RunOptions{
|
||||
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "current commit pointer") {
|
||||
t.Fatalf("executeStages() error = %v, want current-commit-pointer failure", err)
|
||||
}
|
||||
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertExists(t, seed.runWorkDir)
|
||||
}
|
||||
|
||||
type cleanupSeed struct {
|
||||
runID string
|
||||
runWorkDir string
|
||||
otherRunDir string
|
||||
spoolAudioDir string
|
||||
localSourceAudio string
|
||||
previousCachePath string
|
||||
sessionPrefix string
|
||||
}
|
||||
|
||||
type cleanupFailingManifestStore struct {
|
||||
delegate manifest.Store
|
||||
fail func(*manifest.Manifest) error
|
||||
}
|
||||
|
||||
func (s *cleanupFailingManifestStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
|
||||
return s.delegate.Create(ctx, sessionID)
|
||||
}
|
||||
|
||||
func (s *cleanupFailingManifestStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
|
||||
return s.delegate.Load(ctx, path)
|
||||
}
|
||||
|
||||
func (s *cleanupFailingManifestStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
||||
if s.fail != nil {
|
||||
if err := s.fail(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.delegate.Save(ctx, path, m)
|
||||
}
|
||||
|
||||
func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) {
|
||||
t.Helper()
|
||||
|
||||
cfg := testConfig(t)
|
||||
cfg.Pipeline.Publish = &config.PublishConfig{Enabled: boolPtr(true), UploadRun: boolPtr(true)}
|
||||
cfg.Pipeline.Spool.Root = filepath.Join(t.TempDir(), "spool")
|
||||
|
||||
runID := "20260516T010203Z-1a2b3c4d"
|
||||
runWorkDir := artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)
|
||||
otherRunDir := artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, "20260516T010204Z-5e6f7a8b")
|
||||
spoolAudioDir := artifacts.SessionSpoolAudioDir(cfg.Pipeline.Spool.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)
|
||||
previousCachePath := mustPreviousArtifactPathForCampaign(t,
|
||||
cfg.Pipeline.Workspace.Root,
|
||||
cfg.Session.Campaign,
|
||||
cfg.Session.SessionID,
|
||||
"session_recap.md",
|
||||
)
|
||||
|
||||
mustWriteFile(t, filepath.Join(runWorkDir, "logs", "stage.log"), "log\n")
|
||||
mustWriteFile(t, filepath.Join(otherRunDir, "logs", "stage.log"), "other\n")
|
||||
mustWriteFile(t, filepath.Join(spoolAudioDir, "speaker.flac"), "flac\n")
|
||||
mustWriteFile(t, previousCachePath, "# previous recap\n")
|
||||
|
||||
localSourceAudio := filepath.Join(filepath.Dir(cfg.SessionPath), "audio", "alice.flac")
|
||||
mustWriteFile(t, localSourceAudio, "source\n")
|
||||
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
seed.Campaign = cfg.Session.Campaign
|
||||
seed.RunID = runID
|
||||
seed.LocalWorkDir = runWorkDir
|
||||
seed.LocalSpoolDir = spoolAudioDir
|
||||
seed.S3Bucket = "my-dnd-archive"
|
||||
seed.S3SessionPrefix = "dnd/campaigns/sample-campaign/sessions/2026-05-03/"
|
||||
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPathFor(cfg)), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatalf("seed manifest save error = %v", err)
|
||||
}
|
||||
|
||||
return cfg, cleanupSeed{
|
||||
runID: runID,
|
||||
runWorkDir: runWorkDir,
|
||||
otherRunDir: otherRunDir,
|
||||
spoolAudioDir: spoolAudioDir,
|
||||
localSourceAudio: localSourceAudio,
|
||||
previousCachePath: previousCachePath,
|
||||
sessionPrefix: seed.S3SessionPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, string) {
|
||||
t.Helper()
|
||||
|
||||
cfg, seed := cleanupFixtureConfig(t)
|
||||
runID := "20260516T010203Z-1a2b3c4d"
|
||||
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
|
||||
Bucket: "my-dnd-archive",
|
||||
RootPrefix: "dnd",
|
||||
}
|
||||
cfg.Pipeline.Publish = &config.PublishConfig{
|
||||
Enabled: boolPtr(true),
|
||||
UploadRun: boolPtr(true),
|
||||
Outputs: []config.PublishOutputRule{
|
||||
{Source: "narratio.transcript.final_trimmed", Dest: "transcripts/final.trimmed.json", Required: boolPtr(true)},
|
||||
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
|
||||
},
|
||||
}
|
||||
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
|
||||
Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
||||
"session_recap": {
|
||||
OutputPath: "artifacts/session_recap.md",
|
||||
},
|
||||
},
|
||||
}
|
||||
writePublishFixtureRunFiles(
|
||||
t,
|
||||
seed.runWorkDir,
|
||||
artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID),
|
||||
)
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
seedManifest, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
||||
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
setCurrentBuiltInEvidence(t, seedManifest, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID), "transcripts", "final.trimmed.json"))
|
||||
setAppAnalyzeEvidence(seedManifest, "session_recap", "artifacts/session_recap.md", []byte("# recap\n"))
|
||||
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
seed.sessionPrefix = seedManifest.S3SessionPrefix
|
||||
return cfg, seed, runID
|
||||
}
|
||||
|
||||
func writePublishFixtureRunFiles(t *testing.T, runWorkDir, sessionRoot string) {
|
||||
t.Helper()
|
||||
mustWriteFile(t, filepath.Join(runWorkDir, "prepare", "inputs", "session.yml"), "session_id: 2026-05-03\n")
|
||||
mustWriteFile(t, filepath.Join(runWorkDir, "transcribe", "outputs", "transcripts", "raw", "speaker.json"), "{}\n")
|
||||
mustWriteFile(t, filepath.Join(runWorkDir, "trim", "outputs", "transcripts", "final.trimmed.json"), "{\"segments\":[]}\n")
|
||||
mustWriteFile(t, filepath.Join(runWorkDir, "analyze", "outputs", "artifacts", "session_recap.md"), "# recap\n")
|
||||
mustWriteFile(t, filepath.Join(runWorkDir, "polish", "reports", "audita.report.json"), "{}\n")
|
||||
mustWriteFile(t, filepath.Join(runWorkDir, "merge", "config", "seriatim.generated.yml"), "key: value\n")
|
||||
mustWriteFile(t, filepath.Join(runWorkDir, "logs", "audita.stderr.log"), "stderr\n")
|
||||
mustWriteFile(t, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"), "{\"segments\":[]}\n")
|
||||
mustWriteFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
|
||||
}
|
||||
|
||||
type failKeyStore struct {
|
||||
delegate *storage.FakeBackend
|
||||
failKey string
|
||||
fail func(string) bool
|
||||
}
|
||||
|
||||
func (s *failKeyStore) fails(key string) bool {
|
||||
return strings.TrimSpace(key) == strings.TrimSpace(s.failKey) || (s.fail != nil && s.fail(key))
|
||||
}
|
||||
|
||||
func (s *failKeyStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||
return s.delegate.List(ctx, prefix)
|
||||
}
|
||||
|
||||
func (s *failKeyStore) Read(ctx context.Context, key string) (storage.ObjectInfo, io.ReadCloser, error) {
|
||||
return s.delegate.Read(ctx, key)
|
||||
}
|
||||
|
||||
func (s *failKeyStore) Download(ctx context.Context, key, localPath string) error {
|
||||
return s.delegate.Download(ctx, key, localPath)
|
||||
}
|
||||
|
||||
func (s *failKeyStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
|
||||
if s.fails(key) {
|
||||
return storage.ObjectInfo{}, errors.New("forced upload failure")
|
||||
}
|
||||
return s.delegate.Upload(ctx, localPath, key, opts)
|
||||
}
|
||||
|
||||
func (s *failKeyStore) UploadReader(ctx context.Context, source io.Reader, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
|
||||
if s.fails(key) {
|
||||
return storage.ObjectInfo{}, errors.New("forced upload failure")
|
||||
}
|
||||
return s.delegate.UploadReader(ctx, source, key, opts)
|
||||
}
|
||||
|
||||
func (s *failKeyStore) UploadConditional(ctx context.Context, source io.Reader, key string, opts storage.UploadOptions, condition storage.WriteCondition) (storage.ObjectInfo, error) {
|
||||
if s.fails(key) {
|
||||
return storage.ObjectInfo{}, errors.New("forced upload failure")
|
||||
}
|
||||
return s.delegate.UploadConditional(ctx, source, key, opts, condition)
|
||||
}
|
||||
|
||||
func (s *failKeyStore) Delete(ctx context.Context, key string) error {
|
||||
return s.delegate.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (s *failKeyStore) Exists(ctx context.Context, key string) (bool, error) {
|
||||
return s.delegate.Exists(ctx, key)
|
||||
}
|
||||
|
||||
func assertExists(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected path to exist %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMissing(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected path to be removed %q, stat err=%v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCleanupPending(t *testing.T, cfg *config.Config) {
|
||||
t.Helper()
|
||||
m, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if m.PostPublishCleanup == nil {
|
||||
t.Fatal("expected a persisted cleanup obligation")
|
||||
}
|
||||
for _, target := range m.PostPublishCleanup.Targets {
|
||||
if !target.Completed {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("expected at least one cleanup target to remain incomplete")
|
||||
}
|
||||
|
||||
func assertCleanupComplete(t *testing.T, cfg *config.Config) {
|
||||
t.Helper()
|
||||
m, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if m.PostPublishCleanup == nil {
|
||||
t.Fatal("expected a persisted cleanup obligation")
|
||||
}
|
||||
for _, target := range m.PostPublishCleanup.Targets {
|
||||
if !target.Completed {
|
||||
t.Fatalf("cleanup target remains incomplete: %#v", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,7 +191,8 @@ var canonicalInvalidationEdges = map[string][]string{
|
||||
"extract": {"analyze"},
|
||||
"analyze": {"publish"},
|
||||
"publish": {"notify"},
|
||||
"notify": {},
|
||||
"notify": {"cleanup"},
|
||||
"cleanup": {},
|
||||
}
|
||||
|
||||
func newInvalidationRelation(registry []stage.Stage, direct map[string][]string) (*invalidationRelation, error) {
|
||||
|
||||
@@ -223,17 +223,18 @@ func TestInvalidationDependents(t *testing.T) {
|
||||
stage string
|
||||
want []string
|
||||
}{
|
||||
{"prepare", []string{"transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"transcribe", []string{"merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"merge", []string{"polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"polish", []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"normalize", []string{"trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"trim", []string{"render", "extract", "analyze", "publish", "notify"}},
|
||||
{"render", []string{"analyze", "publish", "notify"}},
|
||||
{"extract", []string{"analyze", "publish", "notify"}},
|
||||
{"analyze", []string{"publish", "notify"}},
|
||||
{"publish", []string{"notify"}},
|
||||
{"notify", []string{}},
|
||||
{"prepare", []string{"transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify", "cleanup"}},
|
||||
{"transcribe", []string{"merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify", "cleanup"}},
|
||||
{"merge", []string{"polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify", "cleanup"}},
|
||||
{"polish", []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify", "cleanup"}},
|
||||
{"normalize", []string{"trim", "render", "extract", "analyze", "publish", "notify", "cleanup"}},
|
||||
{"trim", []string{"render", "extract", "analyze", "publish", "notify", "cleanup"}},
|
||||
{"render", []string{"analyze", "publish", "notify", "cleanup"}},
|
||||
{"extract", []string{"analyze", "publish", "notify", "cleanup"}},
|
||||
{"analyze", []string{"publish", "notify", "cleanup"}},
|
||||
{"publish", []string{"notify", "cleanup"}},
|
||||
{"notify", []string{"cleanup"}},
|
||||
{"cleanup", []string{}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.stage, func(t *testing.T) {
|
||||
@@ -290,7 +291,7 @@ func TestInvalidateDependentSucceededStagesWithReason(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("invalidateDependentSucceededStagesWithReason() error = %v", err)
|
||||
}
|
||||
want := []string{"normalize", "trim", "render", "extract", "publish", "notify"}
|
||||
want := []string{"normalize", "trim", "render", "extract", "publish", "notify", "cleanup"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("invalidateDependentSucceededStagesWithReason() = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -316,7 +317,7 @@ func TestRenderAndExtractInvalidationAreIndependent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("invalidate dependents: %v", err)
|
||||
}
|
||||
want := []string{"analyze", "publish", "notify"}
|
||||
want := []string{"analyze", "publish", "notify", "cleanup"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("invalidated = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
@@ -38,8 +38,8 @@ func TestRunContinuesAfterCompletedStages(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "executed=9 skipped=3") {
|
||||
t.Fatalf("output = %q, want executed=9 skipped=3", out.String())
|
||||
if !strings.Contains(out.String(), "executed=10 skipped=4") {
|
||||
t.Fatalf("output = %q, want executed=10 skipped=4", out.String())
|
||||
}
|
||||
|
||||
loaded, err := store.Load(context.Background(), manifestPath)
|
||||
@@ -76,7 +76,7 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "executed=1 skipped=11") {
|
||||
if !strings.Contains(out.String(), "executed=2 skipped=12") {
|
||||
t.Fatalf("output = %q, want disabled extraction to self-skip", out.String())
|
||||
}
|
||||
loaded, err := store.Load(context.Background(), manifestPath)
|
||||
@@ -100,7 +100,7 @@ func TestRunForceRerunsSucceeded(t *testing.T) {
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify", "cleanup"} {
|
||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||
@@ -112,7 +112,7 @@ func TestRunForceRerunsSucceeded(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "executed=11 skipped=1") {
|
||||
if !strings.Contains(out.String(), "executed=12 skipped=2") {
|
||||
t.Fatalf("output = %q, want forced full rerun", out.String())
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("load prepared manifest: %v", err)
|
||||
}
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify", "cleanup"} {
|
||||
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
setCurrentBuiltInEvidence(t, seed, "merge", artifacts.TranscriptOutputKindBase, artifacts.ArtifactTranscriptBase, basePath)
|
||||
@@ -244,7 +244,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("load manifest after force: %v", err)
|
||||
}
|
||||
for _, name := range []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify", "cleanup"} {
|
||||
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
||||
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "executed=7 skipped=5") {
|
||||
if !strings.Contains(out.String(), "executed=8 skipped=6") {
|
||||
t.Fatalf("output = %q, want run to execute stale downstream stages", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,15 +15,14 @@ import (
|
||||
)
|
||||
|
||||
type invocationDependencies struct {
|
||||
now func() time.Time
|
||||
newRunID func() (string, error)
|
||||
newWhisperX func(*config.Config) (whisperx.Client, error)
|
||||
newSeriatim func(*config.Config) (seriatim.Runner, error)
|
||||
newAudita func(*config.Config) (audita.Runner, error)
|
||||
newNotarius func() notarius.Runner
|
||||
newScriptorium func() scriptorium.Runner
|
||||
newObjectStore func(context.Context, *config.Config) (storage.ObjectStore, error)
|
||||
removeScopedDir func(root, target, policy string) error
|
||||
now func() time.Time
|
||||
newRunID func() (string, error)
|
||||
newWhisperX func(*config.Config) (whisperx.Client, error)
|
||||
newSeriatim func(*config.Config) (seriatim.Runner, error)
|
||||
newAudita func(*config.Config) (audita.Runner, error)
|
||||
newNotarius func() notarius.Runner
|
||||
newScriptorium func() scriptorium.Runner
|
||||
newObjectStore func(context.Context, *config.Config) (storage.ObjectStore, error)
|
||||
}
|
||||
|
||||
func (dependencies invocationDependencies) normalized() invocationDependencies {
|
||||
@@ -53,8 +52,5 @@ func (dependencies invocationDependencies) normalized() invocationDependencies {
|
||||
return initializeObjectStoreBackend(ctx, cfg, storage.NewObjectStoreFromConfig)
|
||||
}
|
||||
}
|
||||
if dependencies.removeScopedDir == nil {
|
||||
dependencies.removeScopedDir = removeRunScopedDir
|
||||
}
|
||||
return dependencies
|
||||
}
|
||||
|
||||
@@ -1,35 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func finalizeInvocation(runtime *invocationRuntime) (*RunSummary, error) {
|
||||
if containsStage(runtime.executed, "publish") {
|
||||
if err := runPostPublishCleanup(
|
||||
runtime.ctx,
|
||||
runtime.env,
|
||||
runtime.manifestPath,
|
||||
runtime.manifest,
|
||||
runtime.executed,
|
||||
runtime.dependencies.removeScopedDir,
|
||||
); err != nil {
|
||||
return nil, persistPostPublishCleanupFailure(
|
||||
runtime.ctx,
|
||||
runtime.manifestStore,
|
||||
runtime.manifestPath,
|
||||
runtime.manifest,
|
||||
fmt.Errorf("post-publish cleanup incomplete: %w", err),
|
||||
runtime.dependencies.now(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return &RunSummary{
|
||||
SessionID: runtime.identity.SessionID,
|
||||
RunID: runtime.identity.RunID,
|
||||
@@ -39,24 +10,3 @@ func finalizeInvocation(runtime *invocationRuntime) (*RunSummary, error) {
|
||||
Skipped: runtime.skipped,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func persistPostPublishCleanupFailure(
|
||||
ctx context.Context,
|
||||
sessionStore manifest.Store,
|
||||
sessionManifestPath string,
|
||||
sessionManifest *manifest.Manifest,
|
||||
operationErr error,
|
||||
at time.Time,
|
||||
) error {
|
||||
if operationErr == nil {
|
||||
return nil
|
||||
}
|
||||
if sessionManifest != nil {
|
||||
sessionManifest.RecordFailure(at, operationErr.Error())
|
||||
}
|
||||
// Cleanup may have deliberately removed the selected run directory. The
|
||||
// committed remote snapshot remains authoritative, while the session
|
||||
// manifest records the outstanding local cleanup for the next invocation.
|
||||
sessionErr := sessionStore.Save(ctx, sessionManifestPath, sessionManifest)
|
||||
return errors.Join(operationErr, wrapTerminalPersistenceError("save terminal session manifest", sessionErr))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -561,7 +562,7 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if len(summary.StageNames) != 11 || len(summary.Executed) != 11 || len(summary.Skipped) != 1 || summary.Skipped[0] != "extract" {
|
||||
if len(summary.StageNames) != 12 || len(summary.Executed) != 12 || !reflect.DeepEqual(summary.Skipped, []string{"extract", "cleanup"}) {
|
||||
t.Fatalf("summary = %#v, want full plan with disabled extraction self-skip", summary)
|
||||
}
|
||||
|
||||
@@ -571,14 +572,18 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
||||
t.Fatalf("Load manifest error = %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify", "cleanup"} {
|
||||
sr := m.Stages[name]
|
||||
if sr == nil {
|
||||
t.Fatalf("missing stage record %q", name)
|
||||
}
|
||||
if name == "extract" {
|
||||
if sr.Status != manifest.StatusSkipped || sr.Error == nil || sr.Error.Message != "notarius_disabled" {
|
||||
t.Fatalf("extract stage = %#v, want disabled skip", sr)
|
||||
if name == "extract" || name == "cleanup" {
|
||||
wantReason := "notarius_disabled"
|
||||
if name == "cleanup" {
|
||||
wantReason = "cleanup policies are disabled"
|
||||
}
|
||||
if sr.Status != manifest.StatusSkipped || sr.Error == nil || sr.Error.Message != wantReason {
|
||||
t.Fatalf("%s stage = %#v, want disabled skip", name, sr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ func All() []Stage {
|
||||
analyzeStage{},
|
||||
publishStage{},
|
||||
placeholderStage{name: "notify"},
|
||||
cleanupStage{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user