From 2356688cb93607170f4ba935c343966a8a86afba Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 18 May 2026 03:02:22 +0000 Subject: [PATCH] Removed legacy interfaces and old documentation references to the previous on-disk layout --- README.md | 12 +- .../workspace-implementation-plan.md | 547 +++--------------- docs/development/workspace.md | 19 +- docs/integrations/scriptorium.md | 22 +- docs/stages/prepare.md | 2 +- internal/app/post_archive_cleanup.go | 8 - internal/app/post_archive_cleanup_test.go | 6 +- internal/app/resume.go | 11 +- internal/artifacts/local.go | 82 +-- internal/artifacts/local_test.go | 38 +- internal/artifacts/paths.go | 15 - internal/artifacts/paths_model_test.go | 9 - internal/artifacts/s3.go | 25 - internal/artifacts/store.go | 5 - internal/stage/archive.go | 26 +- internal/stage/archive_test.go | 55 +- internal/stage/prepare.go | 14 +- internal/stage/prepare_test.go | 4 +- internal/stage/session_paths.go | 6 - internal/stage/transcribe_test.go | 3 +- 20 files changed, 150 insertions(+), 759 deletions(-) diff --git a/README.md b/README.md index 1a93eaa..b8775d4 100644 --- a/README.md +++ b/README.md @@ -369,10 +369,16 @@ For the initial implementation, only `session_recap` generation is supported. Analyze-stage session recap behavior: -- available transcript input sources for configured artifacts: `processed_transcript`, `normalized_transcript`, `trimmed_transcript` +- preferred transcript artifact source IDs: + - `narratio.transcript.polished` + - `narratio.transcript.full` + - `narratio.transcript.trimmed` +- backward-compatible aliases remain supported: + - `processed_transcript` + - `normalized_transcript` + - `trimmed_transcript` - session recap should use gameplay-only transcript input (`source: trimmed_transcript`) -- Narratio resolves `trimmed_transcript` from trim manifest output (`transcript_trimmed`) or fallback `transcripts/trimmed.json` -- Narratio resolves `normalized_transcript` from normalize manifest output (`transcript_normalized`) or fallback `transcripts/normalized.json` +- Narratio resolves transcript inputs from the artifact resolver (manifest producer outputs first, then canonical session paths) - missing trimmed transcript fails clearly and advises running trim stage first - `normalized_transcript` is the preferred full-transcript source for future table/meta-analysis artifacts - `processed_transcript` remains supported for advanced/debug use cases diff --git a/docs/development/workspace-implementation-plan.md b/docs/development/workspace-implementation-plan.md index e82b43d..f928c45 100644 --- a/docs/development/workspace-implementation-plan.md +++ b/docs/development/workspace-implementation-plan.md @@ -1,507 +1,130 @@ -# Workspace Architecture Implementation Plan (Audit) +# Workspace Architecture Implementation Plan (Status) -## 1. Executive Summary +This document tracks the implemented workspace architecture and remaining work for v1.0. -**Complexity assessment:** **heavy**. +## Current Architecture (Implemented) -This is not a single path-helper refactor. The current codebase has a hybrid session/run model that works for current behavior, but diverges from `docs/development/workspace.md` in foundational places (workspace root shape, manifest responsibilities, stage output placement, and archive symmetry). +Narratio now uses a canonical campaign-aware local layout: -Highest-risk areas: +```text +{workspace.root}/work/{campaign_id}/{session_id}/ + manifest.json + current/ + manifest.json + run_id.txt + inputs/ + transcripts/ + artifacts/ + reports/ + logs/ + config/ + runs/ + {run_id}/ + manifest.json + {stage}/ + outputs/ + logs/ + reports/ + config/ + scratch/ +``` -1. Splitting the current single manifest model into durable **session manifest** vs per-invocation **run manifest** without regressing skip/force/resume UX. -2. Migrating path helpers and artifact-store interfaces from session-only roots (`work/{session}`) to campaign-aware roots (`work/{campaign}/{session}`) while preserving existing runs. -3. Introducing run-local stage outputs + immediate promotion while keeping stage tests and archive behavior stable. -4. Avoiding stale downstream skips after forced upstream reruns. +Core behavior: -Surprising findings: +- Session manifest remains the skip/resume source of truth. +- Each invocation creates a run manifest at `runs/{run_id}/manifest.json`. +- Stage execution writes run-local artifacts and promotes durable outputs to canonical session paths. +- Archive uploads run records under `runs/{run_id}/`, applies promotion rules, then publishes `current/manifest.json` and `current/run_id.txt`. +- Analyze input resolution uses centralized artifact IDs with alias support. +- Forced upstream reruns mark downstream succeeded stages `stale` so later runs do not skip stale outputs. -1. Code already has campaign/run-aware helpers (`SessionRunWorkDir`, `SessionSpoolAudioDir`) but core session helpers and manifest pathing remain campaign-unaware. -2. Archive recently gained run/session fallback behavior for manifest/promotion sources, which confirms an existing hybrid-layout pressure point. -3. Analyze input resolution is functional but ad hoc and stage-local; there is no centralized artifact registry/resolver. +## Section 4 Sequence Status ---- +### Step 1: Campaign-aware session path model -## 2. Current-State Map +Status: complete. -## 2.1 Workspace Path Construction +Implemented: -Primary path model: +- Campaign-aware session and run path helpers. +- Campaign-aware artifact-store layout APIs. +- Canonical session manifest pathing under `work/{campaign}/{session}`. -- [`internal/artifacts/paths.go`](../../internal/artifacts/paths.go) - - `SessionWorkDir(rootDir, sessionID)` => `{root}/work/{session_id}` - - `buildSessionPaths(workspaceRoot, sessionID)` roots all canonical paths under `{root}/work/{session_id}` - - `SessionRunWorkDir(rootDir, campaign, sessionID, runID)` exists, but is not the default session root helper. +### Step 2: Session manifest + run manifest scaffolding -Artifact store abstraction: +Status: complete. -- [`internal/artifacts/store.go`](../../internal/artifacts/store.go) - - `SessionPaths(sessionID string)` / `EnsureLayout(sessionID string)` are session-id-only (no campaign argument). -- [`internal/artifacts/local.go`](../../internal/artifacts/local.go) - - `EnsureLayout` creates session-level folders under `SessionWorkDir`. +Implemented: -Path normalization helper: +- Invocation-scoped run manifest type and store methods. +- Runner creates/saves run manifests per invocation. +- Session manifest remains authoritative for idempotent stage skipping. -- [`internal/artifacts/resolve.go`](../../internal/artifacts/resolve.go) - - `ResolveSessionLocalPathForRead` accepts absolute/workspace/session-relative values and probes filesystem. +### Step 3: Run-local stage execution + promotion -Where campaign-aware/run-aware support exists: +Status: complete. -- Local run path helpers: `SessionRunWorkDir`, `SessionSpoolAudioDir`. -- S3 key helpers: [`internal/artifacts/s3_keys.go`](../../internal/artifacts/s3_keys.go) (`campaigns/sessions/runs/current`). +Implemented: -Where session-root assumptions remain `{workspace}/work/{session}`: +- Run-local stage directory layout under `runs/{run_id}/{stage}`. +- Shared helpers for run-local output mapping and promotion to canonical durable paths. +- Producer run provenance recorded on durable artifact outputs. -- Manifest path computation: [`internal/app/runner.go`](../../internal/app/runner.go) `manifestPathFor`. -- Artifact store layout and most stage `paths := env.ArtifactStore.SessionPaths(sessionID)` calls. -- Many tests hardcode `workspace/work//...` (examples below). +### Step 4: Archive alignment -Manual/ad hoc path construction (not through a single resolver API): +Status: complete. -- Common stage patterns: `filepath.Join(paths.TranscriptsDir, "...")`, `filepath.Join(paths.ArtifactsDir, "...")`, etc. -- `prepare` run/work selection: [`internal/stage/prepare.go`](../../internal/stage/prepare.go) `pathsWorkDirForManifest`. -- Analyze input fallback path resolution: `resolveInputPathForRead` in [`internal/stage/analyze.go`](../../internal/stage/analyze.go). +Implemented: -## 2.2 Manifest and Run Identity +- Canonical run-root/session-root resolution. +- Deterministic run-file collection and promotion source resolution. +- Current-pointer publication ordering retained (`current/manifest.json` then `current/run_id.txt`). -Current manifest model: +### Step 5: Artifact registry/resolver (analyze first consumer) -- [`internal/manifest/manifest.go`](../../internal/manifest/manifest.go) `Manifest` includes both session and run fields: - - `SessionID`, `Campaign` - - `RunID`, `LocalWorkDir`, `LocalSpoolDir` - - `S3Bucket`, `S3SessionPrefix`, `S3RunPrefix` - - `Stages`, `Inputs`, stage outputs/logs/generated configs/metadata. +Status: complete. -Current persistence: +Implemented: -- [`internal/manifest/store.go`](../../internal/manifest/store.go) `LocalStore` reads/writes one JSON manifest path. -- Runner always loads/saves one manifest path via `manifestPathFor(cfg)` (session-root path under current layout). - -Identity initialization: - -- [`internal/app/runner.go`](../../internal/app/runner.go) `ensureManifestIdentity` populates run fields if absent. -- `RunID` is generated once for an empty manifest and reused thereafter (hybrid semantics). - -Interpretation today: - -- Best described as a **hybrid session manifest** with run identity fields, not as distinct session + run manifests. - -What this means for redesign: - -- Session-vs-run split is not just file relocation; it requires new responsibilities and write flows. -- A backward-compatible evolution path is possible by: - - preserving current fields in session manifest for migration/read-compat, - - adding explicit run-manifest type + path, - - gradually moving invocation-specific details to run manifests. - -## 2.3 Stage Output Paths (Current) - -All implemented stages currently write canonical artifacts directly into session-level `paths.*` roots (under current session root), with logs/configs typically also session-level. - -1. `prepare` ([`internal/stage/prepare.go`](../../internal/stage/prepare.go)) -- Inputs copied to `inputs/` (`session.yml`, `pipeline.resolved.yml`, `speakers.yml`, `autocorrect.yml`, `glossary.yml`) -- Audio copied to `audio/` -- Manifest input provenance recorded in `m.Inputs` -- S3 audio uses run-scoped spool/work helpers when `RunID` is present. - -2. `transcribe` ([`internal/stage/transcribe.go`](../../internal/stage/transcribe.go)) -- Outputs: `transcripts/raw/{speaker}.json` -- Stage metadata only (no stage logs/config files produced here). - -3. `merge` ([`internal/stage/merge.go`](../../internal/stage/merge.go)) -- Pre-normalize intermediates: `transcripts/raw/normalized/{basename}.normalized.json` -- Merge output: `transcripts/merged.json` -- Report: `artifacts/seriatim.report.json` (if enabled) -- Logs/configs: - - per-input normalize logs/configs in session-level `logs/` + `config/` - - merge logs/config in session-level `logs/` + `config/` - -4. `polish` ([`internal/stage/polish.go`](../../internal/stage/polish.go)) -- Output: `transcripts/processed.json` -- Report: `artifacts/audita.report.json` (if enabled) -- Work dir: `artifacts/audita-work` -- Logs/config: `logs/audita.*`, `config/audita.generated.yml` - -5. `normalize` ([`internal/stage/normalize.go`](../../internal/stage/normalize.go)) -- Output: `transcripts/normalized.json` (configurable) -- Report: `artifacts/seriatim.normalize.report.json` (if enabled) -- Logs/config: `logs/seriatim.normalize.*`, `config/seriatim.normalize.generated.yml` - -6. `trim` ([`internal/stage/trim.go`](../../internal/stage/trim.go)) -- Output: `transcripts/trimmed.json` (configurable) -- Bounds output path from config (default examples use `artifacts/session_bounds.json`) -- Logs/configs in session-level `logs/` and `config/` for scriptorium + seriatim invocations - -7. `analyze` ([`internal/stage/analyze.go`](../../internal/stage/analyze.go)) -- Current implemented artifact: `artifacts/session_recap.md` -- Logs/config in session-level `logs/` + `config/` -- Optional render diagnostics under session-level artifacts/logs/config. - -8. `archive` ([`internal/stage/archive.go`](../../internal/stage/archive.go)) -- Reads from a "workDir" derived by `archiveWorkDir`: - - prefers `m.LocalWorkDir` if it exists, - - else tries run-scoped campaign/session/run path, - - else falls back to legacy session path. -- Uploads run record and promotions. -- Current code now resolves manifest and promotion sources via run/session fallback. - -9. `notify` -- Placeholder only in [`internal/stage/placeholders.go`](../../internal/stage/placeholders.go); no durable outputs. - -Durable-vs-diagnostic split today: - -- Durable artifacts and diagnostics are mixed at session level. -- No run-local stage directories exist yet. - -## 2.4 Idempotency / Force / Resume / Sparse Runs - -Run control implementation: - -- [`internal/app/run_control.go`](../../internal/app/run_control.go) - - skip rule: `!force && stageSucceeded(manifest, stage)` - - stale detection TODO only; no invalidation logic. - -Command behavior: - -- `run`: full plan through `executeStages`. -- `run-stage`: single selected stage through `executeStages`. -- `resume`: starts at first non-succeeded stage unless `--force`. - -Current assumptions: - -- Command invocation mutates a single durable workspace + single manifest for the session path model. -- There is no per-invocation run manifest write path. - -Smallest safe UX-preserving invariant to keep during migration: - -- Session manifest remains source-of-truth for skip decisions across invocations. - -## 2.5 Archive and S3 Alignment - -S3 semantics are relatively mature: - -- [`internal/artifacts/s3_keys.go`](../../internal/artifacts/s3_keys.go) - - session prefix + run prefix + `current/manifest.json` + `current/run_id.txt`. - -Archive stage behavior: - -- uploads run records under run prefix, -- uploads promoted session outputs, -- uploads current manifest then current run pointer, -- current pointer is commit marker, -- required promotions fail; optional promotions skipped. - -Local-vs-remote mismatch still present: - -- Local canonical session root currently defaults to `work/{session}` (artifact store), -- while archive/run helpers expect campaign-aware run locations (`work/{campaign}/{session}/{run}`), -- causing fallback logic and hybrid handling in `archive`. - -## 2.6 Artifact Source Resolution in Analyze - -Current implementation is stage-local and ad hoc: - -- Transcript discoverers: - - `discoverProcessedTranscript` - - `discoverNormalizedTranscript` - - `discoverTrimmedTranscript` -- Input source switch in `resolveScriptoriumInput` supports: - - `processed_transcript` - - `normalized_transcript` - - `trimmed_transcript` - - `previous_session_artifact` - -No first-class artifact registry exists yet. Aliases and canonical IDs are not modeled. - -## 2.7 Stale/Invalidation - -- `manifest.StageStatus` already defines `stale` (`internal/manifest/status.go`), but no stage uses it. -- Skip logic ignores stale state and only checks `succeeded`. -- Forced upstream rerun does not invalidate downstream stage success markers. - -## 2.8 Test Coverage Relevant to Redesign - -High-value existing coverage: - -- Workspace helpers: - - [`internal/artifacts/paths_model_test.go`](../../internal/artifacts/paths_model_test.go) - - [`internal/artifacts/resolve_test.go`](../../internal/artifacts/resolve_test.go) -- Runner semantics: - - [`internal/app/runner_test.go`](../../internal/app/runner_test.go) - - [`internal/app/resume_run_stage_test.go`](../../internal/app/resume_run_stage_test.go) -- Stage path/output behavior: - - `internal/stage/*_test.go` for prepare/transcribe/merge/polish/normalize/trim/analyze/archive -- Archive behavior: - - [`internal/stage/archive_test.go`](../../internal/stage/archive_test.go) - - [`internal/app/post_archive_cleanup_test.go`](../../internal/app/post_archive_cleanup_test.go) - -Tests likely to fail during workspace redesign: - -- Any tests hardcoding `work/{session}` manifest and transcript paths (many in `internal/app/*test.go`, `internal/stage/*test.go`). -- Archive tests assuming current hybrid fallback behavior. - ---- - -## 3. Gap Analysis Against `docs/development/workspace.md` - -| Intended concept | Current status | Notes | -|---|---|---| -| Session root at `work/{campaign}/{session}` | **Partial / mostly absent** | `SessionWorkDir` and artifact store still use `work/{session}`. Campaign-aware run helper exists separately. | -| Distinct session manifest vs run manifest | **Absent** | One hybrid manifest model/file is used. | -| Run-local stage dirs under `runs/{run_id}/{stage}` | **Absent** | Stages write canonical outputs/logs/config directly at session level. | -| Immediate promotion run-local -> session canonical | **Absent** | No run-local staging area to promote from today. | -| Session manifest skip source of truth | **Present** | Skip/resume use single manifest stage statuses. | -| Sparse runs represented under `runs/{run_id}` | **Absent** | No run-manifest/per-run stage records yet. | -| Artifact resolver with canonical IDs + aliases | **Absent** | Analyze resolves via stage-local source-name switch and fallback helpers. | -| Downstream invalidation for forced upstream reruns | **Absent** | TODO only; no stale propagation or status clearing. | -| Local semantics mirror archive semantics | **Partial** | Archive/S3 side models runs/current, local workspace core still session-layout-centric. | -| Safe path helpers centralization | **Partial** | Good helper base exists, but many stage-level manual joins still encode conventions. | - ---- - -## 4. Recommended Implementation Sequence - -## Step 1: Introduce campaign-aware session path model without behavior break - -Purpose: - -- Add first-class helpers for `work/{campaign}/{session}` and make them available everywhere. - -Expected changes: - -- `internal/artifacts`: add/extend path helpers and `SessionPaths` constructor variants that accept campaign. -- `internal/app`: pass campaign into path-model entrypoints where available. - -Behavior change: - -- None initially (can keep legacy fallback reads). - -Tests: - -- Add campaign-aware path-model tests. -- Keep legacy-path compatibility tests. - -Risks: - -- Wide compile-time touch due `Store` interface signatures. - -Rollback: - -- Keep legacy helper wrappers until full migration lands. - -## Step 2: Split manifest responsibilities (session manifest + run manifest scaffolding) - -Purpose: - -- Preserve current UX while introducing explicit run execution records. - -Expected changes: - -- `internal/manifest`: add run manifest type/store helpers. -- `internal/app/runner.go`: create/load session manifest and initialize per-invocation run manifest path. - -Behavior change: - -- Session manifest remains skip truth source. -- Run manifest begins recording invocation metadata/stage actions. - -Tests: - -- New tests for both manifest files existing and being updated correctly. - -Risks: - -- Incorrect ordering of saves can regress crash consistency. - -Rollback: - -- Keep session-manifest-only decision logic until run manifest proves stable. - -## Step 3: Move stage execution products to run-local directories with promotion - -Purpose: - -- Align with workspace architecture (`runs/{run_id}/{stage}/...`) while preserving canonical outputs. - -Expected changes: - -- `internal/stage`: each implemented stage writes outputs/logs/config/reports to run-local paths. -- Introduce shared promotion helpers (atomic copy/rename + output validation + manifest provenance). - -Behavior change: - -- Canonical outputs remain session-level; run-local diagnostics now preserved per run. - -Tests: - -- Stage tests updated to assert run-local outputs + promoted canonical outputs. -- New tests for immediate promotion and producer run metadata. - -Risks: - -- Highest regression risk (all implemented stages touched). - -Rollback: - -- Stage-by-stage migration flag or phased rollout by stage order. - -## Step 4: Archive alignment pass - -Purpose: - -- Remove hybrid fallback complexity once local layout is canonical. - -Expected changes: - -- `internal/stage/archive.go`: resolve sources from canonical session outputs and run manifests deterministically. -- Keep `current/manifest.json` + `current/run_id.txt` publication semantics. - -Behavior change: - -- Simpler source selection; fewer cross-layout heuristics. - -Tests: - -- Archive tests for run uploads, promotions, current pointers, and fallback removal/compat gates. - -Risks: - -- Breaking current mixed-layout compatibility too early. - -Rollback: - -- Retain fallback compatibility for one migration window. - -## Step 5: Artifact registry/resolver (analyze first consumer) - -Purpose: - -- Replace ad hoc analyze source resolution with canonical artifact IDs and aliases. - -Expected changes: - -- New resolver package (for example `internal/artifacts/registry` or `internal/stage/artifactresolve`) with IDs: +- Central artifact resolver with canonical IDs: - `narratio.transcript.merged` - `narratio.transcript.polished` - `narratio.transcript.full` - `narratio.transcript.trimmed` - `narratio.bounds.session` - `narratio.artifact.session_recap` -- Backward-compatible alias map for current source names. +- Backward-compatible aliases: + - `processed_transcript` + - `normalized_transcript` + - `trimmed_transcript` +- Analyze stage switched to resolver-based source resolution. -Behavior change: +### Step 6: Minimal downstream invalidation for forced reruns -- Analyze input resolution becomes centralized and consistent. +Status: complete. -Tests: +Implemented: -- Resolver unit tests for canonical IDs + aliases + missing-input error clarity. -- Analyze tests updated to assert resolver usage. +- Deterministic downstream invalidation based on canonical stage order. +- On forced successful rerun of stage `X`, downstream succeeded stages are marked `stale`. +- Resume and non-forced runs naturally re-execute stale stages. -Risks: +### Step 7: Legacy layout migration strategy -- Input resolution edge cases for `previous_session_artifact` and required/optional handling. +Status: intentionally skipped. -Rollback: +Decision: -- Keep old resolver path behind a temporary compatibility function. +- Automatic migration and legacy fallback compatibility are intentionally not implemented. +- The codebase targets canonical-only local layout behavior. +- Legacy local workspace state, if present, should be recreated or migrated manually outside Narratio. -## Step 6: Minimal downstream invalidation after forced upstream reruns +## Remaining Work (v1.0) -Purpose: +No required workspace/run-history migration steps remain from Section 4. -- Prevent stale downstream skips before full checksum stale detection exists. - -Expected changes: - -- `internal/app/run_control.go` + manifest transition helpers. -- On forced rerun success of stage `X`, clear/mark downstream success states. - -Behavior change: - -- Subsequent runs no longer skip stale downstream stages. - -Tests: - -- New run-control tests for force-induced downstream invalidation. -- Resume tests with forced sparse runs. - -Risks: - -- Over-invalidating too broadly and degrading UX. - -Rollback: - -- Start with deterministic downstream stage list based on pipeline order only. - -## Step 7: Legacy layout migration strategy - -Purpose: - -- Handle existing `work/{session}` data safely. - -Expected changes: - -- Startup detection/migration path in app layer. -- Clear failure messages for ambiguous legacy states. - -Behavior change: - -- Explicit migration semantics instead of implicit fallback drift. - -Tests: - -- Migration detection tests for legacy-only, new-only, and ambiguous layouts. - -Risks: - -- Silent duplication if both layouts are partially populated. - -Rollback: - -- Prefer fail-fast ambiguity policy over auto-merge. - ---- - -## 5. Minimal Viable v1.0 Scope - -Must-have for v1.0: - -1. Campaign-aware canonical session roots. -2. Session manifest remains skip/resume authority. -3. Introduce run manifests + run-local stage records. -4. Immediate promotion from run-local outputs to canonical session outputs. -5. Minimal downstream invalidation on forced upstream rerun. -6. Archive/local semantic alignment with `current/*` behavior preserved. -7. Backward-compatible analyze aliases (`processed_transcript`, `normalized_transcript`, `trimmed_transcript`). - -Nice-to-have / defer if risky: - -1. Full checksum-based stale detection graph. -2. Broad manifest schema-version migration framework. -3. Full artifact-registry rollout beyond analyze’s initial needs. -4. Aggressive cleanup of all legacy fallback branches in one release. - ---- - -## 6. Open Questions (with Recommendations) - -1. **Should campaign be mandatory for all local path derivation immediately?** -- Recommendation: yes for new layout writes; keep controlled read compatibility for legacy session-only roots during migration window. - -2. **Session manifest location transition policy:** auto-migrate vs explicit migrate command? -- Recommendation: if user base is small, prefer explicit fail-fast with actionable migration instructions to avoid silent split-brain state. - -3. **Run manifest granularity:** per-stage detailed records vs summary + references? -- Recommendation: start with summary + stage status/paths; avoid duplicating full session artifact state to keep write path simple. - -4. **Invalidation marking:** use `stale` status now or clear `succeeded` markers? -- Recommendation: if invasive to propagate new status semantics quickly, clear/overwrite downstream success states first; add formal `stale` usage in follow-up. - -5. **Promotion timing:** promote every stage immediately vs delayed at end of run? -- Recommendation: immediate per-stage promotion after validation (matches current idempotent skip expectations and simplifies resume behavior). - -6. **Docs consistency order after implementation starts:** -- Recommendation: update `README.md` and `architecture.md` in lockstep with each migration step. Current conflict is explicit: - - code/README/architecture still primarily describe session roots at `work/{session}` - - `docs/development/workspace.md` defines `work/{campaign}/{session}` plus run manifests. +Possible future enhancements (non-blocking): +- Full checksum/input-graph stale detection. +- Optional retention-policy expansion for run-history cleanup. +- Broader artifact-resolver adoption across additional stage consumers. diff --git a/docs/development/workspace.md b/docs/development/workspace.md index 5c44788..287c838 100644 --- a/docs/development/workspace.md +++ b/docs/development/workspace.md @@ -668,27 +668,18 @@ For v1.0, conservative retention is preferred: * If cleanup is enabled, remove only documented run-scoped or spool-scoped paths. * Local development audio inputs must never be deleted by workspace cleanup. -## 15. Migration From Existing Layout +## 15. Canonical-Only Layout Policy -Existing installations may currently use a simpler path such as: - -```text -{workspace.root}/work/{session_id}/manifest.json -``` - -The v1.0 layout introduces campaign-aware session roots: +Narratio now supports only the canonical campaign-aware layout: ```text {workspace.root}/work/{campaign_id}/{session_id}/manifest.json +{workspace.root}/work/{campaign_id}/{session_id}/runs/{run_id}/... ``` -Migration options: +Legacy session-only layout compatibility is intentionally not implemented. -1. Best-effort automatic discovery of legacy session manifests. -2. A one-time migration command. -3. Clear release notes requiring users to move or regenerate workspace state. - -For v1.0, it is acceptable to require explicit migration if the user base is small and the archive contains the authoritative durable outputs. However, the application should fail clearly when it detects an ambiguous legacy layout rather than silently creating duplicate state. +If legacy workspace data exists, operators should recreate or manually migrate that data outside Narratio before running v1.0 commands. ## 16. Documentation Updates Required diff --git a/docs/integrations/scriptorium.md b/docs/integrations/scriptorium.md index 8bd75b3..da3628a 100644 --- a/docs/integrations/scriptorium.md +++ b/docs/integrations/scriptorium.md @@ -283,9 +283,9 @@ Session recap: ```bash scriptorium run \ --prompt dnd.session_recap \ - --input transcript=/work/session-42/transcript.polished.md \ - --input glossary=/work/session-42/glossary.yml \ - --out /work/session-42/artifacts/session_recap.md + --input transcript=/work/campaign-7/session-42/transcript.polished.md \ + --input glossary=/work/campaign-7/session-42/glossary.yml \ + --out /work/campaign-7/session-42/artifacts/session_recap.md ``` Structured events: @@ -293,8 +293,8 @@ Structured events: ```bash scriptorium run \ --prompt dnd.structured_events \ - --input transcript=/work/session-42/transcript.polished.md \ - --out /work/session-42/artifacts/structured_events.json + --input transcript=/work/campaign-7/session-42/transcript.polished.md \ + --out /work/campaign-7/session-42/artifacts/structured_events.json ``` Glossary suggestions: @@ -302,9 +302,9 @@ Glossary suggestions: ```bash scriptorium run \ --prompt dnd.glossary_suggestions \ - --input transcript=/work/session-42/transcript.polished.md \ - --input previous_recap=/work/session-41/artifacts/session_recap.md \ - --out /work/session-42/artifacts/glossary_suggestions.md + --input transcript=/work/campaign-7/session-42/transcript.polished.md \ + --input previous_recap=/work/campaign-7/session-41/artifacts/session_recap.md \ + --out /work/campaign-7/session-42/artifacts/glossary_suggestions.md ``` Player-facing summary: @@ -312,9 +312,9 @@ Player-facing summary: ```bash scriptorium run \ --prompt dnd.player_summary \ - --input transcript=/work/session-42/transcript.polished.md \ - --input structured_events=/work/session-42/artifacts/structured_events.json \ - --out /work/session-42/artifacts/player_summary.md + --input transcript=/work/campaign-7/session-42/transcript.polished.md \ + --input structured_events=/work/campaign-7/session-42/artifacts/structured_events.json \ + --out /work/campaign-7/session-42/artifacts/player_summary.md ``` ## 21. Non-Goals diff --git a/docs/stages/prepare.md b/docs/stages/prepare.md index c20a1b2..3abfe35 100644 --- a/docs/stages/prepare.md +++ b/docs/stages/prepare.md @@ -56,7 +56,7 @@ When `inputs.audio_s3.prefix` is configured, `prepare`: 4. downloads selected objects to spool audio: - `{spool.root}/{campaign}/{session_id}/{run_id}/audio/` 5. materializes audio into workdir audio: - - `{workspace.root}/work/{campaign}/{session_id}/{run_id}/audio/` + - `{workspace.root}/work/{campaign}/{session_id}/runs/{run_id}/audio/` 6. records input provenance in the manifest (bucket, key, metadata, local paths, checksum) Notes: diff --git a/internal/app/post_archive_cleanup.go b/internal/app/post_archive_cleanup.go index 38df42a..07fe403 100644 --- a/internal/app/post_archive_cleanup.go +++ b/internal/app/post_archive_cleanup.go @@ -60,14 +60,6 @@ func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m strings.TrimSpace(env.Config.Session.SessionID), strings.TrimSpace(m.RunID), ) - if info, err := os.Stat(workDir); err != nil || !info.IsDir() { - workDir = artifacts.SessionRunWorkDir( - env.Config.Pipeline.Workspace.Root, - strings.TrimSpace(env.Config.Session.Campaign), - strings.TrimSpace(env.Config.Session.SessionID), - strings.TrimSpace(m.RunID), - ) - } } if spoolRequested { diff --git a/internal/app/post_archive_cleanup_test.go b/internal/app/post_archive_cleanup_test.go index 4fd193b..ca9c246 100644 --- a/internal/app/post_archive_cleanup_test.go +++ b/internal/app/post_archive_cleanup_test.go @@ -210,7 +210,7 @@ func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) { assertExists(t, seed.spoolAudioDir) assertExists(t, seed.runWorkDir) assertExists(t, filepath.Join(seed.runWorkDir, "manifest.json")) - assertExists(t, artifacts.SessionRunWorkDir(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)) + assertExists(t, artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)) } func TestPostArchiveCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) { @@ -271,8 +271,8 @@ func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) { cfg.Pipeline.Spool.Root = filepath.Join(t.TempDir(), "spool") runID := "20260516T010203Z-1a2b3c4d" - runWorkDir := artifacts.SessionRunWorkDir(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID) - otherRunDir := artifacts.SessionRunWorkDir(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, "20260516T010204Z-5e6f7a8b") + 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) mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n") diff --git a/internal/app/resume.go b/internal/app/resume.go index 2c312c3..a66a6bb 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -84,12 +84,11 @@ func Resume(ctx context.Context, args []string, out io.Writer) error { } func loadManifestIfPresent(ctx context.Context, cfg *config.Config) (*manifest.Manifest, error) { - localStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) - paths, err := localStore.ResolveSessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) - if err != nil { - return nil, fmt.Errorf("resolve session workspace paths: %w", err) - } - path := paths.ManifestPath + path := artifacts.SessionManifestPathForCampaign( + cfg.Pipeline.Workspace.Root, + cfg.Session.Campaign, + cfg.Session.SessionID, + ) exists, err := fileExists(path) if err != nil { return nil, fmt.Errorf("check manifest %q: %w", path, err) diff --git a/internal/artifacts/local.go b/internal/artifacts/local.go index 187cc1d..4dc1fe0 100644 --- a/internal/artifacts/local.go +++ b/internal/artifacts/local.go @@ -30,18 +30,13 @@ func NewLocalStore(workspaceRoot string) *LocalStore { return &LocalStore{WorkspaceRoot: workspaceRoot} } -// SessionPaths resolves legacy paths for a session workdir. -func (s *LocalStore) SessionPaths(sessionID string) SessionPaths { - return buildLegacySessionPaths(s.WorkspaceRoot, sessionID) -} - // SessionPathsFor resolves canonical campaign-aware paths for a session workdir. func (s *LocalStore) SessionPathsFor(campaign, sessionID string) SessionPaths { return buildSessionPaths(s.WorkspaceRoot, campaign, sessionID) } -// ResolveSessionPathsFor resolves the active session path with legacy compatibility. -func (s *LocalStore) ResolveSessionPathsFor(campaign, sessionID string) (SessionPaths, error) { +// EnsureLayoutFor creates and verifies campaign-aware session layout. +func (s *LocalStore) EnsureLayoutFor(campaign, sessionID string) (SessionPaths, error) { if strings.TrimSpace(s.WorkspaceRoot) == "" { return SessionPaths{}, fmt.Errorf("workspace root is required") } @@ -51,50 +46,10 @@ func (s *LocalStore) ResolveSessionPathsFor(campaign, sessionID string) (Session campaign = strings.TrimSpace(campaign) if campaign == "" { - return s.SessionPaths(sessionID), nil + return SessionPaths{}, fmt.Errorf("campaign is required") } - canonical := s.SessionPathsFor(campaign, sessionID) - legacy := s.SessionPaths(sessionID) - canonicalExists, err := dirExists(canonical.Root) - if err != nil { - return SessionPaths{}, fmt.Errorf("check canonical session root %q: %w", canonical.Root, err) - } - legacyExists, err := dirExists(legacy.Root) - if err != nil { - return SessionPaths{}, fmt.Errorf("check legacy session root %q: %w", legacy.Root, err) - } - - switch { - case canonicalExists && legacyExists: - return SessionPaths{}, fmt.Errorf( - "ambiguous session workspace roots for campaign %q session %q: canonical=%q legacy=%q", - campaign, - sessionID, - canonical.Root, - legacy.Root, - ) - case canonicalExists: - return canonical, nil - case legacyExists: - return legacy, nil - default: - return canonical, nil - } -} - -// EnsureLayout creates and verifies the canonical session workdir directory layout. -func (s *LocalStore) EnsureLayout(sessionID string) (SessionPaths, error) { - return s.ensureLayout(s.SessionPaths(sessionID)) -} - -// EnsureLayoutFor creates and verifies campaign-aware session layout, with controlled legacy compatibility. -func (s *LocalStore) EnsureLayoutFor(campaign, sessionID string) (SessionPaths, error) { - paths, err := s.ResolveSessionPathsFor(campaign, sessionID) - if err != nil { - return SessionPaths{}, err - } - return s.ensureLayout(paths) + return s.ensureLayout(s.SessionPathsFor(campaign, sessionID)) } func (s *LocalStore) ensureLayout(paths SessionPaths) (SessionPaths, error) { @@ -129,15 +84,6 @@ func (s *LocalStore) ensureLayout(paths SessionPaths) (SessionPaths, error) { return paths, nil } -// CopyInput copies an input file into the session workdir under destRelativePath. -func (s *LocalStore) CopyInput(sessionID, srcPath, destRelativePath string) (Ref, error) { - paths, err := s.EnsureLayout(sessionID) - if err != nil { - return Ref{}, err - } - return s.copyInputWithPaths(paths, sessionID, srcPath, destRelativePath) -} - // CopyInputFor copies an input file into the campaign-aware session workdir under destRelativePath. func (s *LocalStore) CopyInputFor(campaign, sessionID, srcPath, destRelativePath string) (Ref, error) { paths, err := s.EnsureLayoutFor(campaign, sessionID) @@ -248,15 +194,6 @@ func (s *LocalStore) Checksum(path string) (string, error) { return digest, nil } -// AcquireSessionLock acquires an exclusive lock file for a session workdir. -func (s *LocalStore) AcquireSessionLock(sessionID string) (*LockHandle, error) { - paths, err := s.EnsureLayout(sessionID) - if err != nil { - return nil, err - } - return s.acquireSessionLockForPaths(paths) -} - // AcquireSessionLockFor acquires an exclusive lock file for a campaign/session workdir. func (s *LocalStore) AcquireSessionLockFor(campaign, sessionID string) (*LockHandle, error) { paths, err := s.EnsureLayoutFor(campaign, sessionID) @@ -290,17 +227,6 @@ func (s *LocalStore) acquireSessionLockForPaths(paths SessionPaths) (*LockHandle return &LockHandle{path: paths.LockPath, file: f}, nil } -func dirExists(path string) (bool, error) { - info, err := os.Stat(path) - if err == nil { - return info.IsDir(), nil - } - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - return false, err -} - // ReleaseSessionLock releases a previously acquired session lock. func (s *LocalStore) ReleaseSessionLock(lock *LockHandle) error { if lock == nil { diff --git a/internal/artifacts/local_test.go b/internal/artifacts/local_test.go index 07ae95d..c3c1eb9 100644 --- a/internal/artifacts/local_test.go +++ b/internal/artifacts/local_test.go @@ -36,40 +36,14 @@ func TestEnsureLayoutCreatesExpectedDirectories(t *testing.T) { } } -func TestResolveSessionPathsForLegacyFallback(t *testing.T) { - root := t.TempDir() - store := NewLocalStore(root) - legacyRoot := SessionWorkDir(root, "session-1") - if err := os.MkdirAll(legacyRoot, 0o755); err != nil { - t.Fatalf("MkdirAll() error = %v", err) - } - - paths, err := store.ResolveSessionPathsFor("sample-campaign", "session-1") - if err != nil { - t.Fatalf("ResolveSessionPathsFor() error = %v", err) - } - if paths.Root != legacyRoot { - t.Fatalf("paths.Root = %q, want legacy root %q", paths.Root, legacyRoot) - } -} - -func TestResolveSessionPathsForAmbiguousRoots(t *testing.T) { - root := t.TempDir() - store := NewLocalStore(root) - legacyRoot := SessionWorkDir(root, "session-1") - canonicalRoot := SessionWorkDirForCampaign(root, "sample-campaign", "session-1") - for _, dir := range []string{legacyRoot, canonicalRoot} { - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("MkdirAll(%q) error = %v", dir, err) - } - } - - _, err := store.ResolveSessionPathsFor("sample-campaign", "session-1") +func TestEnsureLayoutForRequiresCampaign(t *testing.T) { + store := NewLocalStore(t.TempDir()) + _, err := store.EnsureLayoutFor("", "session-1") if err == nil { - t.Fatal("expected ambiguity error, got nil") + t.Fatal("expected campaign-required error, got nil") } - if !strings.Contains(err.Error(), "ambiguous session workspace roots") { - t.Fatalf("error = %v, want ambiguity message", err) + if !strings.Contains(err.Error(), "campaign is required") { + t.Fatalf("error = %v, want campaign-required error", err) } } diff --git a/internal/artifacts/paths.go b/internal/artifacts/paths.go index 95b6e03..4aa42d4 100644 --- a/internal/artifacts/paths.go +++ b/internal/artifacts/paths.go @@ -27,11 +27,6 @@ type SessionPaths struct { LockPath string } -// SessionWorkDir returns the legacy work directory for one session. -func SessionWorkDir(rootDir, sessionID string) string { - return filepath.Join(rootDir, config.PathWorkDirSegment, sessionID) -} - // SessionWorkDirForCampaign returns the canonical campaign-aware work directory for one session. func SessionWorkDirForCampaign(rootDir, campaign, sessionID string) string { return filepath.Join(rootDir, config.PathWorkDirSegment, campaign, sessionID) @@ -62,21 +57,11 @@ func SessionRunStageDirForCampaign(rootDir, campaign, sessionID, runID, stageNam return filepath.Join(SessionRunRootForCampaign(rootDir, campaign, sessionID, runID), stageName) } -// SessionRunWorkDir returns the legacy campaign/session/run scoped local work directory. -func SessionRunWorkDir(rootDir, campaign, sessionID, runID string) string { - return filepath.Join(rootDir, config.PathWorkDirSegment, campaign, sessionID, runID) -} - // SessionSpoolAudioDir returns the campaign/session/run scoped local spool audio path. func SessionSpoolAudioDir(spoolRoot, campaign, sessionID, runID string) string { return filepath.Join(spoolRoot, campaign, sessionID, runID, config.PathAudioDirSegment) } -func buildLegacySessionPaths(workspaceRoot, sessionID string) SessionPaths { - root := SessionWorkDir(workspaceRoot, sessionID) - return buildSessionPathsFromRoot(workspaceRoot, "", sessionID, root) -} - func buildSessionPaths(workspaceRoot, campaign, sessionID string) SessionPaths { root := SessionWorkDirForCampaign(workspaceRoot, campaign, sessionID) return buildSessionPathsFromRoot(workspaceRoot, campaign, sessionID, root) diff --git a/internal/artifacts/paths_model_test.go b/internal/artifacts/paths_model_test.go index 480c1e0..3724bf0 100644 --- a/internal/artifacts/paths_model_test.go +++ b/internal/artifacts/paths_model_test.go @@ -5,15 +5,6 @@ import ( "testing" ) -func TestSessionRunWorkDir(t *testing.T) { - root := "/tmp/workspace" - got := SessionRunWorkDir(root, "forsaken", "2026-04-19", "20260515T031522Z-a1b2c3d4") - want := filepath.Join(root, "work", "forsaken", "2026-04-19", "20260515T031522Z-a1b2c3d4") - if got != want { - t.Fatalf("SessionRunWorkDir() = %q, want %q", got, want) - } -} - func TestSessionWorkDirForCampaign(t *testing.T) { root := "/tmp/workspace" got := SessionWorkDirForCampaign(root, "forsaken", "2026-04-19") diff --git a/internal/artifacts/s3.go b/internal/artifacts/s3.go index a0b3843..ab9f0d1 100644 --- a/internal/artifacts/s3.go +++ b/internal/artifacts/s3.go @@ -11,36 +11,16 @@ type S3Store struct { Prefix string } -// SessionPaths is not implemented for S3-backed storage. -func (s *S3Store) SessionPaths(_ string) SessionPaths { - return SessionPaths{} -} - // SessionPathsFor is not implemented for S3-backed storage. func (s *S3Store) SessionPathsFor(_, _ string) SessionPaths { return SessionPaths{} } -// ResolveSessionPathsFor is not implemented for S3-backed storage. -func (s *S3Store) ResolveSessionPathsFor(_, _ string) (SessionPaths, error) { - return SessionPaths{}, fmt.Errorf("artifacts s3 resolve session paths: not yet implemented") -} - -// EnsureLayout returns a not-yet-implemented error in the scaffold. -func (s *S3Store) EnsureLayout(_ string) (SessionPaths, error) { - return SessionPaths{}, fmt.Errorf("artifacts s3 ensure layout: not yet implemented") -} - // EnsureLayoutFor returns a not-yet-implemented error in the scaffold. func (s *S3Store) EnsureLayoutFor(_, _ string) (SessionPaths, error) { return SessionPaths{}, fmt.Errorf("artifacts s3 ensure layout for campaign/session: not yet implemented") } -// CopyInput returns a not-yet-implemented error in the scaffold. -func (s *S3Store) CopyInput(_, _, _ string) (Ref, error) { - return Ref{}, fmt.Errorf("artifacts s3 copy input: not yet implemented") -} - // CopyInputFor returns a not-yet-implemented error in the scaffold. func (s *S3Store) CopyInputFor(_, _, _, _ string) (Ref, error) { return Ref{}, fmt.Errorf("artifacts s3 copy input for campaign/session: not yet implemented") @@ -66,11 +46,6 @@ func (s *S3Store) Checksum(_ string) (string, error) { return "", fmt.Errorf("artifacts s3 checksum: not yet implemented") } -// AcquireSessionLock returns a not-yet-implemented error in the scaffold. -func (s *S3Store) AcquireSessionLock(_ string) (*LockHandle, error) { - return nil, fmt.Errorf("artifacts s3 acquire lock: not yet implemented") -} - // AcquireSessionLockFor returns a not-yet-implemented error in the scaffold. func (s *S3Store) AcquireSessionLockFor(_, _ string) (*LockHandle, error) { return nil, fmt.Errorf("artifacts s3 acquire lock for campaign/session: not yet implemented") diff --git a/internal/artifacts/store.go b/internal/artifacts/store.go index b894780..55d988c 100644 --- a/internal/artifacts/store.go +++ b/internal/artifacts/store.go @@ -15,18 +15,13 @@ type Ref struct { // Store is the local artifact/workdir abstraction used by orchestration code. type Store interface { - SessionPaths(sessionID string) SessionPaths SessionPathsFor(campaign, sessionID string) SessionPaths - ResolveSessionPathsFor(campaign, sessionID string) (SessionPaths, error) - EnsureLayout(sessionID string) (SessionPaths, error) EnsureLayoutFor(campaign, sessionID string) (SessionPaths, error) - CopyInput(sessionID, srcPath, destRelativePath string) (Ref, error) CopyInputFor(campaign, sessionID, srcPath, destRelativePath string) (Ref, error) Exists(path string) (bool, error) ExistsRef(ref Ref) (bool, error) WriteFileAtomic(path string, data []byte, perm os.FileMode) error Checksum(path string) (string, error) - AcquireSessionLock(sessionID string) (*LockHandle, error) AcquireSessionLockFor(campaign, sessionID string) (*LockHandle, error) ReleaseSessionLock(lock *LockHandle) error } diff --git a/internal/stage/archive.go b/internal/stage/archive.go index 63280e9..5d027a0 100644 --- a/internal/stage/archive.go +++ b/internal/stage/archive.go @@ -264,27 +264,14 @@ func resolveArchiveRunRoot(env *Env, m *manifest.Manifest) (string, error) { } canonical := filepath.Clean(artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID, runID)) - legacy := filepath.Clean(artifacts.SessionRunWorkDir(env.Config.Pipeline.Workspace.Root, campaign, sessionID, runID)) - canonicalExists, err := directoryExists(canonical) if err != nil { return "", fmt.Errorf("check canonical run root %q: %w", canonical, err) } - legacyExists, err := directoryExists(legacy) - if err != nil { - return "", fmt.Errorf("check legacy run root %q: %w", legacy, err) - } - - switch { - case canonicalExists && legacyExists && canonical != legacy: - return "", fmt.Errorf("ambiguous run roots for campaign %q session %q run %q: canonical=%q legacy=%q", campaign, sessionID, runID, canonical, legacy) - case canonicalExists: - return canonical, nil - case legacyExists: - return legacy, nil - default: - return "", fmt.Errorf("run root not found for campaign %q session %q run %q (checked canonical=%q legacy=%q)", campaign, sessionID, runID, canonical, legacy) + if !canonicalExists { + return "", fmt.Errorf("run root not found for campaign %q session %q run %q at canonical path %q", campaign, sessionID, runID, canonical) } + return canonical, nil } func resolveArchiveSessionRoot(env *Env, m *manifest.Manifest) (string, error) { @@ -299,11 +286,10 @@ func resolveArchiveSessionRoot(env *Env, m *manifest.Manifest) (string, error) { if sessionID == "" { return "", fmt.Errorf("session id is required") } - paths, err := artifacts.NewLocalStore(env.Config.Pipeline.Workspace.Root).ResolveSessionPathsFor(campaign, sessionID) - if err != nil { - return "", err + if campaign == "" { + return "", fmt.Errorf("campaign is required") } - return filepath.Clean(paths.Root), nil + return filepath.Clean(artifacts.SessionWorkDirForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID)), nil } func archiveRunPrefix(env *Env, m *manifest.Manifest) (string, error) { diff --git a/internal/stage/archive_test.go b/internal/stage/archive_test.go index dcade48..68ab6d7 100644 --- a/internal/stage/archive_test.go +++ b/internal/stage/archive_test.go @@ -183,61 +183,18 @@ func TestArchiveFailsWhenRequiredPromotionMissing(t *testing.T) { } } -func TestArchivePromotionFailsOnAmbiguousSessionRoots(t *testing.T) { - env, m, _ := archiveFixture(t) - sessionWorkDir := artifacts.SessionWorkDir(env.Config.Pipeline.Workspace.Root, m.SessionID) - writeStageTestFile(t, filepath.Join(sessionWorkDir, "transcripts", "trimmed.json"), "{}\n") - - _, err := archiveStage{}.Run(context.Background(), env, m) - if err == nil { - t.Fatal("expected ambiguity error, got nil") - } - if !strings.Contains(err.Error(), "ambiguous session workspace roots") { - t.Fatalf("error = %v, want ambiguity error", err) - } -} - -func TestArchiveFallsBackToLegacyRunRootWhenCanonicalMissing(t *testing.T) { +func TestArchiveFailsWhenCanonicalRunRootMissing(t *testing.T) { env, m, runRoot := archiveFixture(t) - legacyRoot := artifacts.SessionRunWorkDir( - env.Config.Pipeline.Workspace.Root, - env.Config.Session.Campaign, - env.Config.Session.SessionID, - m.RunID, - ) - if err := os.MkdirAll(filepath.Dir(legacyRoot), 0o755); err != nil { - t.Fatalf("create legacy run-root parent: %v", err) - } - if err := os.Rename(runRoot, legacyRoot); err != nil { - t.Fatalf("move canonical run root to legacy root: %v", err) - } - m.LocalWorkDir = legacyRoot - - if _, err := (archiveStage{}).Run(context.Background(), env, m); err != nil { - t.Fatalf("Run() error = %v", err) - } -} - -func TestArchiveFailsOnAmbiguousCanonicalAndLegacyRunRoots(t *testing.T) { - env, m, runRoot := archiveFixture(t) - legacyRoot := artifacts.SessionRunWorkDir( - env.Config.Pipeline.Workspace.Root, - env.Config.Session.Campaign, - env.Config.Session.SessionID, - m.RunID, - ) - writeStageTestFile(t, filepath.Join(legacyRoot, "manifest.json"), "{}\n") - writeStageTestFile(t, filepath.Join(legacyRoot, "prepare", "inputs", "session.yml"), "session_id: 2026-04-19\n") - if runRoot == legacyRoot { - t.Fatalf("test requires distinct canonical and legacy run roots, got %q", runRoot) + if err := os.RemoveAll(runRoot); err != nil { + t.Fatalf("remove run root: %v", err) } _, err := archiveStage{}.Run(context.Background(), env, m) if err == nil { - t.Fatal("expected ambiguity error, got nil") + t.Fatal("expected missing run-root error, got nil") } - if !strings.Contains(err.Error(), "ambiguous run roots") { - t.Fatalf("error = %v, want run-root ambiguity", err) + if !strings.Contains(err.Error(), "run root not found") { + t.Fatalf("error = %v, want missing run-root error", err) } } diff --git a/internal/stage/prepare.go b/internal/stage/prepare.go index de0c695..6526196 100644 --- a/internal/stage/prepare.go +++ b/internal/stage/prepare.go @@ -358,6 +358,10 @@ func pathsWorkDirForManifest(env *Env, m *manifest.Manifest, sessionID string) s if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil { return "" } + campaign := strings.TrimSpace(env.Config.Session.Campaign) + if campaign == "" { + return "" + } if m != nil && strings.TrimSpace(m.LocalWorkDir) != "" { return strings.TrimSpace(m.LocalWorkDir) } @@ -366,15 +370,7 @@ func pathsWorkDirForManifest(env *Env, m *manifest.Manifest, sessionID string) s runID = strings.TrimSpace(m.RunID) } if runID != "" { - campaign := strings.TrimSpace(env.Config.Session.Campaign) - if campaign != "" { - return artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID, runID) - } - return artifacts.SessionRunWorkDir(env.Config.Pipeline.Workspace.Root, campaign, sessionID, runID) - } - campaign := strings.TrimSpace(env.Config.Session.Campaign) - if campaign == "" { - return artifacts.SessionWorkDir(env.Config.Pipeline.Workspace.Root, sessionID) + return artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID, runID) } return artifacts.SessionWorkDirForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID) } diff --git a/internal/stage/prepare_test.go b/internal/stage/prepare_test.go index 16ad7cf..0209e4c 100644 --- a/internal/stage/prepare_test.go +++ b/internal/stage/prepare_test.go @@ -160,7 +160,7 @@ func TestPrepareStageS3AudioDownloadAndMaterialization(t *testing.T) { env.Config.Pipeline.Spool = config.SpoolConfig{Root: filepath.Join(t.TempDir(), "spool")} env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "my-dnd-archive", RootPrefix: "dnd"} m.RunID = "20260515T031522Z-a1b2c3d4" - m.LocalWorkDir = artifacts.SessionRunWorkDir(env.Config.Pipeline.Workspace.Root, "forsaken", m.SessionID, m.RunID) + m.LocalWorkDir = artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, "forsaken", m.SessionID, m.RunID) m.LocalSpoolDir = artifacts.SessionSpoolAudioDir(env.Config.Pipeline.Spool.Root, "forsaken", m.SessionID, m.RunID) fake := &storage.FakeBackend{} @@ -256,7 +256,7 @@ func TestPrepareStageS3AudioFailures(t *testing.T) { env.Config.Pipeline.Spool = config.SpoolConfig{Root: filepath.Join(t.TempDir(), "spool")} env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "my-dnd-archive", RootPrefix: "dnd"} m.RunID = "20260515T031522Z-a1b2c3d4" - m.LocalWorkDir = artifacts.SessionRunWorkDir(env.Config.Pipeline.Workspace.Root, "forsaken", m.SessionID, m.RunID) + m.LocalWorkDir = artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, "forsaken", m.SessionID, m.RunID) m.LocalSpoolDir = artifacts.SessionSpoolAudioDir(env.Config.Pipeline.Spool.Root, "forsaken", m.SessionID, m.RunID) fake := &storage.FakeBackend{} diff --git a/internal/stage/session_paths.go b/internal/stage/session_paths.go index f4004a9..7549aa9 100644 --- a/internal/stage/session_paths.go +++ b/internal/stage/session_paths.go @@ -11,9 +11,6 @@ func sessionPathsForEnv(env *Env, sessionID string) artifacts.SessionPaths { if env != nil && env.Config != nil && env.Config.Session != nil { campaign = strings.TrimSpace(env.Config.Session.Campaign) } - if campaign == "" { - return env.ArtifactStore.SessionPaths(sessionID) - } return env.ArtifactStore.SessionPathsFor(campaign, sessionID) } @@ -22,8 +19,5 @@ func ensureLayoutForEnv(env *Env, sessionID string) (artifacts.SessionPaths, err if env != nil && env.Config != nil && env.Config.Session != nil { campaign = strings.TrimSpace(env.Config.Session.Campaign) } - if campaign == "" { - return env.ArtifactStore.EnsureLayout(sessionID) - } return env.ArtifactStore.EnsureLayoutFor(campaign, sessionID) } diff --git a/internal/stage/transcribe_test.go b/internal/stage/transcribe_test.go index fec6415..50ed655 100644 --- a/internal/stage/transcribe_test.go +++ b/internal/stage/transcribe_test.go @@ -233,7 +233,7 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani sessionPath := filepath.Join(cfgDir, "session.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml") - writeFile(t, sessionPath, "session_id: 2026-05-03\n") + writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") @@ -257,6 +257,7 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani }, Session: &config.SessionConfig{ SessionID: "2026-05-03", + Campaign: "sample-campaign", Inputs: config.SessionInputsConfig{ AudioDir: "./audio", SpeakersFile: "./speakers.yml",