540 lines
22 KiB
Go
540 lines
22 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
)
|
|
|
|
func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`))
|
|
seedRestoreObject(fake, sessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
|
|
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))
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "Restored session state for sample-campaign/2026-05-03") {
|
|
t.Fatalf("stdout = %q, want completion summary", stdout.String())
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1,2,3]}`)
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
|
|
reportPath := filepath.Join(sessionRoot, "reports", "restore-latest.json")
|
|
report := mustReadRestoreReport(t, reportPath)
|
|
if report.Status != "succeeded" {
|
|
t.Fatalf("report status = %q, want succeeded", report.Status)
|
|
}
|
|
if report.Execution.Downloaded != 3 {
|
|
t.Fatalf("report execution.downloaded = %d, want 3", report.Execution.Downloaded)
|
|
}
|
|
if len(report.Actions) == 0 {
|
|
t.Fatal("report actions is empty")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(sessionRoot, "audio", "alice.flac")); !os.IsNotExist(err) {
|
|
t.Fatalf("audio should not be restored by default; stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio"))
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio")
|
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
|
if !report.IncludeAudio {
|
|
t.Fatalf("report include_audio = %v, want true", report.IncludeAudio)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
audioKey := sessionPrefix + "audio/alice.flac"
|
|
seedRestoreObject(fake, audioKey, []byte("remote-audio"))
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if got := fakeDownloadCount(fake, audioKey); got != 1 {
|
|
t.Fatalf("audio downloads after first restore = %d, want 1", got)
|
|
}
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio")
|
|
|
|
if err := os.RemoveAll(sessionRoot); err != nil {
|
|
t.Fatalf("remove session root: %v", err)
|
|
}
|
|
stdout.Reset()
|
|
stderr.Reset()
|
|
code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if got := fakeDownloadCount(fake, audioKey); got != 1 {
|
|
t.Fatalf("audio downloads after cached restore = %d, want still 1", got)
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio")
|
|
}
|
|
|
|
func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
previousManifestBytes, err := os.ReadFile(filepath.Join(sessionRoot, "previous", "manifest.json"))
|
|
if err != nil {
|
|
t.Fatalf("read restored previous manifest: %v", err)
|
|
}
|
|
if !strings.Contains(string(previousManifestBytes), `"session_id":"2026-04-26"`) {
|
|
t.Fatalf("restored previous manifest = %q, want previous session id", string(previousManifestBytes))
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
|
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
|
if report.Execution.Downloaded != 3 {
|
|
t.Fatalf("report execution.downloaded = %d, want 3", report.Execution.Downloaded)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreDryRunReportsPreviousCacheWithoutWriting(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "previous/artifacts/session_recap.md") {
|
|
t.Fatalf("stdout = %q, want planned previous-cache artifact", stdout.String())
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
if _, err := os.Stat(filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md")); !os.IsNotExist(err) {
|
|
t.Fatalf("previous artifact should not be written during dry-run; stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "conflicting path") {
|
|
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
|
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
|
if report.Status != "failed" {
|
|
t.Fatalf("report status = %q, want failed", report.Status)
|
|
}
|
|
if report.Plan.Conflicts != 1 {
|
|
t.Fatalf("report plan.conflicts = %d, want 1", report.Plan.Conflicts)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
|
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
|
if !report.Force {
|
|
t.Fatalf("report force = %v, want true", report.Force)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestorePreviousCurrent(t, fake, cfg, "# remote previous recap\n")
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# remote previous recap\n")
|
|
}
|
|
|
|
func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
fake := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
|
|
|
|
store := artifacts.NewLocalStore(workspaceRoot)
|
|
lock, err := store.AcquireSessionLockFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
|
if err != nil {
|
|
t.Fatalf("AcquireSessionLockFor() error = %v", err)
|
|
}
|
|
defer func() { _ = store.ReleaseSessionLock(lock) }()
|
|
|
|
restoreWithStoreAndRealPhases(t, fake)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "acquire session lock") {
|
|
t.Fatalf("stderr = %q, want lock failure", stderr.String())
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
if _, err := os.Stat(filepath.Join(sessionRoot, "transcripts", "full.json")); !os.IsNotExist(err) {
|
|
t.Fatalf("transcript should not be restored when lock acquisition fails; stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.T) {
|
|
workspaceRoot := t.TempDir()
|
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
|
|
|
base := &storage.FakeBackend{}
|
|
cfg, sessionPrefix, manifestKey, _ := seedRestoreCommittedState(t, base, pipelinePath, campaignPath, sessionPath)
|
|
seedRestoreObject(base, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
|
|
|
|
toggled := &stagedManifestDownloadStore{
|
|
delegate: base,
|
|
manifestKey: manifestKey,
|
|
firstManifest: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign),
|
|
secondManifest: []byte("{invalid json"),
|
|
manifestReads: 0,
|
|
}
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
existing := manifest.New(cfg.Session.SessionID, nowUTC())
|
|
existing.Campaign = cfg.Session.Campaign
|
|
existingPath := filepath.Join(sessionRoot, "manifest.json")
|
|
manifestStore := &manifest.LocalStore{}
|
|
if err := manifestStore.Save(context.Background(), existingPath, existing); err != nil {
|
|
t.Fatalf("save existing local manifest: %v", err)
|
|
}
|
|
existingData, err := os.ReadFile(existingPath)
|
|
if err != nil {
|
|
t.Fatalf("read existing local manifest: %v", err)
|
|
}
|
|
|
|
restoreWithStoreAndRealPhases(t, toggled)
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
|
if code == 0 {
|
|
t.Fatal("exit code = 0, want non-zero")
|
|
}
|
|
if !strings.Contains(stderr.String(), "validate manifest decode") {
|
|
t.Fatalf("stderr = %q, want manifest validation failure", stderr.String())
|
|
}
|
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
|
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
|
if report.Status != "failed" {
|
|
t.Fatalf("report status = %q, want failed", report.Status)
|
|
}
|
|
if strings.TrimSpace(report.Error) == "" {
|
|
t.Fatal("report error is empty, want failure context")
|
|
}
|
|
afterData, err := os.ReadFile(existingPath)
|
|
if err != nil {
|
|
t.Fatalf("read local manifest after failure: %v", err)
|
|
}
|
|
if string(afterData) != string(existingData) {
|
|
t.Fatalf("local manifest changed after failed restore; before=%q after=%q", string(existingData), string(afterData))
|
|
}
|
|
}
|
|
|
|
func TestExecuteRestorePlanPathMismatchFails(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
|
|
|
|
plan := &RestorePlan{Actions: []RestoreAction{{
|
|
Kind: RestoreActionDownload,
|
|
RemoteKey: current.SessionPrefix + "transcripts/full.json",
|
|
LocalRelativePath: "transcripts/full.json",
|
|
LocalPath: "/tmp/escape.txt",
|
|
}}}
|
|
|
|
report, err := newRestoreReport(current, plan, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("newRestoreReport() error = %v", err)
|
|
}
|
|
_, err = executeRestorePlan(context.Background(), cfg, current, plan, report, store)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "local path mismatch") {
|
|
t.Fatalf("error = %v, want local path mismatch", err)
|
|
}
|
|
}
|
|
|
|
func mustReadRestoreReport(t *testing.T, path string) *RestoreReport {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%q): %v", path, err)
|
|
}
|
|
var report RestoreReport
|
|
if err := json.Unmarshal(data, &report); err != nil {
|
|
t.Fatalf("Unmarshal restore report %q: %v", path, err)
|
|
}
|
|
return &report
|
|
}
|
|
|
|
func restoreWithStoreAndRealPhases(t *testing.T, objectStore storage.ObjectStore) {
|
|
t.Helper()
|
|
origStoreFn := newObjectStoreFromConfigFn
|
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
|
origPlanFn := buildRestorePlanFn
|
|
origExecuteFn := executeRestorePlanFn
|
|
t.Cleanup(func() {
|
|
newObjectStoreFromConfigFn = origStoreFn
|
|
discoverRemoteCurrentStateFn = origDiscoverFn
|
|
buildRestorePlanFn = origPlanFn
|
|
executeRestorePlanFn = origExecuteFn
|
|
})
|
|
|
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
|
return objectStore, nil
|
|
}
|
|
discoverRemoteCurrentStateFn = discoverRemoteCurrentState
|
|
buildRestorePlanFn = buildRestorePlan
|
|
executeRestorePlanFn = executeRestorePlan
|
|
}
|
|
|
|
func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipelinePath, campaignPath, sessionPath string) (*config.Config, string, string, string) {
|
|
t.Helper()
|
|
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
|
|
if err != nil {
|
|
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
|
}
|
|
if err := config.Validate(cfg); err != nil {
|
|
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
|
|
}
|
|
|
|
func appendRestoreWorkflowPreviousInputConfig(t *testing.T, pipelinePath, sessionPath string) {
|
|
t.Helper()
|
|
appendRestoreWorkflowScriptoriumConfig(t, pipelinePath, `
|
|
scriptorium:
|
|
binary: scriptorium
|
|
artifacts:
|
|
session_recap:
|
|
enabled: true
|
|
prompt_id: dnd.session_recap
|
|
output_path: artifacts/session_recap.md
|
|
inputs:
|
|
previous_recap:
|
|
source: narratio.previous_session.artifact.session_recap
|
|
required: true
|
|
`)
|
|
appendRestoreWorkflowScriptoriumConfig(t, sessionPath, `
|
|
previous_session_id: 2026-04-26
|
|
`)
|
|
}
|
|
|
|
func seedRestorePreviousCurrent(t *testing.T, fake *storage.FakeBackend, cfg *config.Config, artifactBody string) {
|
|
t.Helper()
|
|
seedRestorePreviousCurrentManifestOnly(t, fake, cfg)
|
|
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
|
seedRestoreObject(fake, previousPrefix+"artifacts/session_recap.md", []byte(artifactBody))
|
|
}
|
|
|
|
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
|
|
m.RunID = previousRunID
|
|
data, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatalf("marshal previous restore manifest: %v", err)
|
|
}
|
|
seedRestoreObject(fake, manifestKey, append(data, '\n'))
|
|
}
|
|
|
|
func mustReadEquals(t *testing.T, path, want string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(%q): %v", path, err)
|
|
}
|
|
if string(data) != want {
|
|
t.Fatalf("file %q = %q, want %q", path, string(data), want)
|
|
}
|
|
}
|
|
|
|
func fakeDownloadCount(fake *storage.FakeBackend, key string) int {
|
|
count := 0
|
|
for _, call := range fake.Downloads {
|
|
if call.Key == key {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
type stagedManifestDownloadStore struct {
|
|
delegate *storage.FakeBackend
|
|
manifestKey string
|
|
firstManifest []byte
|
|
secondManifest []byte
|
|
manifestReads int
|
|
}
|
|
|
|
func (s *stagedManifestDownloadStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
|
return s.delegate.List(ctx, prefix)
|
|
}
|
|
|
|
func (s *stagedManifestDownloadStore) Download(ctx context.Context, key, localPath string) error {
|
|
if strings.TrimSpace(key) == strings.TrimSpace(s.manifestKey) {
|
|
s.manifestReads++
|
|
payload := s.secondManifest
|
|
if s.manifestReads <= 1 {
|
|
payload = s.firstManifest
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
|
|
return fmt.Errorf("download staged manifest: create parent: %w", err)
|
|
}
|
|
if err := os.WriteFile(localPath, payload, 0o644); err != nil {
|
|
return fmt.Errorf("download staged manifest: write local file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
return s.delegate.Download(ctx, key, localPath)
|
|
}
|
|
|
|
func (s *stagedManifestDownloadStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
|
|
return s.delegate.Upload(ctx, localPath, key, opts)
|
|
}
|
|
|
|
func (s *stagedManifestDownloadStore) Exists(ctx context.Context, key string) (bool, error) {
|
|
return s.delegate.Exists(ctx, key)
|
|
}
|