diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index b24717c..4c1fbe0 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -70,6 +70,7 @@ Previous-session canonical provenance values include: - Built-ins resolve via manifest producer outputs first, then canonical fallback paths. - Configured `narratio.artifact.` sources resolve through catalog availability. - Canonical previous-session sources resolve to current-session `previous/` cache candidates derived from configured artifact canonical output paths. +- Archive-relative configured artifact paths under `artifacts/` are cached without a redundant nested `artifacts/` segment. - Previous-session canonical resolution prefers manifest-recorded input paths when present, then filesystem fallback under `previous/artifacts/**`. ## Previous-session requirement scanning diff --git a/docs/internal/stage-prepare.md b/docs/internal/stage-prepare.md index 6becd7c..856fb89 100644 --- a/docs/internal/stage-prepare.md +++ b/docs/internal/stage-prepare.md @@ -82,6 +82,7 @@ Does not own: - clears managed `previous/` state; - hydrates required/optional previous artifacts from the configured previous session’s committed archive current state; - writes `previous/manifest.json` and hydrated `previous/artifacts/**`; + - stores archive-relative artifact paths such as `artifacts/session_recap.md` as `previous/artifacts/session_recap.md`, not `previous/artifacts/artifacts/session_recap.md`; - records hydrated previous inputs in `manifest.Inputs` with source `previous_session_archive.current`. - If no canonical previous-session requirements exist, prepare does not manage `previous/`. - `manifest.Inputs` is sorted deterministically by `(kind, path)`. diff --git a/internal/artifacts/artifact_resolver_test.go b/internal/artifacts/artifact_resolver_test.go index 8f7c021..156d50a 100644 --- a/internal/artifacts/artifact_resolver_test.go +++ b/internal/artifacts/artifact_resolver_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -409,6 +410,17 @@ func TestResolvePreviousSessionArtifactWithCatalogFallsBackToPreparedCachePath(t } } +func TestPreviousSessionCacheCandidatePathsStripsArtifactsPrefix(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + + got := previousSessionCacheCandidatePaths(paths, "artifacts/session_recap.md") + want := []string{filepath.Join(paths.PreviousArtifactsDir, "session_recap.md")} + if !reflect.DeepEqual(got, want) { + t.Fatalf("previousSessionCacheCandidatePaths() = %#v, want %#v", got, want) + } +} + func TestResolvePreviousSessionArtifactWithCatalogMissingReturnsTypedError(t *testing.T) { workspace := t.TempDir() paths := buildSessionPaths(workspace, "campaign", "session") diff --git a/internal/artifacts/paths.go b/internal/artifacts/paths.go index 486fab6..9de9a38 100644 --- a/internal/artifacts/paths.go +++ b/internal/artifacts/paths.go @@ -2,6 +2,7 @@ package artifacts import ( "path/filepath" + "strings" "gitea.maximumdirect.net/eric/narratio/internal/config" ) @@ -64,7 +65,7 @@ func SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID string) func SessionPreviousArtifactPathForCampaign(rootDir, campaign, sessionID, artifactRelativePath string) string { return filepath.Join( SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID), - filepath.FromSlash(artifactRelativePath), + filepath.FromSlash(previousArtifactCacheRelativePath(artifactRelativePath)), ) } @@ -105,7 +106,19 @@ func SessionPreviousArtifactsDir(paths SessionPaths) string { // 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)) + return filepath.Join(paths.PreviousArtifactsDir, filepath.FromSlash(previousArtifactCacheRelativePath(artifactRelativePath))) +} + +func previousArtifactCacheRelativePath(artifactRelativePath string) string { + rel := filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.TrimSpace(artifactRelativePath)))) + if rel == "." { + return "" + } + const artifactsPrefix = "artifacts/" + if strings.HasPrefix(rel, artifactsPrefix) && len(rel) > len(artifactsPrefix) { + return strings.TrimPrefix(rel, artifactsPrefix) + } + return rel } func buildSessionPaths(workspaceRoot, campaign, sessionID string) SessionPaths { diff --git a/internal/artifacts/paths_model_test.go b/internal/artifacts/paths_model_test.go index 5cfeb38..eac78ac 100644 --- a/internal/artifacts/paths_model_test.go +++ b/internal/artifacts/paths_model_test.go @@ -74,6 +74,11 @@ func TestSessionPreviousPathsForCampaign(t *testing.T) { if artifactPath != wantArtifactPath { t.Fatalf("SessionPreviousArtifactPathForCampaign() = %q, want %q", artifactPath, wantArtifactPath) } + + archiveRelativeArtifactPath := SessionPreviousArtifactPathForCampaign(root, "forsaken", "2026-04-19", "artifacts/session_recap.md") + if archiveRelativeArtifactPath != wantArtifactPath { + t.Fatalf("SessionPreviousArtifactPathForCampaign(archive-relative) = %q, want %q", archiveRelativeArtifactPath, wantArtifactPath) + } } func TestSessionPreviousPathsFromSessionPaths(t *testing.T) { @@ -93,6 +98,12 @@ func TestSessionPreviousPathsFromSessionPaths(t *testing.T) { if got != want { t.Fatalf("SessionPreviousArtifactPath() = %q, want %q", got, want) } + + got = SessionPreviousArtifactPath(paths, "artifacts/session_recap.md") + want = filepath.Join(paths.PreviousArtifactsDir, "session_recap.md") + if got != want { + t.Fatalf("SessionPreviousArtifactPath(archive-relative) = %q, want %q", got, want) + } } func TestSessionSpoolAudioDir(t *testing.T) { diff --git a/internal/stage/prepare_previous_test.go b/internal/stage/prepare_previous_test.go index 0786baf..304215f 100644 --- a/internal/stage/prepare_previous_test.go +++ b/internal/stage/prepare_previous_test.go @@ -50,6 +50,10 @@ func TestHydratePreviousSessionArtifactsDownloadsManifestAndRequiredArtifact(t * if _, err := os.Stat(recapPath); err != nil { t.Fatalf("previous artifact missing: %v", err) } + nestedRecapPath := filepath.Join(sessionPaths.PreviousArtifactsDir, "artifacts", "session_recap.md") + if _, err := os.Stat(nestedRecapPath); !os.IsNotExist(err) { + t.Fatalf("nested previous artifact should not be created, stat err = %v", err) + } manifestInput := findInputByKind(result.Inputs, preparePreviousInputKindManifest) if manifestInput == nil { diff --git a/internal/stage/prepare_test.go b/internal/stage/prepare_test.go index 568f9fa..7b6ff81 100644 --- a/internal/stage/prepare_test.go +++ b/internal/stage/prepare_test.go @@ -441,6 +441,10 @@ func TestPrepareStageHydratesRequiredPreviousArtifactAndRecordsInputs(t *testing if _, err := os.Stat(recapPath); err != nil { t.Fatalf("expected previous artifact: %v", err) } + nestedRecapPath := filepath.Join(paths.PreviousArtifactsDir, "artifacts", "session_recap.md") + if _, err := os.Stat(nestedRecapPath); !os.IsNotExist(err) { + t.Fatalf("nested previous artifact should not be created, stat err = %v", err) + } var hasPreviousManifest, hasPreviousArtifact bool for _, in := range m.Inputs {