From b907cb01aad2367f6460999705cee46ae192c056 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 20 May 2026 14:31:03 +0000 Subject: [PATCH] Add previous-session workspace path helpers and layout support --- docs/internal/workspace.md | 4 +- internal/app/post_archive_cleanup_test.go | 31 +++++++++----- internal/artifacts/local.go | 2 + internal/artifacts/local_test.go | 5 +++ internal/artifacts/paths.go | 49 +++++++++++++++++++++++ internal/artifacts/paths_model_test.go | 46 +++++++++++++++++++++ internal/config/defaults.go | 1 + internal/stage/run_local.go | 4 ++ internal/stage/run_local_test.go | 21 ++++++++++ 9 files changed, 152 insertions(+), 11 deletions(-) diff --git a/docs/internal/workspace.md b/docs/internal/workspace.md index 04001e2..c356df6 100644 --- a/docs/internal/workspace.md +++ b/docs/internal/workspace.md @@ -17,7 +17,8 @@ Outputs: ## Boundaries Owns: -- Session-level path layout (`inputs/`, `audio/`, `transcripts/`, `artifacts/`, `reports/`, `logs/`, `config/`, `current/`, `runs/`) +- Session-level path layout (`inputs/`, `audio/`, `transcripts/`, `artifacts/`, `reports/`, `logs/`, `config/`, `current/`, `runs/`, `previous/`) + - `previous/manifest.json` and `previous/artifacts/**` are reserved for prepared previous-session state - Run-local stage sandbox layout under `runs/{run_id}/{stage}/` - Session lock acquisition/release (`.lock`) @@ -65,4 +66,5 @@ None directly in this subsystem. Stages may use object storage adapters and then - Session root is campaign-aware: `{workspace.root}/work/{campaign}/{session_id}`. - Run roots are always nested: `runs/{run_id}` under the session root. - Run-local output promotion must end in canonical session paths. +- `previous/**` is session-durable state and must not be treated as run-local output scratch state. - Cleanup only targets run-scoped directories and must never delete configured root directories. diff --git a/internal/app/post_archive_cleanup_test.go b/internal/app/post_archive_cleanup_test.go index 1f363e4..e85e618 100644 --- a/internal/app/post_archive_cleanup_test.go +++ b/internal/app/post_archive_cleanup_test.go @@ -82,6 +82,7 @@ func TestPostArchiveCleanupWorkdirOnly(t *testing.T) { assertExists(t, cfg.Pipeline.Workspace.Root) assertExists(t, seed.otherRunDir) + assertExists(t, seed.previousCachePath) assertMissing(t, seed.runWorkDir) assertExists(t, seed.spoolAudioDir) } @@ -98,6 +99,7 @@ func TestPostArchiveCleanupBothPolicies(t *testing.T) { assertMissing(t, seed.spoolAudioDir) assertMissing(t, seed.runWorkDir) assertExists(t, seed.otherRunDir) + assertExists(t, seed.previousCachePath) } func TestPostArchiveCleanupNotRunWhenArchiveFails(t *testing.T) { @@ -256,11 +258,12 @@ func TestPostArchiveCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) { } type cleanupSeed struct { - runWorkDir string - otherRunDir string - spoolAudioDir string - localSourceAudio string - sessionPrefix string + runWorkDir string + otherRunDir string + spoolAudioDir string + localSourceAudio string + previousCachePath string + sessionPrefix string } func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) { @@ -274,11 +277,18 @@ func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) { 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 := artifacts.SessionPreviousArtifactPathForCampaign( + cfg.Pipeline.Workspace.Root, + cfg.Session.Campaign, + cfg.Session.SessionID, + "session_recap.md", + ) mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n") 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") @@ -301,11 +311,12 @@ func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) { } return cfg, cleanupSeed{ - runWorkDir: runWorkDir, - otherRunDir: otherRunDir, - spoolAudioDir: spoolAudioDir, - localSourceAudio: localSourceAudio, - sessionPrefix: seed.S3SessionPrefix, + runWorkDir: runWorkDir, + otherRunDir: otherRunDir, + spoolAudioDir: spoolAudioDir, + localSourceAudio: localSourceAudio, + previousCachePath: previousCachePath, + sessionPrefix: seed.S3SessionPrefix, } } diff --git a/internal/artifacts/local.go b/internal/artifacts/local.go index 4dc1fe0..c823672 100644 --- a/internal/artifacts/local.go +++ b/internal/artifacts/local.go @@ -73,6 +73,8 @@ func (s *LocalStore) ensureLayout(paths SessionPaths) (SessionPaths, error) { paths.LogsDir, paths.CurrentDir, paths.RunsDir, + paths.PreviousDir, + paths.PreviousArtifactsDir, } for _, dir := range dirs { diff --git a/internal/artifacts/local_test.go b/internal/artifacts/local_test.go index c3c1eb9..de52b72 100644 --- a/internal/artifacts/local_test.go +++ b/internal/artifacts/local_test.go @@ -27,10 +27,15 @@ func TestEnsureLayoutCreatesExpectedDirectories(t *testing.T) { checkDirExists(t, paths.LogsDir) checkDirExists(t, paths.CurrentDir) checkDirExists(t, paths.RunsDir) + checkDirExists(t, paths.PreviousDir) + checkDirExists(t, paths.PreviousArtifactsDir) if filepath.Base(paths.ManifestPath) != "manifest.json" { t.Fatalf("ManifestPath = %q, want basename manifest.json", paths.ManifestPath) } + if filepath.Base(paths.PreviousManifestPath) != "manifest.json" { + t.Fatalf("PreviousManifestPath = %q, want basename manifest.json", paths.PreviousManifestPath) + } if filepath.Base(paths.LockPath) != ".lock" { t.Fatalf("LockPath = %q, want basename .lock", paths.LockPath) } diff --git a/internal/artifacts/paths.go b/internal/artifacts/paths.go index 4aa42d4..486fab6 100644 --- a/internal/artifacts/paths.go +++ b/internal/artifacts/paths.go @@ -23,6 +23,9 @@ type SessionPaths struct { LogsDir string CurrentDir string RunsDir string + PreviousDir string + PreviousManifestPath string + PreviousArtifactsDir string ManifestPath string LockPath string } @@ -42,6 +45,29 @@ func SessionRunsDirForCampaign(rootDir, campaign, sessionID string) string { return filepath.Join(SessionWorkDirForCampaign(rootDir, campaign, sessionID), config.PathRunsDirSegment) } +// SessionPreviousDirForCampaign returns the canonical previous-session state directory. +func SessionPreviousDirForCampaign(rootDir, campaign, sessionID string) string { + return filepath.Join(SessionWorkDirForCampaign(rootDir, campaign, sessionID), config.PathPreviousDirSegment) +} + +// SessionPreviousManifestPathForCampaign returns the canonical previous-session manifest cache path. +func SessionPreviousManifestPathForCampaign(rootDir, campaign, sessionID string) string { + return filepath.Join(SessionPreviousDirForCampaign(rootDir, campaign, sessionID), config.PathManifestFile) +} + +// SessionPreviousArtifactsDirForCampaign returns the canonical previous-session artifact cache directory. +func SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID string) string { + return filepath.Join(SessionPreviousDirForCampaign(rootDir, campaign, sessionID), config.PathArtifactsDirSegment) +} + +// SessionPreviousArtifactPathForCampaign returns a path under previous/artifacts for one artifact. +func SessionPreviousArtifactPathForCampaign(rootDir, campaign, sessionID, artifactRelativePath string) string { + return filepath.Join( + SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID), + filepath.FromSlash(artifactRelativePath), + ) +} + // SessionRunRootForCampaign returns the canonical run root under runs/{run_id}. func SessionRunRootForCampaign(rootDir, campaign, sessionID, runID string) string { return filepath.Join(SessionRunsDirForCampaign(rootDir, campaign, sessionID), runID) @@ -62,6 +88,26 @@ func SessionSpoolAudioDir(spoolRoot, campaign, sessionID, runID string) string { return filepath.Join(spoolRoot, campaign, sessionID, runID, config.PathAudioDirSegment) } +// SessionPreviousDir returns the previous-session state directory for already-resolved session paths. +func SessionPreviousDir(paths SessionPaths) string { + return paths.PreviousDir +} + +// SessionPreviousManifestPath returns the previous-session manifest path for already-resolved session paths. +func SessionPreviousManifestPath(paths SessionPaths) string { + return paths.PreviousManifestPath +} + +// SessionPreviousArtifactsDir returns the previous-session artifact directory for already-resolved session paths. +func SessionPreviousArtifactsDir(paths SessionPaths) string { + return paths.PreviousArtifactsDir +} + +// SessionPreviousArtifactPath returns a path under previous/artifacts for already-resolved session paths. +func SessionPreviousArtifactPath(paths SessionPaths, artifactRelativePath string) string { + return filepath.Join(paths.PreviousArtifactsDir, filepath.FromSlash(artifactRelativePath)) +} + func buildSessionPaths(workspaceRoot, campaign, sessionID string) SessionPaths { root := SessionWorkDirForCampaign(workspaceRoot, campaign, sessionID) return buildSessionPathsFromRoot(workspaceRoot, campaign, sessionID, root) @@ -84,6 +130,9 @@ func buildSessionPathsFromRoot(workspaceRoot, campaign, sessionID, root string) LogsDir: filepath.Join(root, config.PathLogsDirSegment), CurrentDir: filepath.Join(root, config.PathCurrentDirSegment), RunsDir: filepath.Join(root, config.PathRunsDirSegment), + PreviousDir: filepath.Join(root, config.PathPreviousDirSegment), + PreviousManifestPath: filepath.Join(root, config.PathPreviousDirSegment, config.PathManifestFile), + PreviousArtifactsDir: filepath.Join(root, config.PathPreviousDirSegment, config.PathArtifactsDirSegment), ManifestPath: filepath.Join(root, config.PathManifestFile), LockPath: filepath.Join(root, config.PathLockFile), } diff --git a/internal/artifacts/paths_model_test.go b/internal/artifacts/paths_model_test.go index 3724bf0..5cfeb38 100644 --- a/internal/artifacts/paths_model_test.go +++ b/internal/artifacts/paths_model_test.go @@ -49,6 +49,52 @@ func TestSessionRunManifestPathForCampaign(t *testing.T) { } } +func TestSessionPreviousPathsForCampaign(t *testing.T) { + root := "/tmp/workspace" + previousDir := SessionPreviousDirForCampaign(root, "forsaken", "2026-04-19") + wantPreviousDir := filepath.Join(root, "work", "forsaken", "2026-04-19", "previous") + if previousDir != wantPreviousDir { + t.Fatalf("SessionPreviousDirForCampaign() = %q, want %q", previousDir, wantPreviousDir) + } + + manifestPath := SessionPreviousManifestPathForCampaign(root, "forsaken", "2026-04-19") + wantManifestPath := filepath.Join(root, "work", "forsaken", "2026-04-19", "previous", "manifest.json") + if manifestPath != wantManifestPath { + t.Fatalf("SessionPreviousManifestPathForCampaign() = %q, want %q", manifestPath, wantManifestPath) + } + + artifactsDir := SessionPreviousArtifactsDirForCampaign(root, "forsaken", "2026-04-19") + wantArtifactsDir := filepath.Join(root, "work", "forsaken", "2026-04-19", "previous", "artifacts") + if artifactsDir != wantArtifactsDir { + t.Fatalf("SessionPreviousArtifactsDirForCampaign() = %q, want %q", artifactsDir, wantArtifactsDir) + } + + artifactPath := SessionPreviousArtifactPathForCampaign(root, "forsaken", "2026-04-19", "session_recap.md") + wantArtifactPath := filepath.Join(root, "work", "forsaken", "2026-04-19", "previous", "artifacts", "session_recap.md") + if artifactPath != wantArtifactPath { + t.Fatalf("SessionPreviousArtifactPathForCampaign() = %q, want %q", artifactPath, wantArtifactPath) + } +} + +func TestSessionPreviousPathsFromSessionPaths(t *testing.T) { + paths := buildSessionPaths("/tmp/workspace", "forsaken", "2026-04-19") + if got := SessionPreviousDir(paths); got != paths.PreviousDir { + t.Fatalf("SessionPreviousDir() = %q, want %q", got, paths.PreviousDir) + } + if got := SessionPreviousManifestPath(paths); got != paths.PreviousManifestPath { + t.Fatalf("SessionPreviousManifestPath() = %q, want %q", got, paths.PreviousManifestPath) + } + if got := SessionPreviousArtifactsDir(paths); got != paths.PreviousArtifactsDir { + t.Fatalf("SessionPreviousArtifactsDir() = %q, want %q", got, paths.PreviousArtifactsDir) + } + + got := SessionPreviousArtifactPath(paths, "quest_log.json") + want := filepath.Join(paths.PreviousArtifactsDir, "quest_log.json") + if got != want { + t.Fatalf("SessionPreviousArtifactPath() = %q, want %q", got, want) + } +} + func TestSessionSpoolAudioDir(t *testing.T) { root := "/var/spool/narratio" got := SessionSpoolAudioDir(root, "forsaken", "2026-04-19", "20260515T031522Z-a1b2c3d4") diff --git a/internal/config/defaults.go b/internal/config/defaults.go index ee9c8a6..51ff93c 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -56,6 +56,7 @@ const ( PathLogsDirSegment = "logs" PathCurrentDirSegment = "current" PathRunsDirSegment = "runs" + PathPreviousDirSegment = "previous" PathManifestFile = "manifest.json" PathLockFile = ".lock" PathTranscriptMerged = "transcripts/merged.json" diff --git a/internal/stage/run_local.go b/internal/stage/run_local.go index 760907c..b290da2 100644 --- a/internal/stage/run_local.go +++ b/internal/stage/run_local.go @@ -7,6 +7,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) @@ -99,6 +100,9 @@ func runLocalPathForCanonical(layout runStageLayout, sessionPaths artifacts.Sess if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", fmt.Errorf("canonical path %q is outside session root %q", cleanCanonical, sessionPaths.Root) } + if rel == config.PathPreviousDirSegment || strings.HasPrefix(rel, config.PathPreviousDirSegment+string(filepath.Separator)) { + return cleanCanonical, nil + } localPath := filepath.Join(layout.OutputsDir, rel) if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { return "", fmt.Errorf("create run-local output parent for %q: %w", localPath, err) diff --git a/internal/stage/run_local_test.go b/internal/stage/run_local_test.go index 18f6ee4..3bbc38b 100644 --- a/internal/stage/run_local_test.go +++ b/internal/stage/run_local_test.go @@ -33,3 +33,24 @@ func TestRunLocalPathForCanonicalCreatesParentDirectories(t *testing.T) { t.Fatalf("expected run-local parent directory to exist: %v", err) } } + +func TestRunLocalPathForCanonicalKeepsPreviousStateSessionDurable(t *testing.T) { + root := t.TempDir() + sessionRoot := filepath.Join(root, "work", "dilfs", "2026-05-17") + layout := runStageLayout{ + Enabled: true, + OutputsDir: filepath.Join(sessionRoot, "runs", "run-1", "prepare", "outputs"), + } + if err := os.MkdirAll(layout.OutputsDir, 0o755); err != nil { + t.Fatalf("mkdir outputs dir: %v", err) + } + + canonical := filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md") + got, err := runLocalPathForCanonical(layout, artifacts.SessionPaths{Root: sessionRoot}, canonical) + if err != nil { + t.Fatalf("runLocalPathForCanonical() error = %v", err) + } + if got != canonical { + t.Fatalf("runLocalPathForCanonical() = %q, want canonical %q", got, canonical) + } +}