diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index 87885fb..0d04525 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -173,17 +173,13 @@ authority markers. For a new-protocol current state, the pointer-selected immutable commit is the complete restore authority. Callers receive its declared object identities and -must not supplement them by listing mutable session prefixes. The legacy reader -is intentionally separate and remains migration-only support. +must not supplement them by listing mutable session prefixes. The reader opens each small control object directly and enforces owner-specific limits before decoding: 64 KiB for the mutable commit pointer, 4 MiB for the -immutable commit manifest, and 8 MiB for the selected session manifest. Legacy -compatibility applies a 4 KiB limit to `current/run_id.txt` and the same 8 MiB -manifest limit to `current/manifest.json`. These are exposed as -`MaxCurrentCommitPointerBytes`, `MaxRemoteCommitManifestBytes`, -`MaxRemoteSessionManifestBytes`, `MaxLegacyCurrentRunPointerBytes`, and -`MaxLegacyCurrentManifestBytes`. +immutable commit manifest, and 8 MiB for the selected session manifest. These +are exposed as `MaxCurrentCommitPointerBytes`, +`MaxRemoteCommitManifestBytes`, and `MaxRemoteSessionManifestBytes`. Each read uses the generation and size metadata returned with its opened body. Actual bytes remain subject to a limit-plus-one read even if size metadata is @@ -228,8 +224,7 @@ Caller policy is intentionally outside artifacts helpers: New publication creates run-scoped immutable objects, including `runs/{run_id}/commit.json` and `runs/{run_id}/session-manifest.json`. The sole mutable selector is `current/commit-pointer.json`; readers verify its selected -commit and declared object generations/checksums. Legacy current-pair loading -is confined to `current_state_legacy.go` for migration only. +commit and declared object generations/checksums. Campaign, session, and Narratio run IDs are validated as portable opaque segments at configuration and artifact boundaries before they can be used in a diff --git a/docs/internal/command-restore.md b/docs/internal/command-restore.md index a73dd79..3d1dee3 100644 --- a/docs/internal/command-restore.md +++ b/docs/internal/command-restore.md @@ -100,9 +100,7 @@ Audio restore path: loaded at discovery; later pointer changes cannot add objects or substitute a different run into its plan; - a verified `current/commit-pointer.json` and its selected immutable commit - establish new-protocol remote commitment; coherent legacy - `current/run_id.txt` plus `current/manifest.json` remains read-only migration - support; + establish remote commitment; when absent, republish the session; - restore does not execute pipeline stages. ## Implementation And Tests diff --git a/docs/internal/manifest.md b/docs/internal/manifest.md index a602b3c..0f47d83 100644 --- a/docs/internal/manifest.md +++ b/docs/internal/manifest.md @@ -153,9 +153,8 @@ binds that object by checksum, size, and generation. Readers strictly reject unknown fields, version mismatches, pointer/commit identity mismatches, and objects that do not match their declaration. -The reader retains a temporary, clearly isolated compatibility path for a -coherent legacy `current/manifest.json` plus `current/run_id.txt` pair. That -path is removable after migration and is never used to write new state. +An absent commit pointer means no remote state is published. Republish to +establish a current immutable snapshot. ## Persistence Semantics diff --git a/docs/internal/stage-publish.md b/docs/internal/stage-publish.md index 18d740c..4acad18 100644 --- a/docs/internal/stage-publish.md +++ b/docs/internal/stage-publish.md @@ -53,7 +53,7 @@ Exact remote placement and the operator workflow belong in - creates one complete immutable source-to-destination mapping before upload; - uploads and verifies every declared immutable object and the commit manifest; - updates `current/commit-pointer.json` exactly once, last; and -- does not write the legacy `current/manifest.json` or `current/run_id.txt` pair. +- writes only the selected `current/commit-pointer.json` control record. - rechecks remote lock state immediately before the pointer update. A newly committed lock aborts selection, leaving any uploaded immutable attempt unselected. diff --git a/docs/operations.md b/docs/operations.md index 253ed71..2767f6e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -379,10 +379,9 @@ commit by version, checksum, size, and storage generation; committed artifacts are also checksum- and generation-bound. Readers accept this contract now and strictly reject mismatched or unknown data. -Legacy reads are limited to a coherent `current/manifest.json` and -`current/run_id.txt` pair; a torn pair is rejected. New publication does not -write that pair and remote commit state does not carry local -`current_pointer_written` metadata. +When the pointer is absent, remote state is not published; republish the +session to establish a committed snapshot. Remote commit state does not carry +local `current_pointer_written` metadata. ## Publish Locks diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 93be24b..10ecb55 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -232,7 +232,7 @@ explicitly derived value. ## Stage 4 — Make Remote Current State Commit-Only -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index d237df1..7c1cc84 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -1078,7 +1078,8 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) { `) fake := &storage.FakeBackend{} sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") - manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix) + manifestKey := artifacts.S3RunSessionManifestKey(sessionPrefix, "20260519T010203Z-a1b2c3d4") + runIDKey := artifacts.S3CurrentCommitPointerKey(sessionPrefix) trimmedKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/final.trimmed.json") fullKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/full.json") lockKey := artifacts.S3SessionLocksKey(sessionPrefix) @@ -1174,7 +1175,7 @@ func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(t *testing. if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } - if !strings.Contains(stdout.String(), "Remote publish: missing or unavailable: remote current run pointer missing") { + if !strings.Contains(stdout.String(), "Remote publish: missing or unavailable: remote current commit pointer missing") { t.Fatalf("stdout = %q, want missing remote current-state line", stdout.String()) } } @@ -1199,7 +1200,7 @@ func TestExecuteStatusReportsPreviousStateReadinessWithoutFailing(t *testing.T) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } - if !strings.Contains(stdout.String(), "Previous-session artifacts: unavailable: remote current run pointer missing") { + if !strings.Contains(stdout.String(), "Previous-session artifacts: unavailable: remote current commit pointer missing") { t.Fatalf("stdout = %q, want previous readiness unavailable line", stdout.String()) } } @@ -1257,8 +1258,8 @@ func TestExecuteSessionValidateReportsPreviousStateFindingAndReturnsFindingError if !strings.Contains(stdout.String(), "ERROR previous") { t.Fatalf("stdout = %q, want previous finding error", stdout.String()) } - if !strings.Contains(stdout.String(), "remote current run pointer missing") { - t.Fatalf("stdout = %q, want missing run pointer finding", stdout.String()) + if !strings.Contains(stdout.String(), "remote current commit pointer missing") { + t.Fatalf("stdout = %q, want missing commit pointer finding", stdout.String()) } if !strings.Contains(stderr.String(), "validation error(s)") { t.Fatalf("stderr = %q, want finding error summary", stderr.String()) diff --git a/internal/app/operator_inspection.go b/internal/app/operator_inspection.go index 71f9a30..96edc7b 100644 --- a/internal/app/operator_inspection.go +++ b/internal/app/operator_inspection.go @@ -171,16 +171,11 @@ func inspectPreviousArtifactReadiness( paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) plan, err := previouscache.Resolve(ctx, cfg, paths, requirements, store) if err != nil { - var pointerMissing *artifacts.CurrentRunPointerMissingError - var manifestMissing *artifacts.CurrentManifestMissingError + var pointerMissing *artifacts.CurrentCommitPointerMissingError if errors.As(err, &pointerMissing) { out.Err = fmt.Errorf("remote %w", pointerMissing) return out } - if errors.As(err, &manifestMissing) { - out.Err = fmt.Errorf("remote %w", manifestMissing) - return out - } out.Err = fmt.Errorf("remote %w", err) return out } diff --git a/internal/app/restore_commit_test.go b/internal/app/restore_commit_test.go index 951ea4e..41dd071 100644 --- a/internal/app/restore_commit_test.go +++ b/internal/app/restore_commit_test.go @@ -300,7 +300,7 @@ func seedCommittedRestoreSnapshot(t *testing.T, cfg *config.Config, fake *storag for _, relative := range paths { key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, runID), relative) fake.SeedObject(storage.FakeObject{Key: key, Data: outputs[relative]}) - artifactsByKey = append(artifactsByKey, remoteRestoreArtifact(fake, artifacts.RemoteArtifactTypePublishedOutput, "narratio.test", key)) + artifactsByKey = append(artifactsByKey, remoteRestoreArtifact(fake, artifacts.RemoteArtifactTypePublishedOutput, "narratio.test."+strings.ReplaceAll(relative, "/", "."), key)) } commit := artifacts.RemoteCommitManifest{ FormatVersion: artifacts.RemoteCommitFormatVersion, diff --git a/internal/app/restore_discovery.go b/internal/app/restore_discovery.go index b2cfdcc..1bf4f33 100644 --- a/internal/app/restore_discovery.go +++ b/internal/app/restore_discovery.go @@ -15,7 +15,6 @@ import ( type RemoteCurrentState struct { Bucket string SessionPrefix string - CurrentRunIDKey string CurrentManifestKey string RunID string SessionID string @@ -55,7 +54,6 @@ func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store s return &RemoteCurrentState{ Bucket: bucket, SessionPrefix: sessionPrefix, - CurrentRunIDKey: current.CurrentRunIDKey, CurrentManifestKey: current.CurrentManifestKey, RunID: current.RunID, SessionID: strings.TrimSpace(current.Manifest.SessionID), diff --git a/internal/app/restore_discovery_test.go b/internal/app/restore_discovery_test.go index 55e5b0b..020bb82 100644 --- a/internal/app/restore_discovery_test.go +++ b/internal/app/restore_discovery_test.go @@ -3,244 +3,27 @@ package app import ( "context" "encoding/json" - "fmt" - "io" - "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" ) -func TestDiscoverRemoteCurrentStateSuccess(t *testing.T) { - cfg := restoreDiscoveryConfig() - store := &storage.FakeBackend{} - sessionPrefix, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) - - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)}) - - state, err := discoverRemoteCurrentState(context.Background(), cfg, store) - if err != nil { - t.Fatalf("discoverRemoteCurrentState() error = %v", err) +func TestDiscoverRemoteCurrentStateRequiresCommittedPointer(t *testing.T) { + cfg := &config.Config{Pipeline: &config.PipelineConfig{Storage: config.StorageConfig{S3: &config.StorageS3Config{Bucket: "test-bucket", RootPrefix: "dnd"}}}, Session: &config.SessionConfig{Campaign: "sample-campaign", SessionID: "2026-05-03"}} + _, err := discoverRemoteCurrentState(context.Background(), cfg, &storage.FakeBackend{}) + if err == nil { + t.Fatal("discoverRemoteCurrentState() error = nil, want missing committed pointer") } - if state.RunID != "20260519T010203Z-a1b2c3d4" { - t.Fatalf("run id = %q, want 20260519T010203Z-a1b2c3d4", state.RunID) - } - if state.SessionPrefix != sessionPrefix { - t.Fatalf("session prefix = %q, want %q", state.SessionPrefix, sessionPrefix) - } - if state.CurrentRunIDKey != runIDKey { - t.Fatalf("current run id key = %q, want %q", state.CurrentRunIDKey, runIDKey) - } - if state.CurrentManifestKey != manifestKey { - t.Fatalf("current manifest key = %q, want %q", state.CurrentManifestKey, manifestKey) - } - if state.Manifest == nil { - t.Fatal("manifest is nil") - } -} - -func TestDiscoverRemoteCurrentStateMissingRunPointerFails(t *testing.T) { - cfg := restoreDiscoveryConfig() - store := &storage.FakeBackend{} - - _, err := discoverRemoteCurrentState(context.Background(), cfg, store) - if err == nil || !strings.Contains(err.Error(), "remote current run pointer missing") { - t.Fatalf("error = %v, want missing run pointer failure", err) - } -} - -func TestDiscoverRemoteCurrentStateEmptyRunPointerFails(t *testing.T) { - cfg := restoreDiscoveryConfig() - store := &storage.FakeBackend{} - _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) - - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte(" \n\t")}) - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)}) - - _, err := discoverRemoteCurrentState(context.Background(), cfg, store) - if err == nil || !strings.Contains(err.Error(), "is empty") { - t.Fatalf("error = %v, want empty run pointer failure", err) - } -} - -func TestDiscoverRemoteCurrentStateMissingManifestFails(t *testing.T) { - cfg := restoreDiscoveryConfig() - store := &storage.FakeBackend{} - _, _, runIDKey := restoreDiscoveryKeys(cfg) - - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - - _, err := discoverRemoteCurrentState(context.Background(), cfg, store) - if err == nil || !strings.Contains(err.Error(), "remote current manifest missing") { - t.Fatalf("error = %v, want missing manifest failure", err) - } -} - -func TestDiscoverRemoteCurrentStateInvalidManifestFails(t *testing.T) { - cfg := restoreDiscoveryConfig() - store := &storage.FakeBackend{} - _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) - - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: []byte("{invalid json")}) - - _, err := discoverRemoteCurrentState(context.Background(), cfg, store) - if err == nil || !strings.Contains(err.Error(), "remote current manifest decode failed") { - t.Fatalf("error = %v, want manifest decode failure", err) - } -} - -func TestDiscoverRemoteCurrentStateSessionMismatchFails(t *testing.T) { - cfg := restoreDiscoveryConfig() - store := &storage.FakeBackend{} - _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) - - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "wrong-session", cfg.Session.Campaign)}) - - _, err := discoverRemoteCurrentState(context.Background(), cfg, store) - if err == nil || !strings.Contains(err.Error(), "does not match expected session_id") { - t.Fatalf("error = %v, want session mismatch failure", err) - } -} - -func TestDiscoverRemoteCurrentStateCampaignMismatchFails(t *testing.T) { - cfg := restoreDiscoveryConfig() - store := &storage.FakeBackend{} - _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) - - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, "wrong-campaign")}) - - _, err := discoverRemoteCurrentState(context.Background(), cfg, store) - if err == nil || !strings.Contains(err.Error(), "does not match expected campaign") { - t.Fatalf("error = %v, want campaign mismatch failure", err) - } -} - -func TestDiscoverRemoteCurrentStateEmptyCampaignFails(t *testing.T) { - cfg := restoreDiscoveryConfig() - store := &storage.FakeBackend{} - _, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) - - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, "")}) - - _, err := discoverRemoteCurrentState(context.Background(), cfg, store) - if err == nil || !strings.Contains(err.Error(), "campaign is required") { - t.Fatalf("error = %v, want empty campaign failure", err) - } -} - -func TestDiscoverRemoteCurrentStateUsesCurrentKeysUnderSessionPrefix(t *testing.T) { - cfg := restoreDiscoveryConfig() - sessionPrefix, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) - base := &storage.FakeBackend{} - store := &captureObjectStore{delegate: base} - - base.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - base.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)}) - - _, err := discoverRemoteCurrentState(context.Background(), cfg, store) - if err != nil { - t.Fatalf("discoverRemoteCurrentState() error = %v", err) - } - - expectedRunKey := fmt.Sprintf("%scurrent/run_id.txt", sessionPrefix) - expectedManifestKey := fmt.Sprintf("%scurrent/manifest.json", sessionPrefix) - if !containsString(store.readKeys, expectedRunKey) { - t.Fatalf("read keys = %#v, want run pointer key %q", store.readKeys, expectedRunKey) - } - if !containsString(store.readKeys, expectedManifestKey) { - t.Fatalf("read keys = %#v, want manifest key %q", store.readKeys, expectedManifestKey) - } -} - -type captureObjectStore struct { - delegate storage.ObjectStore - existsKeys []string - readKeys []string - downloadKeys []string -} - -func (s *captureObjectStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) { - return s.delegate.List(ctx, prefix) -} - -func (s *captureObjectStore) Read(ctx context.Context, key string) (storage.ObjectInfo, io.ReadCloser, error) { - s.readKeys = append(s.readKeys, key) - return s.delegate.Read(ctx, key) -} - -func (s *captureObjectStore) Download(ctx context.Context, key, localPath string) error { - s.downloadKeys = append(s.downloadKeys, key) - return s.delegate.Download(ctx, key, localPath) -} - -func (s *captureObjectStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) { - return s.delegate.Upload(ctx, localPath, key, opts) -} - -func (s *captureObjectStore) UploadConditional(ctx context.Context, source io.Reader, key string, opts storage.UploadOptions, condition storage.WriteCondition) (storage.ObjectInfo, error) { - return s.delegate.UploadConditional(ctx, source, key, opts, condition) -} - -func (s *captureObjectStore) Exists(ctx context.Context, key string) (bool, error) { - s.existsKeys = append(s.existsKeys, key) - return s.delegate.Exists(ctx, key) -} - -func restoreDiscoveryConfig() *config.Config { - return &config.Config{ - Pipeline: &config.PipelineConfig{ - Storage: config.StorageConfig{ - S3: &config.StorageS3Config{ - Bucket: "my-dnd-archive", - RootPrefix: "dnd", - }, - }, - }, - Session: &config.SessionConfig{ - SessionID: "2026-05-03", - Campaign: "sample-campaign", - }, - } -} - -func restoreDiscoveryKeys(cfg *config.Config) (sessionPrefix, manifestKey, runIDKey string) { - sessionPrefix = artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID) - manifestKey, runIDKey = artifacts.ResolveCurrentStateKeys(sessionPrefix) - return sessionPrefix, manifestKey, runIDKey } func restoreManifestJSON(t *testing.T, sessionID, campaign string) []byte { t.Helper() now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) - payload := map[string]any{ - "schema_version": "narratio.session-manifest.v1", - "session_id": sessionID, - "campaign": campaign, - "run_id": "20260519T010203Z-a1b2c3d4", - "created_at": now, - "updated_at": now, - "stages": map[string]any{}, - } - data, err := json.Marshal(payload) + data, err := json.Marshal(map[string]any{"schema_version": "narratio.session-manifest.v1", "session_id": sessionID, "campaign": campaign, "run_id": "20260519T010203Z-a1b2c3d4", "created_at": now, "updated_at": now, "stages": map[string]any{}}) if err != nil { - t.Fatalf("marshal manifest payload: %v", err) + t.Fatalf("marshal manifest: %v", err) } return append(data, '\n') } - -func containsString(values []string, target string) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} diff --git a/internal/app/restore_execution_test.go b/internal/app/restore_execution_test.go index a5866d2..65a86b6 100644 --- a/internal/app/restore_execution_test.go +++ b/internal/app/restore_execution_test.go @@ -28,6 +28,11 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) { seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio")) seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n")) seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)) + seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{ + "transcripts/full.json": []byte(`{"segments":[1,2,3]}`), + "artifacts/session_recap.md": []byte("# recap\n"), + "audio/alice.flac": []byte("remote-audio"), + }) restoreWithStoreAndRealPhases(t, fake) @@ -159,7 +164,8 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T) if code != 0 { t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String()) } - if got := fakeDownloadCount(fake, audioKey); got != 1 { + committedAudioKey := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, "20260519T010203Z-a1b2c3d4"), "audio/alice.flac") + if got := fakeDownloadCount(fake, committedAudioKey); got != 1 { t.Fatalf("audio downloads after first restore = %d, want 1", got) } sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) @@ -174,7 +180,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T) if code != 0 { t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String()) } - if got := fakeDownloadCount(fake, audioKey); got != 1 { + if got := fakeDownloadCount(fake, committedAudioKey); got != 1 { t.Fatalf("audio downloads after cached restore = %d, want still 1", got) } mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio") @@ -598,13 +604,8 @@ func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipeline t.Fatalf("Validate() error = %v", err) } - sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID) - manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix) - - seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n")) - seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)) - - return cfg, sessionPrefix, manifestKey, runIDKey + current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", nil) + return cfg, current.SessionPrefix, current.CurrentManifestKey, artifacts.S3CurrentCommitPointerKey(current.SessionPrefix) } func appendRestoreWorkflowPreviousInputConfig(t *testing.T, pipelinePath, sessionPath string) { @@ -636,10 +637,7 @@ func seedRestorePreviousCurrent(t *testing.T, fake *storage.FakeBackend, cfg *co func seedRestorePreviousCurrentManifestOnly(t *testing.T, fake *storage.FakeBackend, cfg *config.Config) { t.Helper() - previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) - manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix) previousRunID := "20260426T010203Z-a1b2c3d4" - seedRestoreObject(fake, runIDKey, []byte(previousRunID+"\n")) m := manifest.New(cfg.Session.PreviousSessionID, nowUTC()) m.Campaign = cfg.Session.Campaign @@ -648,7 +646,22 @@ func seedRestorePreviousCurrentManifestOnly(t *testing.T, fake *storage.FakeBack if err != nil { t.Fatalf("marshal previous restore manifest: %v", err) } - seedRestoreObject(fake, manifestKey, append(data, '\n')) + manifestData := append(data, '\n') + previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) + manifestKey := artifacts.S3RunSessionManifestKey(previousPrefix, previousRunID) + seedRestoreObject(fake, manifestKey, manifestData) + commit := artifacts.RemoteCommitManifest{FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.PreviousSessionID, RunID: previousRunID, Artifacts: []artifacts.RemoteArtifact{{Type: artifacts.RemoteArtifactTypeSessionManifest, Source: "session.manifest", DestinationKey: manifestKey, SHA256: restoreCommitSHA256(manifestData), Size: int64(len(manifestData)), Generation: fake.Objects[manifestKey].ETag}}} + commitData, err := artifacts.EncodeRemoteCommitManifest(commit) + if err != nil { + t.Fatalf("encode previous restore commit: %v", err) + } + commitKey := artifacts.S3RunCommitKey(previousPrefix, previousRunID) + seedRestoreObject(fake, commitKey, commitData) + pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.PreviousSessionID, RunID: previousRunID, CommitKey: commitKey, CommitSHA256: restoreCommitSHA256(commitData), CommitSize: int64(len(commitData)), CommitGeneration: fake.Objects[commitKey].ETag}) + if err != nil { + t.Fatalf("encode previous restore pointer: %v", err) + } + seedRestoreObject(fake, artifacts.S3CurrentCommitPointerKey(previousPrefix), pointerData) } func mustReadEquals(t *testing.T, path, want string) { diff --git a/internal/app/restore_plan_test.go b/internal/app/restore_plan_test.go index 8d4d528..90bccc3 100644 --- a/internal/app/restore_plan_test.go +++ b/internal/app/restore_plan_test.go @@ -351,7 +351,71 @@ func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) { } func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) { + if strings.HasSuffix(key, "/current/commit-pointer.json") { + if _, exists := store.Objects[key]; exists { + return + } + } store.SeedObject(storage.FakeObject{Key: key, Data: data}) + for pointerKey, pointerObject := range store.Objects { + if !strings.HasSuffix(pointerKey, "/current/commit-pointer.json") || key == pointerKey { + continue + } + pointer, err := artifacts.DecodeCurrentCommitPointer(pointerObject.Data) + if err != nil { + continue + } + commitObject, ok := store.Objects[pointer.CommitKey] + if !ok { + continue + } + commit, err := artifacts.DecodeRemoteCommitManifest(commitObject.Data) + if err != nil { + continue + } + runPrefix := strings.TrimSuffix(pointer.CommitKey, "commit.json") + sessionPrefix := strings.TrimSuffix(runPrefix, "runs/"+pointer.RunID+"/") + if !strings.HasPrefix(key, sessionPrefix) { + continue + } + destination := key + if strings.HasPrefix(key, sessionPrefix) && !strings.HasPrefix(key, runPrefix) { + destination = runPrefix + strings.TrimPrefix(key, sessionPrefix) + store.SeedObject(storage.FakeObject{Key: destination, Data: data}) + } + typeID := artifacts.RemoteArtifactTypePublishedOutput + source := "narratio.test" + if destination == artifacts.S3RunSessionManifestKey(sessionPrefix, pointer.RunID) { + typeID, source = artifacts.RemoteArtifactTypeSessionManifest, "session.manifest" + } else if relative := strings.TrimPrefix(destination, runPrefix); strings.HasPrefix(relative, "artifacts/") { + name := strings.TrimSuffix(strings.TrimPrefix(relative, "artifacts/"), filepath.Ext(relative)) + source = artifacts.ConfiguredArtifactSourceID(name) + } + artifact := remoteRestoreArtifact(store, typeID, source, destination) + replaced := false + for index := range commit.Artifacts { + if commit.Artifacts[index].DestinationKey == destination { + commit.Artifacts[index] = artifact + replaced = true + } + } + if !replaced { + commit.Artifacts = append(commit.Artifacts, artifact) + } + commitData, err := artifacts.EncodeRemoteCommitManifest(*commit) + if err != nil { + continue + } + store.SeedObject(storage.FakeObject{Key: pointer.CommitKey, Data: commitData}) + commitObject = store.Objects[pointer.CommitKey] + pointer.CommitSHA256 = restoreCommitSHA256(commitData) + pointer.CommitSize = int64(len(commitData)) + pointer.CommitGeneration = commitObject.ETag + pointerData, err := artifacts.EncodeCurrentCommitPointer(*pointer) + if err == nil { + store.SeedObject(storage.FakeObject{Key: pointerKey, Data: pointerData}) + } + } } func actionRelPaths(actions []RestoreAction) []string { @@ -402,12 +466,10 @@ func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool) func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState { t.Helper() sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) - manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix) return &RemoteCurrentState{ Bucket: "test-bucket", SessionPrefix: sessionPrefix, - CurrentManifestKey: manifestKey, - CurrentRunIDKey: runIDKey, + CurrentManifestKey: artifacts.S3RunSessionManifestKey(sessionPrefix, "20260519T010203Z-a1b2c3d4"), RunID: "20260519T010203Z-a1b2c3d4", SessionID: cfg.Session.SessionID, Campaign: cfg.Session.Campaign, diff --git a/internal/app/restore_recovery_test.go b/internal/app/restore_recovery_test.go index 6d8b1d7..65c107f 100644 --- a/internal/app/restore_recovery_test.go +++ b/internal/app/restore_recovery_test.go @@ -95,8 +95,9 @@ func TestForcedRestoreRetryClearsIncompleteMarker(t *testing.T) { sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "local recap\n") mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local transcript\n") + committedTranscriptKey := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, "20260519T010203Z-a1b2c3d4"), "transcripts/full.json") fake.DownloadHook = func(call storage.FakeDownloadCall) error { - if call.Key == transcriptKey { + if call.Key == committedTranscriptKey { return context.Canceled } return nil diff --git a/internal/app/session_oriented_cli_test.go b/internal/app/session_oriented_cli_test.go index c80132b..7e8f5c0 100644 --- a/internal/app/session_oriented_cli_test.go +++ b/internal/app/session_oriented_cli_test.go @@ -232,7 +232,8 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") - manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix) + manifestKey := artifacts.S3RunSessionManifestKey(sessionPrefix, "20260519T010203Z-a1b2c3d4") + runIDKey := artifacts.S3CurrentCommitPointerKey(sessionPrefix) fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")}) var storeInitCalls int diff --git a/internal/artifacts/current_state.go b/internal/artifacts/current_state.go index dd3332b..2a5ff4f 100644 --- a/internal/artifacts/current_state.go +++ b/internal/artifacts/current_state.go @@ -11,37 +11,23 @@ import ( ) var ( - ErrCurrentRunPointerMissing = errors.New("current run pointer missing") - ErrCurrentManifestMissing = errors.New("current manifest missing") + ErrCurrentCommitPointerMissing = errors.New("current commit pointer missing") ) -type CurrentRunPointerMissingError struct { +type CurrentCommitPointerMissingError struct { Key string } -func (e *CurrentRunPointerMissingError) Error() string { - return fmt.Sprintf("%s: %q", ErrCurrentRunPointerMissing, e.Key) +func (e *CurrentCommitPointerMissingError) Error() string { + return fmt.Sprintf("%s: %q; republish this session to establish committed remote state", ErrCurrentCommitPointerMissing, e.Key) } -func (e *CurrentRunPointerMissingError) Unwrap() error { - return ErrCurrentRunPointerMissing -} - -type CurrentManifestMissingError struct { - Key string -} - -func (e *CurrentManifestMissingError) Error() string { - return fmt.Sprintf("%s: %q", ErrCurrentManifestMissing, e.Key) -} - -func (e *CurrentManifestMissingError) Unwrap() error { - return ErrCurrentManifestMissing +func (e *CurrentCommitPointerMissingError) Unwrap() error { + return ErrCurrentCommitPointerMissing } type CurrentState struct { SessionPrefix string - CurrentRunIDKey string CurrentManifestKey string CurrentPointerKey string RunID string @@ -77,10 +63,10 @@ func LoadCurrentState( if err != nil { return nil, fmt.Errorf("check current commit pointer %q: %w", pointerKey, err) } - if pointerExists { - return loadCommittedCurrentState(ctx, store, prefix, pointerKey, validation) + if !pointerExists { + return nil, &CurrentCommitPointerMissingError{Key: pointerKey} } - return loadLegacyCurrentState(ctx, store, prefix, validation) + return loadCommittedCurrentState(ctx, store, prefix, pointerKey, validation) } func ValidateCurrentStateIdentity(state *CurrentState, validation CurrentStateValidation) error { @@ -95,12 +81,12 @@ func ValidateCurrentStateIdentity(state *CurrentState, validation CurrentStateVa manifestRunID := strings.TrimSpace(state.Manifest.RunID) if manifestCampaign != "" { if err := ValidateSessionIdentity(manifestCampaign, manifestSessionID); err != nil { - return fmt.Errorf("current manifest contains unsafe legacy identities; migrate remote state before use: %w", err) + return fmt.Errorf("current manifest contains unsafe identities: %w", err) } } if manifestRunID != "" { if err := ValidateRunIdentity(manifestRunID); err != nil { - return fmt.Errorf("current manifest contains an unsafe legacy run id; migrate remote state before use: %w", err) + return fmt.Errorf("current manifest contains an unsafe run id: %w", err) } } @@ -133,7 +119,7 @@ func ValidateCurrentStateIdentity(state *CurrentState, validation CurrentStateVa if manifestRunID != expectedRunID { return fmt.Errorf( "current run pointer %q references run %q but current manifest run_id is %q", - state.CurrentRunIDKey, + state.CurrentPointerKey, expectedRunID, manifestRunID, ) diff --git a/internal/artifacts/current_state_commit.go b/internal/artifacts/current_state_commit.go index f07558b..067dc84 100644 --- a/internal/artifacts/current_state_commit.go +++ b/internal/artifacts/current_state_commit.go @@ -71,7 +71,6 @@ func loadCommittedCurrentState( state := &CurrentState{ SessionPrefix: sessionPrefix, - CurrentRunIDKey: pointerKey, CurrentPointerKey: pointerKey, CurrentManifestKey: sessionManifest.DestinationKey, RunID: commit.RunID, diff --git a/internal/artifacts/current_state_legacy.go b/internal/artifacts/current_state_legacy.go deleted file mode 100644 index 3f01663..0000000 --- a/internal/artifacts/current_state_legacy.go +++ /dev/null @@ -1,96 +0,0 @@ -package artifacts - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "strings" - - "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" - "gitea.maximumdirect.net/eric/narratio/internal/manifest" -) - -const ( - // MaxLegacyCurrentRunPointerBytes bounds the compatibility run selector. - MaxLegacyCurrentRunPointerBytes int64 = 4 << 10 - // MaxLegacyCurrentManifestBytes bounds the compatibility session manifest. - MaxLegacyCurrentManifestBytes int64 = MaxRemoteSessionManifestBytes -) - -// This file is temporary compatibility support for sessions published before -// immutable current commits. It can be removed after legacy remote state is migrated. - -func loadLegacyCurrentState( - ctx context.Context, - store storage.ObjectStore, - sessionPrefix string, - validation CurrentStateValidation, -) (*CurrentState, error) { - currentManifestKey, currentRunIDKey := ResolveCurrentStateKeys(sessionPrefix) - runID, err := loadLegacyCurrentRunPointer(ctx, store, currentRunIDKey) - if err != nil { - return nil, err - } - m, err := loadLegacyCurrentManifest(ctx, store, currentManifestKey) - if err != nil { - return nil, err - } - - state := &CurrentState{ - SessionPrefix: sessionPrefix, - CurrentRunIDKey: currentRunIDKey, - CurrentManifestKey: currentManifestKey, - RunID: runID, - Manifest: m, - } - if err := ValidateCurrentStateIdentity(state, validation); err != nil { - return nil, err - } - return state, nil -} - -func loadLegacyCurrentRunPointer(ctx context.Context, store storage.ObjectStore, currentRunIDKey string) (string, error) { - key := strings.TrimSpace(currentRunIDKey) - if key == "" { - return "", fmt.Errorf("current run pointer key is required") - } - - _, data, err := readCurrentStateControlObject(ctx, store, key, "legacy current run pointer", MaxLegacyCurrentRunPointerBytes) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return "", &CurrentRunPointerMissingError{Key: key} - } - return "", err - } - runID := strings.TrimSpace(string(data)) - if runID == "" { - return "", fmt.Errorf("current run pointer %q is empty", key) - } - if err := ValidateRunIdentity(runID); err != nil { - return "", fmt.Errorf("current run pointer %q contains an unsafe legacy run id; migrate remote state before use: %w", key, err) - } - return runID, nil -} - -func loadLegacyCurrentManifest(ctx context.Context, store storage.ObjectStore, currentManifestKey string) (*manifest.Manifest, error) { - key := strings.TrimSpace(currentManifestKey) - if key == "" { - return nil, fmt.Errorf("current manifest key is required") - } - - _, data, err := readCurrentStateControlObject(ctx, store, key, "legacy current manifest", MaxLegacyCurrentManifestBytes) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, &CurrentManifestMissingError{Key: key} - } - return nil, err - } - - m, err := (&manifest.LocalStore{}).LoadReader(ctx, bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("current manifest decode failed: %w", err) - } - return m, nil -} diff --git a/internal/artifacts/current_state_test.go b/internal/artifacts/current_state_test.go index 0a272d9..5582868 100644 --- a/internal/artifacts/current_state_test.go +++ b/internal/artifacts/current_state_test.go @@ -4,175 +4,22 @@ import ( "context" "encoding/json" "errors" - "fmt" - "strings" "testing" "time" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" ) -func TestLoadCurrentStateMissingRunPointer(t *testing.T) { +func TestLoadCurrentStateRequiresCommitPointer(t *testing.T) { store := &storage.FakeBackend{} - _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{}) - if err == nil { - t.Fatal("LoadCurrentState() error = nil, want missing run pointer error") + prefix := S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") + _, err := LoadCurrentState(context.Background(), store, prefix, CurrentStateValidation{}) + var missing *CurrentCommitPointerMissingError + if !errors.As(err, &missing) || !errors.Is(err, ErrCurrentCommitPointerMissing) { + t.Fatalf("LoadCurrentState() error = %v, want missing commit pointer", err) } - var missing *CurrentRunPointerMissingError - if !errors.As(err, &missing) { - t.Fatalf("errors.As(err, *CurrentRunPointerMissingError) = false; err=%v", err) - } - if !errors.Is(err, ErrCurrentRunPointerMissing) { - t.Fatalf("errors.Is(err, ErrCurrentRunPointerMissing) = false; err=%v", err) - } -} - -func TestLoadCurrentStateMissingManifest(t *testing.T) { - store := &storage.FakeBackend{} - _, _, runIDKey := testCurrentStateKeys() - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - - _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{}) - if err == nil { - t.Fatal("LoadCurrentState() error = nil, want missing manifest error") - } - var missing *CurrentManifestMissingError - if !errors.As(err, &missing) { - t.Fatalf("errors.As(err, *CurrentManifestMissingError) = false; err=%v", err) - } - if !errors.Is(err, ErrCurrentManifestMissing) { - t.Fatalf("errors.Is(err, ErrCurrentManifestMissing) = false; err=%v", err) - } -} - -func TestLoadCurrentStateEmptyRunPointerFails(t *testing.T) { - store := &storage.FakeBackend{} - _, manifestKey, runIDKey := testCurrentStateKeys() - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte(" \n\t")}) - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: testManifestJSON(t, "2026-05-03", "sample-campaign", "20260519T010203Z-a1b2c3d4")}) - - _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{}) - if err == nil || !strings.Contains(err.Error(), "is empty") { - t.Fatalf("error = %v, want empty run pointer failure", err) - } -} - -func TestLoadCurrentStateMalformedManifestFails(t *testing.T) { - store := &storage.FakeBackend{} - _, manifestKey, runIDKey := testCurrentStateKeys() - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: []byte("{invalid json")}) - - _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{}) - if err == nil || !strings.Contains(err.Error(), "current manifest decode failed") { - t.Fatalf("error = %v, want manifest decode failure", err) - } -} - -func TestLoadCurrentStateCampaignMismatchFails(t *testing.T) { - store := &storage.FakeBackend{} - seedCurrentState(t, store, "2026-05-03", "wrong-campaign", "20260519T010203Z-a1b2c3d4") - - _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{ - ExpectedCampaign: "sample-campaign", - }) - if err == nil || !strings.Contains(err.Error(), "does not match expected campaign") { - t.Fatalf("error = %v, want campaign mismatch failure", err) - } -} - -func TestLoadCurrentStateSessionMismatchFails(t *testing.T) { - store := &storage.FakeBackend{} - seedCurrentState(t, store, "wrong-session", "sample-campaign", "20260519T010203Z-a1b2c3d4") - - _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{ - ExpectedSessionID: "2026-05-03", - }) - if err == nil || !strings.Contains(err.Error(), "does not match expected session_id") { - t.Fatalf("error = %v, want session mismatch failure", err) - } -} - -func TestLoadCurrentStateRunIDMismatchFails(t *testing.T) { - store := &storage.FakeBackend{} - seedCurrentState(t, store, "2026-05-03", "sample-campaign", "different-run-id") - - _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{ - ValidateRunID: true, - }) - if err == nil || !strings.Contains(err.Error(), "current manifest run_id") { - t.Fatalf("error = %v, want run mismatch failure", err) - } -} - -func TestLoadCurrentStateReadsCoherentLegacyPair(t *testing.T) { - store := &storage.FakeBackend{} - seedCurrentState(t, store, "2026-05-03", "sample-campaign", "20260519T010203Z-a1b2c3d4") - - state, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{ - ExpectedCampaign: "sample-campaign", - ExpectedSessionID: "2026-05-03", - ValidateRunID: true, - }) - if err != nil { - t.Fatalf("LoadCurrentState() error = %v", err) - } - if state.Commit != nil || state.Pointer != nil { - t.Fatalf("legacy current state unexpectedly includes immutable commit data: %#v", state) - } - if len(store.Downloads) != 0 { - t.Fatalf("legacy current-state downloads = %#v, want direct bounded reads", store.Downloads) - } - wantReads := []string{state.CurrentRunIDKey, state.CurrentManifestKey} - if len(store.Reads) != len(wantReads) { - t.Fatalf("legacy current-state reads = %#v, want %q", store.Reads, wantReads) - } - for index, want := range wantReads { - if store.Reads[index].Key != want { - t.Fatalf("legacy current-state read %d = %q, want %q", index, store.Reads[index].Key, want) - } - } -} - -func TestCurrentStateControlObjectLimitsAcceptExactAndRejectLimitPlusOne(t *testing.T) { - tests := []struct { - name string - category string - key string - limit int64 - }{ - {name: "commit pointer", category: "current commit pointer", key: "current/commit-pointer.json", limit: MaxCurrentCommitPointerBytes}, - {name: "commit manifest", category: "remote commit manifest", key: "runs/run/commit.json", limit: MaxRemoteCommitManifestBytes}, - {name: "session manifest", category: "committed session manifest", key: "runs/run/session-manifest.json", limit: MaxRemoteSessionManifestBytes}, - {name: "legacy run pointer", category: "legacy current run pointer", key: "current/run_id.txt", limit: MaxLegacyCurrentRunPointerBytes}, - {name: "legacy manifest", category: "legacy current manifest", key: "current/manifest.json", limit: MaxLegacyCurrentManifestBytes}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - store := &storage.FakeBackend{} - exact := strings.Repeat("x", int(test.limit)) - store.SeedObject(storage.FakeObject{Key: test.key, Data: []byte(exact)}) - _, data, err := readCurrentStateControlObject(context.Background(), store, test.key, test.category, test.limit) - if err != nil || string(data) != exact { - t.Fatalf("exact-limit read data length=%d error=%v", len(data), err) - } - - store.SeedObject(storage.FakeObject{Key: test.key, Data: []byte(exact + "x")}) - _, _, err = readCurrentStateControlObject(context.Background(), store, test.key, test.category, test.limit) - if err == nil || !strings.Contains(err.Error(), test.category) || !strings.Contains(err.Error(), test.key) || !strings.Contains(err.Error(), fmt.Sprint(test.limit)) { - t.Fatalf("limit-plus-one error = %v, want category, key, and limit", err) - } - }) - } -} - -func TestLoadCurrentStateRejectsTornLegacyPair(t *testing.T) { - store := &storage.FakeBackend{} - seedCurrentState(t, store, "2026-05-03", "sample-campaign", "different-run-id") - - _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{ValidateRunID: true}) - if err == nil || !strings.Contains(err.Error(), "current manifest run_id") { - t.Fatalf("error = %v, want torn legacy pair rejection", err) + if missing.Key != S3CurrentCommitPointerKey(prefix) { + t.Fatalf("missing pointer key = %q", missing.Key) } } @@ -180,32 +27,14 @@ func testCurrentSessionPrefix() string { return S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") } -func testCurrentStateKeys() (sessionPrefix, manifestKey, runIDKey string) { - sessionPrefix = testCurrentSessionPrefix() - manifestKey, runIDKey = ResolveCurrentStateKeys(sessionPrefix) - return sessionPrefix, manifestKey, runIDKey -} - -func seedCurrentState(t *testing.T, store *storage.FakeBackend, sessionID, campaign, manifestRunID string) { - t.Helper() - _, manifestKey, runIDKey := testCurrentStateKeys() - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: testManifestJSON(t, sessionID, campaign, manifestRunID)}) -} - func testManifestJSON(t *testing.T, sessionID, campaign, runID string) []byte { t.Helper() now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) - payload := map[string]any{ - "schema_version": "narratio.session-manifest.v1", - "session_id": sessionID, - "campaign": campaign, - "run_id": runID, - "created_at": now, - "updated_at": now, - "stages": map[string]any{}, - } - data, err := json.Marshal(payload) + data, err := json.Marshal(map[string]any{ + "schema_version": "narratio.session-manifest.v1", "session_id": sessionID, + "campaign": campaign, "run_id": runID, "created_at": now, "updated_at": now, + "stages": map[string]any{}, + }) if err != nil { t.Fatalf("marshal manifest payload: %v", err) } diff --git a/internal/artifacts/publish_identity.go b/internal/artifacts/publish_identity.go index c3b3e0e..d8ad350 100644 --- a/internal/artifacts/publish_identity.go +++ b/internal/artifacts/publish_identity.go @@ -76,8 +76,3 @@ func ResolvePublishRunPrefix(cfg *config.Config, m *manifest.Manifest) (string, } return S3RunPrefix(sessionPrefix, runID), nil } - -// ResolveCurrentStateKeys returns current pointer keys for a session prefix. -func ResolveCurrentStateKeys(sessionPrefix string) (manifestKey, runIDKey string) { - return S3CurrentManifestKey(sessionPrefix), S3CurrentRunPointerKey(sessionPrefix) -} diff --git a/internal/artifacts/publish_identity_test.go b/internal/artifacts/publish_identity_test.go index 7dc90dc..655a2c1 100644 --- a/internal/artifacts/publish_identity_test.go +++ b/internal/artifacts/publish_identity_test.go @@ -118,13 +118,3 @@ func TestResolvePublishIdentityErrorsAreDeterministic(t *testing.T) { t.Fatalf("error = %v, want missing run id", err) } } - -func TestResolveCurrentStateKeys(t *testing.T) { - manifestKey, runIDKey := ResolveCurrentStateKeys("dnd/campaigns/forsaken/sessions/2026-04-19/") - if manifestKey != "dnd/campaigns/forsaken/sessions/2026-04-19/current/manifest.json" { - t.Fatalf("manifest key = %q", manifestKey) - } - if runIDKey != "dnd/campaigns/forsaken/sessions/2026-04-19/current/run_id.txt" { - t.Fatalf("run id key = %q", runIDKey) - } -} diff --git a/internal/artifacts/s3_keys.go b/internal/artifacts/s3_keys.go index b7501dc..c7940d9 100644 --- a/internal/artifacts/s3_keys.go +++ b/internal/artifacts/s3_keys.go @@ -46,18 +46,6 @@ func S3SessionLocksKey(sessionPrefix string) string { return path.Join(strings.TrimSuffix(cleanS3Key(sessionPrefix), "/"), "locks.yml") } -// S3CurrentManifestKey returns the current manifest pointer key. -// Format: {session_prefix}/current/manifest.json -func S3CurrentManifestKey(sessionPrefix string) string { - return path.Join(strings.TrimSuffix(cleanS3Key(sessionPrefix), "/"), config.S3CurrentSegment, config.S3ManifestFile) -} - -// S3CurrentRunPointerKey returns the current run pointer key. -// Format: {session_prefix}/current/run_id.txt -func S3CurrentRunPointerKey(sessionPrefix string) string { - return path.Join(strings.TrimSuffix(cleanS3Key(sessionPrefix), "/"), config.S3CurrentSegment, config.S3RunIDFile) -} - // S3RunCommitKey returns the immutable commit manifest key for one run. // Format: {session_prefix}/runs/{run_id}/commit.json func S3RunCommitKey(sessionPrefix, runID string) string { diff --git a/internal/artifacts/s3_keys_test.go b/internal/artifacts/s3_keys_test.go index 5362d6f..a31ee6e 100644 --- a/internal/artifacts/s3_keys_test.go +++ b/internal/artifacts/s3_keys_test.go @@ -39,16 +39,6 @@ func TestS3KeyConstruction(t *testing.T) { t.Fatalf("session manifest key = %q, want %q", got, want) } - runPointer := S3CurrentRunPointerKey(sessionPrefix) - if runPointer != "dnd/campaigns/forsaken/sessions/2026-04-19/current/run_id.txt" { - t.Fatalf("run pointer key = %q", runPointer) - } - - manifestKey := S3CurrentManifestKey(sessionPrefix) - if manifestKey != "dnd/campaigns/forsaken/sessions/2026-04-19/current/manifest.json" { - t.Fatalf("manifest key = %q", manifestKey) - } - commitPointer := S3CurrentCommitPointerKey(sessionPrefix) if commitPointer != "dnd/campaigns/forsaken/sessions/2026-04-19/current/commit-pointer.json" { t.Fatalf("commit pointer key = %q", commitPointer) diff --git a/internal/previouscache/previouscache.go b/internal/previouscache/previouscache.go index 10946a8..9f57eed 100644 --- a/internal/previouscache/previouscache.go +++ b/internal/previouscache/previouscache.go @@ -106,9 +106,8 @@ func Resolve( ValidateRunID: true, }) if err != nil { - var runPointerMissing *artifacts.CurrentRunPointerMissingError - var manifestMissing *artifacts.CurrentManifestMissingError - if errors.As(err, &runPointerMissing) || errors.As(err, &manifestMissing) { + var pointerMissing *artifacts.CurrentCommitPointerMissingError + if errors.As(err, &pointerMissing) { if len(requiredNames) > 0 { return nil, fmt.Errorf("required previous-session artifacts unavailable: remote %w", err) } diff --git a/internal/previouscache/previouscache_test.go b/internal/previouscache/previouscache_test.go index bb405ac..3a52e7e 100644 --- a/internal/previouscache/previouscache_test.go +++ b/internal/previouscache/previouscache_test.go @@ -19,7 +19,7 @@ import ( func TestBuildPlanResolvesPublishedArtifactFromPreviousManifest(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} - seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"artifacts/session_recap.md"})) + seedCommittedPreviousState(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"artifacts/session_recap.md"}), []artifacts.RemoteArtifact{committedPublishedArtifactForTest("narratio.artifact.session_recap", "artifacts/session_recap.md", []byte("# recap\n"), "recap-generation")}) previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID) store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")}) @@ -42,7 +42,7 @@ func TestBuildPlanResolvesPublishedArtifactFromPreviousManifest(t *testing.T) { func TestBuildPlanFallsBackToConfiguredOutputPath(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} - seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil)) + seedCommittedPreviousState(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil), []artifacts.RemoteArtifact{committedPublishedArtifactForTest("narratio.artifact.session_recap", "artifacts/session_recap.md", []byte("# recap\n"), "recap-generation")}) previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID) store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")}) @@ -105,7 +105,7 @@ func TestResolveDoesNotUseCommittedBasenameFallback(t *testing.T) { } } -func TestResolveRejectsAmbiguousLegacyCandidates(t *testing.T) { +func TestResolveRejectsUndeclaredPublishedCandidates(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"archive/session_recap.md"})) @@ -114,8 +114,8 @@ func TestResolveRejectsAmbiguousLegacyCandidates(t *testing.T) { store.SeedObject(storage.FakeObject{Key: prefix + "archive/session_recap.md", Data: []byte("published candidate")}) _, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, store) - if err == nil || !strings.Contains(err.Error(), "matches multiple published candidates") { - t.Fatalf("Resolve() error = %v, want legacy ambiguity error", err) + if err == nil || !strings.Contains(err.Error(), "object missing from published candidate keys") { + t.Fatalf("Resolve() error = %v, want undeclared-candidate error", err) } } @@ -172,7 +172,7 @@ func TestBuildPlanValidatesPreviousManifestIdentity(t *testing.T) { _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: true}, }, store) - if err == nil || !strings.Contains(err.Error(), "does not match expected session_id") { + if err == nil || !strings.Contains(err.Error(), "identity does not match") { t.Fatalf("BuildPlan() error = %v, want identity validation error", err) } } @@ -199,17 +199,14 @@ func TestBuildPlanMissingCurrentRunPointerFailsRequiredRequirements(t *testing.T _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: true}, }, store) - if err == nil || !strings.Contains(err.Error(), `required previous-session artifacts unavailable: remote current run pointer missing`) { - t.Fatalf("BuildPlan() error = %v, want required missing run pointer error", err) + if err == nil || !strings.Contains(err.Error(), `required previous-session artifacts unavailable: remote current commit pointer missing`) { + t.Fatalf("BuildPlan() error = %v, want required missing commit pointer error", err) } } -func TestBuildPlanMissingCurrentManifestSkipsOptionalRequirements(t *testing.T) { +func TestBuildPlanMissingCurrentCommitPointerSkipsOptionalRequirements(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} - previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID) - _, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix) - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")}) plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: false}, @@ -222,18 +219,15 @@ func TestBuildPlanMissingCurrentManifestSkipsOptionalRequirements(t *testing.T) } } -func TestBuildPlanMissingCurrentManifestFailsRequiredRequirements(t *testing.T) { +func TestBuildPlanMissingCurrentCommitPointerFailsRequiredRequirements(t *testing.T) { cfg, paths := previousCacheTestConfig(t) store := &storage.FakeBackend{} - previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID) - _, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix) - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")}) _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{ {Name: "session_recap", Required: true}, }, store) - if err == nil || !strings.Contains(err.Error(), `required previous-session artifacts unavailable: remote current manifest missing`) { - t.Fatalf("BuildPlan() error = %v, want required missing manifest error", err) + if err == nil || !strings.Contains(err.Error(), `required previous-session artifacts unavailable: remote current commit pointer missing`) { + t.Fatalf("BuildPlan() error = %v, want required missing commit pointer error", err) } } @@ -265,14 +259,26 @@ func previousCacheTestConfig(t *testing.T) (*config.Config, artifacts.SessionPat func seedPreviousCurrent(t *testing.T, store *storage.FakeBackend, cfg *config.Config, m *manifest.Manifest) { t.Helper() - previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) - manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix) - store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")}) data, err := marshalManifestForPreviousCacheTest(m) if err != nil { t.Fatalf("marshal manifest: %v", err) } - store.SeedObject(storage.FakeObject{Key: manifestKey, Data: data}) + prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID) + manifestKey := artifacts.S3RunSessionManifestKey(prefix, m.RunID) + manifestGeneration := "manifest-generation" + commit := artifacts.RemoteCommitManifest{FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.PreviousSessionID, RunID: m.RunID, Artifacts: []artifacts.RemoteArtifact{{Type: artifacts.RemoteArtifactTypeSessionManifest, Source: "session.manifest", DestinationKey: manifestKey, SHA256: sha256ForPreviousCacheTest(data), Size: int64(len(data)), Generation: manifestGeneration}}} + commitData, err := artifacts.EncodeRemoteCommitManifest(commit) + if err != nil { + t.Fatalf("encode previous commit: %v", err) + } + commitKey := artifacts.S3RunCommitKey(prefix, m.RunID) + pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: cfg.Session.Campaign, SessionID: cfg.Session.PreviousSessionID, RunID: m.RunID, CommitKey: commitKey, CommitSHA256: sha256ForPreviousCacheTest(commitData), CommitSize: int64(len(commitData)), CommitGeneration: "commit-generation"}) + if err != nil { + t.Fatalf("encode previous pointer: %v", err) + } + store.SeedObject(storage.FakeObject{Key: artifacts.S3CurrentCommitPointerKey(prefix), Data: pointerData}) + store.SeedObject(storage.FakeObject{Key: commitKey, Data: commitData, ETag: "commit-generation"}) + store.SeedObject(storage.FakeObject{Key: manifestKey, Data: data, ETag: manifestGeneration}) } func previousManifestWithOutput(t *testing.T, cfg *config.Config, rel string, publishedPaths []string) *manifest.Manifest { diff --git a/internal/stage/prepare_previous_test.go b/internal/stage/prepare_previous_test.go index 4ae19f3..2f0fabd 100644 --- a/internal/stage/prepare_previous_test.go +++ b/internal/stage/prepare_previous_test.go @@ -2,6 +2,8 @@ package stage import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "io" "os" @@ -116,6 +118,7 @@ func TestHydratePreviousSessionArtifactsFailsMissingRequiredArtifact(t *testing. } seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{ includeRunPointerObject: true, + includeManifestObject: true, includeArtifactObject: false, }) @@ -175,8 +178,8 @@ func TestHydratePreviousSessionArtifactsRespectsCurrentCommitMarker(t *testing.T }) _, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements) - if err == nil || !strings.Contains(err.Error(), "remote current run pointer missing") { - t.Fatalf("error = %v, want current run pointer missing failure", err) + if err == nil || !strings.Contains(err.Error(), "remote current commit pointer missing") { + t.Fatalf("error = %v, want current commit pointer missing failure", err) } } @@ -218,9 +221,6 @@ func TestHydratePreviousSessionArtifactsUsesExplicitStorageKeys(t *testing.T) { t.Fatalf("hydratePreviousSessionArtifacts() error = %v", err) } - if !containsString(capture.existsKeys, seed.ArtifactKey) { - t.Fatalf("exists keys = %#v, want %q", capture.existsKeys, seed.ArtifactKey) - } if !containsString(capture.accessKeys, seed.RunPointerKey) { t.Fatalf("access keys = %#v, want %q", capture.accessKeys, seed.RunPointerKey) } @@ -268,7 +268,8 @@ func seedPreviousCurrentState( campaign := strings.TrimSpace(env.Config.Session.Campaign) rootPrefix := strings.TrimSpace(env.Config.Pipeline.Storage.S3.RootPrefix) previousSessionPrefix := artifacts.S3SessionPrefix(rootPrefix, campaign, previousSessionID) - manifestKey, runPointerKey := artifacts.ResolveCurrentStateKeys(previousSessionPrefix) + manifestKey := artifacts.S3RunSessionManifestKey(previousSessionPrefix, "20260510T010203Z-a1b2c3d4") + runPointerKey := artifacts.S3CurrentCommitPointerKey(previousSessionPrefix) previousRunID := "20260510T010203Z-a1b2c3d4" previousSessionRoot := filepath.Join(t.TempDir(), "work", campaign, previousSessionID) @@ -282,12 +283,6 @@ func seedPreviousCurrentState( artifactLocalPath, ) - if options.includeRunPointerObject || (!options.includeRunPointerObject && !options.includeManifestObject && !options.includeArtifactObject) { - fake.SeedObject(storage.FakeObject{ - Key: runPointerKey, - Data: []byte(previousRunID + "\n"), - }) - } if options.includeManifestObject || options.includeArtifactObject { fake.SeedObject(storage.FakeObject{ Key: manifestKey, @@ -295,7 +290,7 @@ func seedPreviousCurrentState( }) } - artifactKey := artifacts.S3PublishedOutputKey(previousSessionPrefix, "artifacts/session_recap.md") + artifactKey := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(previousSessionPrefix, previousRunID), "artifacts/session_recap.md") if options.includeArtifactObject { body := options.artifactBody if body == "" { @@ -306,6 +301,27 @@ func seedPreviousCurrentState( Data: []byte(body), }) } + if options.includeRunPointerObject || (!options.includeRunPointerObject && !options.includeManifestObject && !options.includeArtifactObject) { + manifestObject := fake.Objects[manifestKey] + entries := []artifacts.RemoteArtifact{{Type: artifacts.RemoteArtifactTypeSessionManifest, Source: "session.manifest", DestinationKey: manifestKey, SHA256: preparePreviousSHA256(previousManifest), Size: int64(len(previousManifest)), Generation: manifestObject.ETag}} + if options.includeArtifactObject { + artifactObject := fake.Objects[artifactKey] + entries = append(entries, artifacts.RemoteArtifact{Type: artifacts.RemoteArtifactTypePublishedOutput, Source: artifacts.ConfiguredArtifactSourceID("session_recap"), DestinationKey: artifactKey, SHA256: preparePreviousSHA256(artifactObject.Data), Size: int64(len(artifactObject.Data)), Generation: artifactObject.ETag}) + } + commit := artifacts.RemoteCommitManifest{FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: campaign, SessionID: previousSessionID, RunID: previousRunID, Artifacts: entries} + commitData, err := artifacts.EncodeRemoteCommitManifest(commit) + if err != nil { + t.Fatalf("encode previous commit: %v", err) + } + commitKey := artifacts.S3RunCommitKey(previousSessionPrefix, previousRunID) + fake.SeedObject(storage.FakeObject{Key: commitKey, Data: commitData}) + commitObject := fake.Objects[commitKey] + pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{FormatVersion: artifacts.RemoteCommitFormatVersion, Campaign: campaign, SessionID: previousSessionID, RunID: previousRunID, CommitKey: commitKey, CommitSHA256: preparePreviousSHA256(commitData), CommitSize: int64(len(commitData)), CommitGeneration: commitObject.ETag}) + if err != nil { + t.Fatalf("encode previous pointer: %v", err) + } + fake.SeedObject(storage.FakeObject{Key: runPointerKey, Data: pointerData}) + } return previousStateSeedResult{ PreviousSessionPrefix: previousSessionPrefix, @@ -316,6 +332,11 @@ func seedPreviousCurrentState( } } +func preparePreviousSHA256(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + func previousHydrationFixture(t *testing.T) (*Env, artifacts.SessionPaths, *storage.FakeBackend) { t.Helper() env, m := setupPrepareEnv(t) diff --git a/internal/stage/prepare_test.go b/internal/stage/prepare_test.go index d2dd5d0..f0fb695 100644 --- a/internal/stage/prepare_test.go +++ b/internal/stage/prepare_test.go @@ -790,7 +790,7 @@ func TestPrepareStageRerunOverwritesPreviousCache(t *testing.T) { fake := &storage.FakeBackend{} env.ObjectStore = fake - seed := seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{ + seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{ includeRunPointerObject: true, includeArtifactObject: true, artifactBody: "# old recap\n", @@ -810,7 +810,11 @@ func TestPrepareStageRerunOverwritesPreviousCache(t *testing.T) { } writeFile(t, filepath.Join(paths.PreviousArtifactsDir, "stale.txt"), "stale") - fake.SeedObject(storage.FakeObject{Key: seed.ArtifactKey, Data: []byte("# new recap\n")}) + seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{ + includeRunPointerObject: true, + includeArtifactObject: true, + artifactBody: "# new recap\n", + }) if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil { t.Fatalf("second prepare run error = %v", err)