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

View File

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

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

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;
- 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.

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

View File

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

View File

@@ -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())

View File

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

View File

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

View File

@@ -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),

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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,
)

View File

@@ -71,7 +71,6 @@ func loadCommittedCurrentState(
state := &CurrentState{
SessionPrefix: sessionPrefix,
CurrentRunIDKey: pointerKey,
CurrentPointerKey: pointerKey,
CurrentManifestKey: sessionManifest.DestinationKey,
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"
"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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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