Require committed remote current state

This commit is contained in:
2026-09-02 13:47:20 +00:00
parent 82d75e7beb
commit 6c7a45d8d0
27 changed files with 218 additions and 662 deletions

View File

@@ -173,17 +173,13 @@ authority markers.
For a new-protocol current state, the pointer-selected immutable commit is the For a new-protocol current state, the pointer-selected immutable commit is the
complete restore authority. Callers receive its declared object identities and complete restore authority. Callers receive its declared object identities and
must not supplement them by listing mutable session prefixes. The legacy reader must not supplement them by listing mutable session prefixes.
is intentionally separate and remains migration-only support.
The reader opens each small control object directly and enforces owner-specific 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 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 immutable commit manifest, and 8 MiB for the selected session manifest. These
compatibility applies a 4 KiB limit to `current/run_id.txt` and the same 8 MiB are exposed as `MaxCurrentCommitPointerBytes`,
manifest limit to `current/manifest.json`. These are exposed as `MaxRemoteCommitManifestBytes`, and `MaxRemoteSessionManifestBytes`.
`MaxCurrentCommitPointerBytes`, `MaxRemoteCommitManifestBytes`,
`MaxRemoteSessionManifestBytes`, `MaxLegacyCurrentRunPointerBytes`, and
`MaxLegacyCurrentManifestBytes`.
Each read uses the generation and size metadata returned with its opened body. 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 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 New publication creates run-scoped immutable objects, including
`runs/{run_id}/commit.json` and `runs/{run_id}/session-manifest.json`. The sole `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 mutable selector is `current/commit-pointer.json`; readers verify its selected
commit and declared object generations/checksums. Legacy current-pair loading commit and declared object generations/checksums.
is confined to `current_state_legacy.go` for migration only.
Campaign, session, and Narratio run IDs are validated as portable opaque Campaign, session, and Narratio run IDs are validated as portable opaque
segments at configuration and artifact boundaries before they can be used in a segments at configuration and artifact boundaries before they can be used in a

View File

@@ -100,9 +100,7 @@ Audio restore path:
loaded at discovery; later pointer changes cannot add objects or substitute a loaded at discovery; later pointer changes cannot add objects or substitute a
different run into its plan; different run into its plan;
- a verified `current/commit-pointer.json` and its selected immutable commit - a verified `current/commit-pointer.json` and its selected immutable commit
establish new-protocol remote commitment; coherent legacy establish remote commitment; when absent, republish the session;
`current/run_id.txt` plus `current/manifest.json` remains read-only migration
support;
- restore does not execute pipeline stages. - restore does not execute pipeline stages.
## Implementation And Tests ## Implementation And Tests

View File

@@ -153,9 +153,8 @@ binds that object by checksum, size, and generation. Readers strictly reject
unknown fields, version mismatches, pointer/commit identity mismatches, and unknown fields, version mismatches, pointer/commit identity mismatches, and
objects that do not match their declaration. objects that do not match their declaration.
The reader retains a temporary, clearly isolated compatibility path for a An absent commit pointer means no remote state is published. Republish to
coherent legacy `current/manifest.json` plus `current/run_id.txt` pair. That establish a current immutable snapshot.
path is removable after migration and is never used to write new state.
## Persistence Semantics ## Persistence Semantics

View File

@@ -53,7 +53,7 @@ Exact remote placement and the operator workflow belong in
- creates one complete immutable source-to-destination mapping before upload; - creates one complete immutable source-to-destination mapping before upload;
- uploads and verifies every declared immutable object and the commit manifest; - uploads and verifies every declared immutable object and the commit manifest;
- updates `current/commit-pointer.json` exactly once, last; and - 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 - rechecks remote lock state immediately before the pointer update. A newly
committed lock aborts selection, leaving any uploaded immutable attempt committed lock aborts selection, leaving any uploaded immutable attempt
unselected. unselected.

View File

@@ -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 are also checksum- and generation-bound. Readers accept this contract now and
strictly reject mismatched or unknown data. strictly reject mismatched or unknown data.
Legacy reads are limited to a coherent `current/manifest.json` and When the pointer is absent, remote state is not published; republish the
`current/run_id.txt` pair; a torn pair is rejected. New publication does not session to establish a committed snapshot. Remote commit state does not carry
write that pair and remote commit state does not carry local local `current_pointer_written` metadata.
`current_pointer_written` metadata.
## Publish Locks ## Publish Locks

View File

@@ -232,7 +232,7 @@ explicitly derived value.
## Stage 4 — Make Remote Current State Commit-Only ## Stage 4 — Make Remote Current State Commit-Only
**Status: Pending** **Status: Completed**
### Goal ### Goal

View File

@@ -1078,7 +1078,8 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
`) `)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") 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") trimmedKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/final.trimmed.json")
fullKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/full.json") fullKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/full.json")
lockKey := artifacts.S3SessionLocksKey(sessionPrefix) lockKey := artifacts.S3SessionLocksKey(sessionPrefix)
@@ -1174,7 +1175,7 @@ func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(t *testing.
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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()) t.Fatalf("stdout = %q, want missing remote current-state line", stdout.String())
} }
} }
@@ -1199,7 +1200,7 @@ func TestExecuteStatusReportsPreviousStateReadinessWithoutFailing(t *testing.T)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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()) t.Fatalf("stdout = %q, want previous readiness unavailable line", stdout.String())
} }
} }
@@ -1257,8 +1258,8 @@ func TestExecuteSessionValidateReportsPreviousStateFindingAndReturnsFindingError
if !strings.Contains(stdout.String(), "ERROR previous") { if !strings.Contains(stdout.String(), "ERROR previous") {
t.Fatalf("stdout = %q, want previous finding error", stdout.String()) t.Fatalf("stdout = %q, want previous finding error", stdout.String())
} }
if !strings.Contains(stdout.String(), "remote current run pointer missing") { if !strings.Contains(stdout.String(), "remote current commit pointer missing") {
t.Fatalf("stdout = %q, want missing run pointer finding", stdout.String()) t.Fatalf("stdout = %q, want missing commit pointer finding", stdout.String())
} }
if !strings.Contains(stderr.String(), "validation error(s)") { if !strings.Contains(stderr.String(), "validation error(s)") {
t.Fatalf("stderr = %q, want finding error summary", stderr.String()) t.Fatalf("stderr = %q, want finding error summary", stderr.String())

View File

@@ -171,16 +171,11 @@ func inspectPreviousArtifactReadiness(
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
plan, err := previouscache.Resolve(ctx, cfg, paths, requirements, store) plan, err := previouscache.Resolve(ctx, cfg, paths, requirements, store)
if err != nil { if err != nil {
var pointerMissing *artifacts.CurrentRunPointerMissingError var pointerMissing *artifacts.CurrentCommitPointerMissingError
var manifestMissing *artifacts.CurrentManifestMissingError
if errors.As(err, &pointerMissing) { if errors.As(err, &pointerMissing) {
out.Err = fmt.Errorf("remote %w", pointerMissing) out.Err = fmt.Errorf("remote %w", pointerMissing)
return out return out
} }
if errors.As(err, &manifestMissing) {
out.Err = fmt.Errorf("remote %w", manifestMissing)
return out
}
out.Err = fmt.Errorf("remote %w", err) out.Err = fmt.Errorf("remote %w", err)
return out return out
} }

View File

@@ -300,7 +300,7 @@ func seedCommittedRestoreSnapshot(t *testing.T, cfg *config.Config, fake *storag
for _, relative := range paths { for _, relative := range paths {
key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, runID), relative) key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, runID), relative)
fake.SeedObject(storage.FakeObject{Key: key, Data: outputs[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{ commit := artifacts.RemoteCommitManifest{
FormatVersion: artifacts.RemoteCommitFormatVersion, FormatVersion: artifacts.RemoteCommitFormatVersion,

View File

@@ -15,7 +15,6 @@ import (
type RemoteCurrentState struct { type RemoteCurrentState struct {
Bucket string Bucket string
SessionPrefix string SessionPrefix string
CurrentRunIDKey string
CurrentManifestKey string CurrentManifestKey string
RunID string RunID string
SessionID string SessionID string
@@ -55,7 +54,6 @@ func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store s
return &RemoteCurrentState{ return &RemoteCurrentState{
Bucket: bucket, Bucket: bucket,
SessionPrefix: sessionPrefix, SessionPrefix: sessionPrefix,
CurrentRunIDKey: current.CurrentRunIDKey,
CurrentManifestKey: current.CurrentManifestKey, CurrentManifestKey: current.CurrentManifestKey,
RunID: current.RunID, RunID: current.RunID,
SessionID: strings.TrimSpace(current.Manifest.SessionID), SessionID: strings.TrimSpace(current.Manifest.SessionID),

View File

@@ -3,244 +3,27 @@ package app
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"io"
"strings"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
func TestDiscoverRemoteCurrentStateSuccess(t *testing.T) { func TestDiscoverRemoteCurrentStateRequiresCommittedPointer(t *testing.T) {
cfg := restoreDiscoveryConfig() 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"}}
store := &storage.FakeBackend{} _, err := discoverRemoteCurrentState(context.Background(), cfg, &storage.FakeBackend{})
sessionPrefix, manifestKey, runIDKey := restoreDiscoveryKeys(cfg) if err == nil {
t.Fatal("discoverRemoteCurrentState() error = nil, want missing committed pointer")
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)
} }
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 { func restoreManifestJSON(t *testing.T, sessionID, campaign string) []byte {
t.Helper() t.Helper()
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
payload := map[string]any{ 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{}})
"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)
if err != nil { if err != nil {
t.Fatalf("marshal manifest payload: %v", err) t.Fatalf("marshal manifest: %v", err)
} }
return append(data, '\n') return append(data, '\n')
} }
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -28,6 +28,11 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio")) seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio"))
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n")) seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)) 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) restoreWithStoreAndRealPhases(t, fake)
@@ -159,7 +164,8 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
if code != 0 { if code != 0 {
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String()) 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) t.Fatalf("audio downloads after first restore = %d, want 1", got)
} }
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
@@ -174,7 +180,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
if code != 0 { if code != 0 {
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String()) 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) t.Fatalf("audio downloads after cached restore = %d, want still 1", got)
} }
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio") 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) t.Fatalf("Validate() error = %v", err)
} }
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID) current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", nil)
manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix) return cfg, current.SessionPrefix, current.CurrentManifestKey, artifacts.S3CurrentCommitPointerKey(current.SessionPrefix)
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
return cfg, sessionPrefix, manifestKey, runIDKey
} }
func appendRestoreWorkflowPreviousInputConfig(t *testing.T, pipelinePath, sessionPath string) { 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) { func seedRestorePreviousCurrentManifestOnly(t *testing.T, fake *storage.FakeBackend, cfg *config.Config) {
t.Helper() t.Helper()
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(previousPrefix)
previousRunID := "20260426T010203Z-a1b2c3d4" previousRunID := "20260426T010203Z-a1b2c3d4"
seedRestoreObject(fake, runIDKey, []byte(previousRunID+"\n"))
m := manifest.New(cfg.Session.PreviousSessionID, nowUTC()) m := manifest.New(cfg.Session.PreviousSessionID, nowUTC())
m.Campaign = cfg.Session.Campaign m.Campaign = cfg.Session.Campaign
@@ -648,7 +646,22 @@ func seedRestorePreviousCurrentManifestOnly(t *testing.T, fake *storage.FakeBack
if err != nil { if err != nil {
t.Fatalf("marshal previous restore manifest: %v", err) 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) { func mustReadEquals(t *testing.T, path, want string) {

View File

@@ -351,7 +351,71 @@ func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) {
} }
func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) { 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}) 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 { 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 { func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
t.Helper() t.Helper()
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
manifestKey, runIDKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
return &RemoteCurrentState{ return &RemoteCurrentState{
Bucket: "test-bucket", Bucket: "test-bucket",
SessionPrefix: sessionPrefix, SessionPrefix: sessionPrefix,
CurrentManifestKey: manifestKey, CurrentManifestKey: artifacts.S3RunSessionManifestKey(sessionPrefix, "20260519T010203Z-a1b2c3d4"),
CurrentRunIDKey: runIDKey,
RunID: "20260519T010203Z-a1b2c3d4", RunID: "20260519T010203Z-a1b2c3d4",
SessionID: cfg.Session.SessionID, SessionID: cfg.Session.SessionID,
Campaign: cfg.Session.Campaign, Campaign: cfg.Session.Campaign,

View File

@@ -95,8 +95,9 @@ func TestForcedRestoreRetryClearsIncompleteMarker(t *testing.T) {
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) 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, "artifacts", "session_recap.md"), "local recap\n")
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local transcript\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 { fake.DownloadHook = func(call storage.FakeDownloadCall) error {
if call.Key == transcriptKey { if call.Key == committedTranscriptKey {
return context.Canceled return context.Canceled
} }
return nil return nil

View File

@@ -232,7 +232,8 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") 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: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")}) fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")})
var storeInitCalls int var storeInitCalls int

View File

@@ -11,37 +11,23 @@ import (
) )
var ( var (
ErrCurrentRunPointerMissing = errors.New("current run pointer missing") ErrCurrentCommitPointerMissing = errors.New("current commit pointer missing")
ErrCurrentManifestMissing = errors.New("current manifest missing")
) )
type CurrentRunPointerMissingError struct { type CurrentCommitPointerMissingError struct {
Key string Key string
} }
func (e *CurrentRunPointerMissingError) Error() string { func (e *CurrentCommitPointerMissingError) Error() string {
return fmt.Sprintf("%s: %q", ErrCurrentRunPointerMissing, e.Key) return fmt.Sprintf("%s: %q; republish this session to establish committed remote state", ErrCurrentCommitPointerMissing, e.Key)
} }
func (e *CurrentRunPointerMissingError) Unwrap() error { func (e *CurrentCommitPointerMissingError) Unwrap() error {
return ErrCurrentRunPointerMissing return ErrCurrentCommitPointerMissing
}
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
} }
type CurrentState struct { type CurrentState struct {
SessionPrefix string SessionPrefix string
CurrentRunIDKey string
CurrentManifestKey string CurrentManifestKey string
CurrentPointerKey string CurrentPointerKey string
RunID string RunID string
@@ -77,10 +63,10 @@ func LoadCurrentState(
if err != nil { if err != nil {
return nil, fmt.Errorf("check current commit pointer %q: %w", pointerKey, err) return nil, fmt.Errorf("check current commit pointer %q: %w", pointerKey, err)
} }
if pointerExists { if !pointerExists {
return loadCommittedCurrentState(ctx, store, prefix, pointerKey, validation) 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 { func ValidateCurrentStateIdentity(state *CurrentState, validation CurrentStateValidation) error {
@@ -95,12 +81,12 @@ func ValidateCurrentStateIdentity(state *CurrentState, validation CurrentStateVa
manifestRunID := strings.TrimSpace(state.Manifest.RunID) manifestRunID := strings.TrimSpace(state.Manifest.RunID)
if manifestCampaign != "" { if manifestCampaign != "" {
if err := ValidateSessionIdentity(manifestCampaign, manifestSessionID); err != nil { 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 manifestRunID != "" {
if err := ValidateRunIdentity(manifestRunID); err != nil { 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 { if manifestRunID != expectedRunID {
return fmt.Errorf( return fmt.Errorf(
"current run pointer %q references run %q but current manifest run_id is %q", "current run pointer %q references run %q but current manifest run_id is %q",
state.CurrentRunIDKey, state.CurrentPointerKey,
expectedRunID, expectedRunID,
manifestRunID, manifestRunID,
) )

View File

@@ -71,7 +71,6 @@ func loadCommittedCurrentState(
state := &CurrentState{ state := &CurrentState{
SessionPrefix: sessionPrefix, SessionPrefix: sessionPrefix,
CurrentRunIDKey: pointerKey,
CurrentPointerKey: pointerKey, CurrentPointerKey: pointerKey,
CurrentManifestKey: sessionManifest.DestinationKey, CurrentManifestKey: sessionManifest.DestinationKey,
RunID: commit.RunID, RunID: commit.RunID,

View File

@@ -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
}

View File

@@ -4,175 +4,22 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strings"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
) )
func TestLoadCurrentStateMissingRunPointer(t *testing.T) { func TestLoadCurrentStateRequiresCommitPointer(t *testing.T) {
store := &storage.FakeBackend{} store := &storage.FakeBackend{}
_, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{}) prefix := S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
if err == nil { _, err := LoadCurrentState(context.Background(), store, prefix, CurrentStateValidation{})
t.Fatal("LoadCurrentState() error = nil, want missing run pointer error") 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 missing.Key != S3CurrentCommitPointerKey(prefix) {
if !errors.As(err, &missing) { t.Fatalf("missing pointer key = %q", missing.Key)
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)
} }
} }
@@ -180,32 +27,14 @@ func testCurrentSessionPrefix() string {
return S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") 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 { func testManifestJSON(t *testing.T, sessionID, campaign, runID string) []byte {
t.Helper() t.Helper()
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano) now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
payload := map[string]any{ data, err := json.Marshal(map[string]any{
"schema_version": "narratio.session-manifest.v1", "schema_version": "narratio.session-manifest.v1", "session_id": sessionID,
"session_id": sessionID, "campaign": campaign, "run_id": runID, "created_at": now, "updated_at": now,
"campaign": campaign, "stages": map[string]any{},
"run_id": runID, })
"created_at": now,
"updated_at": now,
"stages": map[string]any{},
}
data, err := json.Marshal(payload)
if err != nil { if err != nil {
t.Fatalf("marshal manifest payload: %v", err) t.Fatalf("marshal manifest payload: %v", err)
} }

View File

@@ -76,8 +76,3 @@ func ResolvePublishRunPrefix(cfg *config.Config, m *manifest.Manifest) (string,
} }
return S3RunPrefix(sessionPrefix, runID), nil 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)
}

View File

@@ -118,13 +118,3 @@ func TestResolvePublishIdentityErrorsAreDeterministic(t *testing.T) {
t.Fatalf("error = %v, want missing run id", err) 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)
}
}

View File

@@ -46,18 +46,6 @@ func S3SessionLocksKey(sessionPrefix string) string {
return path.Join(strings.TrimSuffix(cleanS3Key(sessionPrefix), "/"), "locks.yml") 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. // S3RunCommitKey returns the immutable commit manifest key for one run.
// Format: {session_prefix}/runs/{run_id}/commit.json // Format: {session_prefix}/runs/{run_id}/commit.json
func S3RunCommitKey(sessionPrefix, runID string) string { func S3RunCommitKey(sessionPrefix, runID string) string {

View File

@@ -39,16 +39,6 @@ func TestS3KeyConstruction(t *testing.T) {
t.Fatalf("session manifest key = %q, want %q", got, want) 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) commitPointer := S3CurrentCommitPointerKey(sessionPrefix)
if commitPointer != "dnd/campaigns/forsaken/sessions/2026-04-19/current/commit-pointer.json" { if commitPointer != "dnd/campaigns/forsaken/sessions/2026-04-19/current/commit-pointer.json" {
t.Fatalf("commit pointer key = %q", commitPointer) t.Fatalf("commit pointer key = %q", commitPointer)

View File

@@ -106,9 +106,8 @@ func Resolve(
ValidateRunID: true, ValidateRunID: true,
}) })
if err != nil { if err != nil {
var runPointerMissing *artifacts.CurrentRunPointerMissingError var pointerMissing *artifacts.CurrentCommitPointerMissingError
var manifestMissing *artifacts.CurrentManifestMissingError if errors.As(err, &pointerMissing) {
if errors.As(err, &runPointerMissing) || errors.As(err, &manifestMissing) {
if len(requiredNames) > 0 { if len(requiredNames) > 0 {
return nil, fmt.Errorf("required previous-session artifacts unavailable: remote %w", err) return nil, fmt.Errorf("required previous-session artifacts unavailable: remote %w", err)
} }

View File

@@ -19,7 +19,7 @@ import (
func TestBuildPlanResolvesPublishedArtifactFromPreviousManifest(t *testing.T) { func TestBuildPlanResolvesPublishedArtifactFromPreviousManifest(t *testing.T) {
cfg, paths := previousCacheTestConfig(t) cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{} 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) previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID)
store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")}) 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) { func TestBuildPlanFallsBackToConfiguredOutputPath(t *testing.T) {
cfg, paths := previousCacheTestConfig(t) cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{} 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) previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID)
store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")}) 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) cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{} store := &storage.FakeBackend{}
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"archive/session_recap.md"})) 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")}) 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) _, 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") { if err == nil || !strings.Contains(err.Error(), "object missing from published candidate keys") {
t.Fatalf("Resolve() error = %v, want legacy ambiguity error", err) 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{ _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true}, {Name: "session_recap", Required: true},
}, store) }, 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) 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{ _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true}, {Name: "session_recap", Required: true},
}, store) }, store)
if err == nil || !strings.Contains(err.Error(), `required previous-session artifacts unavailable: remote current run pointer missing`) { 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 run pointer error", err) 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) cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{} 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{ plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: false}, {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) cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{} 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{ _, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true}, {Name: "session_recap", Required: true},
}, store) }, store)
if err == nil || !strings.Contains(err.Error(), `required previous-session artifacts unavailable: remote current manifest missing`) { 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 manifest error", err) 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) { func seedPreviousCurrent(t *testing.T, store *storage.FakeBackend, cfg *config.Config, m *manifest.Manifest) {
t.Helper() 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) data, err := marshalManifestForPreviousCacheTest(m)
if err != nil { if err != nil {
t.Fatalf("marshal manifest: %v", err) 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 { func previousManifestWithOutput(t *testing.T, cfg *config.Config, rel string, publishedPaths []string) *manifest.Manifest {

View File

@@ -2,6 +2,8 @@ package stage
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"io" "io"
"os" "os"
@@ -116,6 +118,7 @@ func TestHydratePreviousSessionArtifactsFailsMissingRequiredArtifact(t *testing.
} }
seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{ seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: true, includeRunPointerObject: true,
includeManifestObject: true,
includeArtifactObject: false, includeArtifactObject: false,
}) })
@@ -175,8 +178,8 @@ func TestHydratePreviousSessionArtifactsRespectsCurrentCommitMarker(t *testing.T
}) })
_, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements) _, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err == nil || !strings.Contains(err.Error(), "remote current run pointer missing") { if err == nil || !strings.Contains(err.Error(), "remote current commit pointer missing") {
t.Fatalf("error = %v, want current run pointer missing failure", err) 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) 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) { if !containsString(capture.accessKeys, seed.RunPointerKey) {
t.Fatalf("access keys = %#v, want %q", 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) campaign := strings.TrimSpace(env.Config.Session.Campaign)
rootPrefix := strings.TrimSpace(env.Config.Pipeline.Storage.S3.RootPrefix) rootPrefix := strings.TrimSpace(env.Config.Pipeline.Storage.S3.RootPrefix)
previousSessionPrefix := artifacts.S3SessionPrefix(rootPrefix, campaign, previousSessionID) previousSessionPrefix := artifacts.S3SessionPrefix(rootPrefix, campaign, previousSessionID)
manifestKey, runPointerKey := artifacts.ResolveCurrentStateKeys(previousSessionPrefix) manifestKey := artifacts.S3RunSessionManifestKey(previousSessionPrefix, "20260510T010203Z-a1b2c3d4")
runPointerKey := artifacts.S3CurrentCommitPointerKey(previousSessionPrefix)
previousRunID := "20260510T010203Z-a1b2c3d4" previousRunID := "20260510T010203Z-a1b2c3d4"
previousSessionRoot := filepath.Join(t.TempDir(), "work", campaign, previousSessionID) previousSessionRoot := filepath.Join(t.TempDir(), "work", campaign, previousSessionID)
@@ -282,12 +283,6 @@ func seedPreviousCurrentState(
artifactLocalPath, 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 { if options.includeManifestObject || options.includeArtifactObject {
fake.SeedObject(storage.FakeObject{ fake.SeedObject(storage.FakeObject{
Key: manifestKey, 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 { if options.includeArtifactObject {
body := options.artifactBody body := options.artifactBody
if body == "" { if body == "" {
@@ -306,6 +301,27 @@ func seedPreviousCurrentState(
Data: []byte(body), 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{ return previousStateSeedResult{
PreviousSessionPrefix: previousSessionPrefix, 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) { func previousHydrationFixture(t *testing.T) (*Env, artifacts.SessionPaths, *storage.FakeBackend) {
t.Helper() t.Helper()
env, m := setupPrepareEnv(t) env, m := setupPrepareEnv(t)

View File

@@ -790,7 +790,7 @@ func TestPrepareStageRerunOverwritesPreviousCache(t *testing.T) {
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
env.ObjectStore = fake env.ObjectStore = fake
seed := seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{ seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: true, includeRunPointerObject: true,
includeArtifactObject: true, includeArtifactObject: true,
artifactBody: "# old recap\n", artifactBody: "# old recap\n",
@@ -810,7 +810,11 @@ func TestPrepareStageRerunOverwritesPreviousCache(t *testing.T) {
} }
writeFile(t, filepath.Join(paths.PreviousArtifactsDir, "stale.txt"), "stale") 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 { if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("second prepare run error = %v", err) t.Fatalf("second prepare run error = %v", err)