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

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