Bind restore to committed remote snapshots
This commit is contained in:
@@ -168,7 +168,8 @@ Read-only preflight checks for config validity, required inputs, audio mode, pre
|
|||||||
narratio session status <session_id> [...common config flags]
|
narratio session status <session_id> [...common config flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
Prints local manifest state and, when storage is available, remote current-state and published-output status.
|
Prints local manifest state and, when storage is available, status for the
|
||||||
|
pointer-selected remote commit and its declared published outputs.
|
||||||
|
|
||||||
### `session init`
|
### `session init`
|
||||||
|
|
||||||
@@ -211,7 +212,8 @@ Behavior:
|
|||||||
- discovers committed remote current state;
|
- discovers committed remote current state;
|
||||||
- plans local restores;
|
- plans local restores;
|
||||||
- writes an execution report;
|
- writes an execution report;
|
||||||
- blocks conflicting overwrites unless `--force` is set.
|
- blocks unresolved conflicts. `--force` permits replacement only of eligible
|
||||||
|
regular files.
|
||||||
|
|
||||||
See [Operations: Restore Workflow](./operations.md#restore-workflow) for the
|
See [Operations: Restore Workflow](./operations.md#restore-workflow) for the
|
||||||
default restore scope, report location, and conflict-handling workflow.
|
default restore scope, report location, and conflict-handling workflow.
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ Validation by content type:
|
|||||||
Artifacts package owns shared remote current-state loading mechanics used by
|
Artifacts package owns shared remote current-state loading mechanics used by
|
||||||
restore, status and validation checks, and previous-cache planning.
|
restore, status and validation checks, and previous-cache planning.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
Core helpers:
|
Core helpers:
|
||||||
|
|
||||||
- `LoadCurrentState`
|
- `LoadCurrentState`
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Discovery delegates current-state pointer and manifest loading to
|
|||||||
|
|
||||||
- campaign must match;
|
- campaign must match;
|
||||||
- session ID must match.
|
- session ID must match.
|
||||||
|
- run ID must match the pointer-selected committed run.
|
||||||
|
|
||||||
Restore treats any missing or invalid remote current state as a command error.
|
Restore treats any missing or invalid remote current state as a command error.
|
||||||
|
|
||||||
@@ -31,10 +32,13 @@ Restore planner action kinds:
|
|||||||
|
|
||||||
Planner behavior:
|
Planner behavior:
|
||||||
|
|
||||||
- remote list scope is the resolved session prefix;
|
- a new-protocol restore uses only the selected commit's declared artifact set;
|
||||||
|
each action carries that artifact's immutable key, checksum, size, and
|
||||||
|
generation. Coherent legacy state remains on the isolated compatibility path;
|
||||||
- remote-to-local mapping is traversal-safe;
|
- remote-to-local mapping is traversal-safe;
|
||||||
- actions are sorted by local relative path and then remote key;
|
- actions are sorted by local relative path and then remote key;
|
||||||
- force converts differing local targets from conflicts to downloads.
|
- force converts differing eligible regular files from conflicts to downloads;
|
||||||
|
directories and other non-regular targets remain conflicts.
|
||||||
|
|
||||||
Previous-cache files are planned separately through `previouscache.BuildPlan`
|
Previous-cache files are planned separately through `previouscache.BuildPlan`
|
||||||
when configured previous-session requirements exist.
|
when configured previous-session requirements exist.
|
||||||
@@ -47,6 +51,8 @@ Execution order and safety:
|
|||||||
- `manifest.json` installs last;
|
- `manifest.json` installs last;
|
||||||
- downloads use sibling temp files plus atomic rename;
|
- downloads use sibling temp files plus atomic rename;
|
||||||
- manifest replacement is validated before rename;
|
- manifest replacement is validated before rename;
|
||||||
|
- each committed object is verified against its declared checksum, size, and
|
||||||
|
generation before installation;
|
||||||
- failed installs do not roll back files already written in the same execution.
|
- failed installs do not roll back files already written in the same execution.
|
||||||
|
|
||||||
Audio restore path:
|
Audio restore path:
|
||||||
@@ -65,6 +71,9 @@ Audio restore path:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- restore uses committed remote current state as authority;
|
- restore uses committed remote current state as authority;
|
||||||
|
- one restore or status inspection observes the single pointer-selected commit
|
||||||
|
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
|
- a verified `current/commit-pointer.json` and its selected immutable commit
|
||||||
establish new-protocol remote commitment; coherent legacy
|
establish new-protocol remote commitment; coherent legacy
|
||||||
`current/run_id.txt` plus `current/manifest.json` remains read-only migration
|
`current/run_id.txt` plus `current/manifest.json` remains read-only migration
|
||||||
|
|||||||
@@ -271,15 +271,15 @@ narratio session restore 2026-04-04
|
|||||||
|
|
||||||
Default restore scope:
|
Default restore scope:
|
||||||
|
|
||||||
- `manifest.json`
|
- the committed session manifest and the committed transcript/artifact objects
|
||||||
- `transcripts/**`
|
declared by the selected remote commit
|
||||||
- `artifacts/**`
|
|
||||||
- `previous/**` when needed by configured previous-session artifact inputs
|
- `previous/**` when needed by configured previous-session artifact inputs
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
- `--include-audio` to include `audio/**`
|
- `--include-audio` to include `audio/**`
|
||||||
- `--force` to overwrite local conflicts
|
- `--force` to overwrite eligible conflicting regular files; it never replaces
|
||||||
|
directories or other non-regular local targets
|
||||||
|
|
||||||
Restore writes an execution report at `reports/restore-latest.json`.
|
Restore writes an execution report at `reports/restore-latest.json`.
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ install the validated session manifest after other restored durable files. The
|
|||||||
physical workflow and recovery procedures belong in
|
physical workflow and recovery procedures belong in
|
||||||
[Operations](../operations.md).
|
[Operations](../operations.md).
|
||||||
|
|
||||||
|
For the immutable remote-commit protocol, a restore or status operation binds
|
||||||
|
to one pointer-selected commit and only its declared object identities. A force
|
||||||
|
flag may replace an eligible regular managed file, but never turns a directory
|
||||||
|
or other non-regular conflict into a successful restore.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is strict, explicit, centralized, and operator-oriented.
|
Configuration is strict, explicit, centralized, and operator-oriented.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ All stages are pending when this plan is created.
|
|||||||
| 14 | Publish through immutable commits and canonical mappings | COR-004, COR-011, DUP-002, TST-004 | Completed |
|
| 14 | Publish through immutable commits and canonical mappings | COR-004, COR-011, DUP-002, TST-004 | Completed |
|
||||||
| 15 | Make remote locks generation-safe and harden pagination | RSK-005, RSK-014 | Completed |
|
| 15 | Make remote locks generation-safe and harden pagination | RSK-005, RSK-014 | Completed |
|
||||||
| 16 | Persist retryable post-commit cleanup state | COR-006, COR-007 | Completed |
|
| 16 | Persist retryable post-commit cleanup state | COR-006, COR-007 | Completed |
|
||||||
| 17 | Bind restore/status to a committed snapshot and reject conflicts | COR-008, COR-009, TST-005 | Pending |
|
| 17 | Bind restore/status to a committed snapshot and reject conflicts | COR-008, COR-009, TST-005 | Completed |
|
||||||
| 18 | Serialize restore transitions and make restored paths portable | RSK-006, RSK-008 | Pending |
|
| 18 | Serialize restore transitions and make restored paths portable | RSK-006, RSK-008 | Pending |
|
||||||
| 19 | Bind audio cache reuse to remote object identity | RSK-007 | Pending |
|
| 19 | Bind audio cache reuse to remote object identity | RSK-007 | Pending |
|
||||||
| 20 | Unify previous-source readiness and eliminate duplicate transfers | COR-010, EFF-001 | Pending |
|
| 20 | Unify previous-source readiness and eliminate duplicate transfers | COR-010, EFF-001 | Pending |
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type FakeBackend struct {
|
|||||||
UploadErr error
|
UploadErr error
|
||||||
ExistsErr error
|
ExistsErr error
|
||||||
UploadHook func(FakeUploadCall) error
|
UploadHook func(FakeUploadCall) error
|
||||||
|
DownloadHook func(FakeDownloadCall) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// FakeUploadCall captures one upload invocation in call order.
|
// FakeUploadCall captures one upload invocation in call order.
|
||||||
@@ -139,6 +140,11 @@ func (f *FakeBackend) DownloadTo(ctx context.Context, key string, destination io
|
|||||||
if destination == nil {
|
if destination == nil {
|
||||||
return fmt.Errorf("download object: destination writer is required")
|
return fmt.Errorf("download object: destination writer is required")
|
||||||
}
|
}
|
||||||
|
if f.DownloadHook != nil {
|
||||||
|
if err := f.DownloadHook(FakeDownloadCall{Key: normalizeObjectKey(key)}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, source, err := f.Read(ctx, key)
|
_, source, err := f.Read(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -129,6 +129,47 @@ func remotePublishedOutputAvailability(ctx context.Context, cfg *config.Config,
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remotePublishedOutputAvailabilityForCurrent(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg *config.Config,
|
||||||
|
store storage.ObjectStore,
|
||||||
|
catalog *artifacts.ArtifactCatalog,
|
||||||
|
current *RemoteCurrentState,
|
||||||
|
) map[string]string {
|
||||||
|
if current == nil || current.Commit == nil {
|
||||||
|
return remotePublishedOutputAvailability(ctx, cfg, store, catalog)
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
runPrefix := artifacts.S3RunPrefix(current.SessionPrefix, current.RunID)
|
||||||
|
for _, rule := range cfg.Pipeline.Publish.Outputs {
|
||||||
|
source := strings.TrimSpace(rule.Source)
|
||||||
|
dest, _, err := helperPublishedOutputDest(rule, catalog)
|
||||||
|
if err != nil {
|
||||||
|
out[publishedOutputRemoteStateKey(source, "")] = "remote=error"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := artifacts.S3RunRelativeDestinationKey(runPrefix, dest)
|
||||||
|
if committedPublishedOutput(current.Commit, key) {
|
||||||
|
out[publishedOutputRemoteStateKey(source, dest)] = "remote=published"
|
||||||
|
} else {
|
||||||
|
out[publishedOutputRemoteStateKey(source, dest)] = "remote=missing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func committedPublishedOutput(commit *artifacts.RemoteCommitManifest, key string) bool {
|
||||||
|
if commit == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, artifact := range commit.Artifacts {
|
||||||
|
if artifact.Type == artifacts.RemoteArtifactTypePublishedOutput && artifact.DestinationKey == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, bool, error) {
|
func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, bool, error) {
|
||||||
source := strings.TrimSpace(rule.Source)
|
source := strings.TrimSpace(rule.Source)
|
||||||
normalized, err := artifactpolicy.ResolvePublishedDestinationWithExtractions(
|
normalized, err := artifactpolicy.ResolvePublishedDestinationWithExtractions(
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
store, storeErr := objectStoreIfConfigured(ctx, cfg)
|
store, storeErr := objectStoreIfConfigured(ctx, cfg)
|
||||||
|
var remoteCurrent *RemoteCurrentState
|
||||||
if storeErr != nil {
|
if storeErr != nil {
|
||||||
fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr)
|
fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr)
|
||||||
} else if store != nil {
|
} else if store != nil {
|
||||||
@@ -60,6 +61,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if current.Err != nil {
|
if current.Err != nil {
|
||||||
fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", current.Err)
|
fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", current.Err)
|
||||||
} else {
|
} else {
|
||||||
|
remoteCurrent = current.State
|
||||||
fmt.Fprintf(out, "Remote publish: current run %s\n", current.State.RunID)
|
fmt.Fprintf(out, "Remote publish: current run %s\n", current.State.RunID)
|
||||||
fmt.Fprintf(out, "Remote manifest: %s\n", current.State.CurrentManifestKey)
|
fmt.Fprintf(out, "Remote manifest: %s\n", current.State.CurrentManifestKey)
|
||||||
}
|
}
|
||||||
@@ -87,7 +89,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
}
|
}
|
||||||
publishedRemoteState := map[string]string{}
|
publishedRemoteState := map[string]string{}
|
||||||
if store != nil {
|
if store != nil {
|
||||||
publishedRemoteState = remotePublishedOutputAvailability(ctx, cfg, store, catalog)
|
publishedRemoteState = remotePublishedOutputAvailabilityForCurrent(ctx, cfg, store, catalog, remoteCurrent)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Remote outputs:")
|
fmt.Fprintln(out, "Remote outputs:")
|
||||||
writeArtifactList(out, cfg, catalog, catalogLocks, publishedRemoteState)
|
writeArtifactList(out, cfg, catalog, catalogLocks, publishedRemoteState)
|
||||||
|
|||||||
@@ -110,13 +110,13 @@ func Restore(ctx context.Context, args []string, out io.Writer) (resultErr error
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if plan.ConflictCount > 0 && !force {
|
if plan.ConflictCount > 0 {
|
||||||
report.setFailed(fmt.Errorf("conflict: %d conflicting path(s)", plan.ConflictCount))
|
report.setFailed(fmt.Errorf("conflict: %d conflicting path(s)", plan.ConflictCount))
|
||||||
if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil {
|
if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil {
|
||||||
return fmt.Errorf("restore: report failure: %w", reportErr)
|
return fmt.Errorf("restore: report failure: %w", reportErr)
|
||||||
}
|
}
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"restore conflict: %d conflicting path(s); rerun with --force to overwrite (download=%d skip_same=%d conflicts=%d)",
|
"restore conflict: %d conflicting path(s); --force can replace eligible regular files but not unresolved conflicts (download=%d skip_same=%d conflicts=%d)",
|
||||||
plan.ConflictCount,
|
plan.ConflictCount,
|
||||||
plan.DownloadCount,
|
plan.DownloadCount,
|
||||||
plan.SkipSameCount,
|
plan.SkipSameCount,
|
||||||
|
|||||||
258
internal/app/restore_commit_test.go
Normal file
258
internal/app/restore_commit_test.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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 TestCommittedRestorePlanUsesOnlyDeclaredObjects(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{
|
||||||
|
"transcripts/full.json": []byte(`{"segments":[1]}`),
|
||||||
|
})
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: current.SessionPrefix + "artifacts/stale.md", Data: []byte("stale")})
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, "20260519T010204Z-e5f6a7b8"), "transcripts/other.json"), Data: []byte("other")})
|
||||||
|
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
got := actionRelPaths(plan.Actions)
|
||||||
|
want := []string{"manifest.json", "transcripts/full.json"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("action paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
for index := range want {
|
||||||
|
if got[index] != want[index] {
|
||||||
|
t.Fatalf("action paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommittedStatusReportsOnlyDeclaredPublishedOutputs(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
cfg.Pipeline.Publish = &config.PublishConfig{Outputs: []config.PublishOutputRule{{
|
||||||
|
Source: "narratio.transcript.final_trimmed",
|
||||||
|
Dest: "transcripts/full.json",
|
||||||
|
}}}
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{
|
||||||
|
"transcripts/full.json": []byte("declared\n"),
|
||||||
|
})
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: current.SessionPrefix + "transcripts/full.json", Data: []byte("mutable stale copy\n")})
|
||||||
|
|
||||||
|
availability := remotePublishedOutputAvailabilityForCurrent(context.Background(), cfg, fake, nil, current)
|
||||||
|
key := publishedOutputRemoteStateKey("narratio.transcript.final_trimmed", "transcripts/full.json")
|
||||||
|
if availability[key] != "remote=published" {
|
||||||
|
t.Fatalf("availability = %#v, want committed published output", availability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommittedRestoreKeepsSelectedSnapshotWhenPointerChanges(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
first := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{
|
||||||
|
"transcripts/full.json": []byte("from first commit\n"),
|
||||||
|
})
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, first, fake, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
_ = seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010204Z-e5f6a7b8", map[string][]byte{
|
||||||
|
"transcripts/full.json": []byte("from second commit\n"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := executeRestorePlan(context.Background(), cfg, first, plan, nil, fake); err != nil {
|
||||||
|
t.Fatalf("executeRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
mustReadEquals(t, filepath.Join(root, "transcripts", "full.json"), "from first commit\n")
|
||||||
|
restored, err := (&manifest.LocalStore{}).Load(context.Background(), filepath.Join(root, "manifest.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load restored manifest: %v", err)
|
||||||
|
}
|
||||||
|
if restored.RunID != first.RunID {
|
||||||
|
t.Fatalf("restored run id = %q, want %q", restored.RunID, first.RunID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommittedRestoreRejectsChangedDeclaredObjectBeforeManifestInstall(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{
|
||||||
|
"transcripts/full.json": []byte("committed bytes\n"),
|
||||||
|
})
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, current.RunID), "transcripts/full.json")
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("changed bytes\n")})
|
||||||
|
|
||||||
|
if _, err := executeRestorePlan(context.Background(), cfg, current, plan, nil, fake); err == nil {
|
||||||
|
t.Fatal("executeRestorePlan() error = nil, want committed-object verification failure")
|
||||||
|
}
|
||||||
|
root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "manifest.json")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("manifest should not be installed after failed restore; stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommittedRestoreRejectsChangedDeclaredObjectGeneration(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{
|
||||||
|
"transcripts/full.json": []byte("committed bytes\n"),
|
||||||
|
})
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
key := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, current.RunID), "transcripts/full.json")
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: key, Data: []byte("committed bytes\n"), ETag: "replacement-generation"})
|
||||||
|
|
||||||
|
if _, err := executeRestorePlan(context.Background(), cfg, current, plan, nil, fake); err == nil {
|
||||||
|
t.Fatal("executeRestorePlan() error = nil, want generation verification failure")
|
||||||
|
}
|
||||||
|
root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "manifest.json")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("manifest should not be installed after failed restore; stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommittedRestoreRejectsMissingDeclaredObjectBeforeManifestInstall(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{
|
||||||
|
"transcripts/full.json": []byte("committed bytes\n"),
|
||||||
|
})
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
missingKey := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(current.SessionPrefix, current.RunID), "transcripts/full.json")
|
||||||
|
fake.DownloadHook = func(call storage.FakeDownloadCall) error {
|
||||||
|
if call.Key == missingKey {
|
||||||
|
return os.ErrNotExist
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := executeRestorePlan(context.Background(), cfg, current, plan, nil, fake); err == nil {
|
||||||
|
t.Fatal("executeRestorePlan() error = nil, want missing-object failure")
|
||||||
|
}
|
||||||
|
root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "manifest.json")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("manifest should not be installed after failed restore; stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommittedRestoreForceRetainsDirectoryConflict(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
current := seedCommittedRestoreSnapshot(t, cfg, fake, "20260519T010203Z-a1b2c3d4", map[string][]byte{
|
||||||
|
"transcripts/full.json": []byte("committed bytes\n"),
|
||||||
|
})
|
||||||
|
root := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "transcripts", "full.json"), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, fake, RestorePlanOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
if plan.ConflictCount != 1 {
|
||||||
|
t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount)
|
||||||
|
}
|
||||||
|
for _, action := range plan.Actions {
|
||||||
|
if action.LocalRelativePath == "transcripts/full.json" && action.ConflictKind != RestoreConflictDirectory {
|
||||||
|
t.Fatalf("ConflictKind = %q, want %q", action.ConflictKind, RestoreConflictDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedCommittedRestoreSnapshot(t *testing.T, cfg *config.Config, fake *storage.FakeBackend, runID string, outputs map[string][]byte) *RemoteCurrentState {
|
||||||
|
t.Helper()
|
||||||
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
remoteManifest := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
remoteManifest.Campaign = cfg.Session.Campaign
|
||||||
|
remoteManifest.RunID = runID
|
||||||
|
manifestData, err := json.Marshal(remoteManifest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal manifest: %v", err)
|
||||||
|
}
|
||||||
|
manifestData = append(manifestData, '\n')
|
||||||
|
manifestKey := artifacts.S3RunSessionManifestKey(sessionPrefix, runID)
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: manifestData})
|
||||||
|
artifactsByKey := []artifacts.RemoteArtifact{remoteRestoreArtifact(fake, artifacts.RemoteArtifactTypeSessionManifest, "session.manifest", manifestKey)}
|
||||||
|
|
||||||
|
paths := make([]string, 0, len(outputs))
|
||||||
|
for relative := range outputs {
|
||||||
|
paths = append(paths, relative)
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
commit := artifacts.RemoteCommitManifest{
|
||||||
|
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||||
|
Campaign: cfg.Session.Campaign,
|
||||||
|
SessionID: cfg.Session.SessionID,
|
||||||
|
RunID: runID,
|
||||||
|
Artifacts: artifactsByKey,
|
||||||
|
}
|
||||||
|
commitData, err := artifacts.EncodeRemoteCommitManifest(commit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode remote commit: %v", err)
|
||||||
|
}
|
||||||
|
commitKey := artifacts.S3RunCommitKey(sessionPrefix, runID)
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: commitKey, Data: commitData})
|
||||||
|
commitObject := fake.Objects[commitKey]
|
||||||
|
pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{
|
||||||
|
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||||
|
Campaign: cfg.Session.Campaign,
|
||||||
|
SessionID: cfg.Session.SessionID,
|
||||||
|
RunID: runID,
|
||||||
|
CommitKey: commitKey,
|
||||||
|
CommitSHA256: restoreCommitSHA256(commitData),
|
||||||
|
CommitSize: int64(len(commitData)),
|
||||||
|
CommitGeneration: commitObject.ETag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("encode current pointer: %v", err)
|
||||||
|
}
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: artifacts.S3CurrentCommitPointerKey(sessionPrefix), Data: pointerData})
|
||||||
|
current, err := discoverRemoteCurrentState(context.Background(), cfg, fake)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("discoverRemoteCurrentState() error = %v", err)
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteRestoreArtifact(fake *storage.FakeBackend, artifactType artifacts.RemoteArtifactType, source, key string) artifacts.RemoteArtifact {
|
||||||
|
object := fake.Objects[key]
|
||||||
|
return artifacts.RemoteArtifact{
|
||||||
|
Type: artifactType, Source: source, DestinationKey: key, SHA256: restoreCommitSHA256(object.Data), Size: int64(len(object.Data)), Generation: object.ETag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreCommitSHA256(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type RemoteCurrentState struct {
|
|||||||
SessionID string
|
SessionID string
|
||||||
Campaign string
|
Campaign string
|
||||||
Manifest *manifest.Manifest
|
Manifest *manifest.Manifest
|
||||||
|
Commit *artifacts.RemoteCommitManifest
|
||||||
}
|
}
|
||||||
|
|
||||||
func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*RemoteCurrentState, error) {
|
func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*RemoteCurrentState, error) {
|
||||||
@@ -44,6 +45,7 @@ func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store s
|
|||||||
current, err := artifacts.LoadCurrentState(ctx, store, sessionPrefix, artifacts.CurrentStateValidation{
|
current, err := artifacts.LoadCurrentState(ctx, store, sessionPrefix, artifacts.CurrentStateValidation{
|
||||||
ExpectedSessionID: requestedSession,
|
ExpectedSessionID: requestedSession,
|
||||||
ExpectedCampaign: requestedCampaign,
|
ExpectedCampaign: requestedCampaign,
|
||||||
|
ValidateRunID: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("remote %w", err)
|
return nil, fmt.Errorf("remote %w", err)
|
||||||
@@ -58,5 +60,6 @@ func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store s
|
|||||||
SessionID: strings.TrimSpace(current.Manifest.SessionID),
|
SessionID: strings.TrimSpace(current.Manifest.SessionID),
|
||||||
Campaign: strings.TrimSpace(current.Manifest.Campaign),
|
Campaign: strings.TrimSpace(current.Manifest.Campaign),
|
||||||
Manifest: current.Manifest,
|
Manifest: current.Manifest,
|
||||||
|
Commit: current.Commit,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ func restoreManifestJSON(t *testing.T, sessionID, campaign string) []byte {
|
|||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"session_id": sessionID,
|
"session_id": sessionID,
|
||||||
"campaign": campaign,
|
"campaign": campaign,
|
||||||
|
"run_id": "20260519T010203Z-a1b2c3d4",
|
||||||
"created_at": now,
|
"created_at": now,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
"stages": map[string]any{},
|
"stages": map[string]any{},
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -108,6 +110,9 @@ func executeRestoreDownloadAction(
|
|||||||
return fmt.Errorf("download to temp file: %w", err)
|
return fmt.Errorf("download to temp file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = temporary.Cleanup() }()
|
defer func() { _ = temporary.Cleanup() }()
|
||||||
|
if err := verifyRestoredObject(ctx, store, action, temporary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if action.LocalRelativePath == config.PathManifestFile {
|
if action.LocalRelativePath == config.PathManifestFile {
|
||||||
file, err := temporary.Open()
|
file, err := temporary.Open()
|
||||||
@@ -131,6 +136,56 @@ func executeRestoreDownloadAction(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func verifyRestoredObject(ctx context.Context, store storage.ObjectStore, action RestoreAction, temporary *fileops.DownloadedTempFile) error {
|
||||||
|
if strings.TrimSpace(action.SHA256) == "" && strings.TrimSpace(action.Generation) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(action.SHA256) == "" || strings.TrimSpace(action.Generation) == "" {
|
||||||
|
return fmt.Errorf("committed object identity for %q is incomplete", action.RemoteKey)
|
||||||
|
}
|
||||||
|
file, err := temporary.Open()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open downloaded object for verification: %w", err)
|
||||||
|
}
|
||||||
|
digest := sha256.New()
|
||||||
|
count, copyErr := io.Copy(digest, file)
|
||||||
|
closeErr := file.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return fmt.Errorf("checksum downloaded object: %w", copyErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return fmt.Errorf("close downloaded object: %w", closeErr)
|
||||||
|
}
|
||||||
|
if count != action.Size {
|
||||||
|
return fmt.Errorf("committed object size mismatch for %q: got %d, want %d", action.RemoteKey, count, action.Size)
|
||||||
|
}
|
||||||
|
if got := hex.EncodeToString(digest.Sum(nil)); got != action.SHA256 {
|
||||||
|
return fmt.Errorf("committed object checksum mismatch for %q: got %s, want %s", action.RemoteKey, got, action.SHA256)
|
||||||
|
}
|
||||||
|
objects, err := store.List(ctx, action.RemoteKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read committed object identity for %q: %w", action.RemoteKey, err)
|
||||||
|
}
|
||||||
|
var found *storage.ObjectInfo
|
||||||
|
for _, object := range objects {
|
||||||
|
if normalizeRemoteKey(object.Key) != normalizeRemoteKey(action.RemoteKey) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if found != nil {
|
||||||
|
return fmt.Errorf("committed object %q is ambiguous", action.RemoteKey)
|
||||||
|
}
|
||||||
|
copy := object
|
||||||
|
found = ©
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
return fmt.Errorf("committed object %q is missing", action.RemoteKey)
|
||||||
|
}
|
||||||
|
if found.Size != action.Size || strings.TrimSpace(found.ETag) != action.Generation {
|
||||||
|
return fmt.Errorf("committed object generation mismatch for %q", action.RemoteKey)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func executeRestoreAudioAction(
|
func executeRestoreAudioAction(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
@@ -190,6 +245,9 @@ func validateRestoredManifest(ctx context.Context, cfg *config.Config, current *
|
|||||||
if expected := strings.TrimSpace(current.Campaign); expected != "" && manifestCampaign != expected {
|
if expected := strings.TrimSpace(current.Campaign); expected != "" && manifestCampaign != expected {
|
||||||
return fmt.Errorf("manifest campaign %q does not match discovered campaign %q", manifestCampaign, expected)
|
return fmt.Errorf("manifest campaign %q does not match discovered campaign %q", manifestCampaign, expected)
|
||||||
}
|
}
|
||||||
|
if expected := strings.TrimSpace(current.RunID); expected != "" && strings.TrimSpace(m.RunID) != expected {
|
||||||
|
return fmt.Errorf("manifest run_id %q does not match discovered run_id %q", strings.TrimSpace(m.RunID), expected)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -27,17 +27,29 @@ const (
|
|||||||
RestoreActionConflict RestoreActionKind = "conflict"
|
RestoreActionConflict RestoreActionKind = "conflict"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RestoreConflictKind identifies why a local target cannot be restored safely.
|
||||||
|
type RestoreConflictKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RestoreConflictContentMismatch RestoreConflictKind = "content_mismatch"
|
||||||
|
RestoreConflictDirectory RestoreConflictKind = "directory"
|
||||||
|
RestoreConflictNonRegular RestoreConflictKind = "non_regular"
|
||||||
|
)
|
||||||
|
|
||||||
// RestoreAction is one deterministic planner action.
|
// RestoreAction is one deterministic planner action.
|
||||||
type RestoreAction struct {
|
type RestoreAction struct {
|
||||||
Kind RestoreActionKind
|
Kind RestoreActionKind
|
||||||
RemoteKey string
|
RemoteKey string
|
||||||
LocalRelativePath string
|
LocalRelativePath string
|
||||||
LocalPath string
|
LocalPath string
|
||||||
|
SHA256 string
|
||||||
|
Generation string
|
||||||
Size int64
|
Size int64
|
||||||
ETag string
|
ETag string
|
||||||
ExistsLocal bool
|
ExistsLocal bool
|
||||||
SameLocal bool
|
SameLocal bool
|
||||||
Conflict bool
|
Conflict bool
|
||||||
|
ConflictKind RestoreConflictKind
|
||||||
Reason string
|
Reason string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,55 +78,10 @@ func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCu
|
|||||||
if store == nil {
|
if store == nil {
|
||||||
return nil, fmt.Errorf("remote object store is required")
|
return nil, fmt.Errorf("remote object store is required")
|
||||||
}
|
}
|
||||||
prefix := normalizeRemoteKey(current.SessionPrefix)
|
|
||||||
if strings.TrimSpace(prefix) == "" {
|
|
||||||
return nil, fmt.Errorf("remote session prefix is required")
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(prefix, "/") {
|
|
||||||
prefix += "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
objects, err := store.List(ctx, prefix)
|
actions, err := buildCurrentRestoreActions(ctx, current, store, sessionPaths, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list remote session objects under %q: %w", prefix, err)
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
candidates := make(map[string]storage.ObjectInfo, len(objects)+1)
|
|
||||||
for _, obj := range objects {
|
|
||||||
key := normalizeRemoteKey(obj.Key)
|
|
||||||
if key == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
obj.Key = key
|
|
||||||
candidates[key] = obj
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(current.CurrentManifestKey) != "" {
|
|
||||||
key := normalizeRemoteKey(current.CurrentManifestKey)
|
|
||||||
if _, ok := candidates[key]; !ok {
|
|
||||||
candidates[key] = storage.ObjectInfo{Key: key}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actions := make([]RestoreAction, 0, len(candidates))
|
|
||||||
for key, obj := range candidates {
|
|
||||||
rel, include, err := restoreLocalRelativePathForKey(prefix, normalizeRemoteKey(current.CurrentManifestKey), key, opts.IncludeAudio)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
|
||||||
}
|
|
||||||
if !include {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
action, err := classifyRestoreAction(ctx, store, obj, rel, localPath, opts.Force)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("classify remote key %q: %w", key, err)
|
|
||||||
}
|
|
||||||
actions = append(actions, action)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
previousActions, err := buildPreviousCacheRestoreActions(ctx, cfg, sessionPaths, store, opts.Force)
|
previousActions, err := buildPreviousCacheRestoreActions(ctx, cfg, sessionPaths, store, opts.Force)
|
||||||
@@ -146,6 +113,138 @@ func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCu
|
|||||||
return plan, nil
|
return plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildCurrentRestoreActions(
|
||||||
|
ctx context.Context,
|
||||||
|
current *RemoteCurrentState,
|
||||||
|
store storage.ObjectStore,
|
||||||
|
sessionPaths artifacts.SessionPaths,
|
||||||
|
opts RestorePlanOptions,
|
||||||
|
) ([]RestoreAction, error) {
|
||||||
|
if current != nil && current.Commit != nil {
|
||||||
|
return buildCommittedRestoreActions(ctx, current, store, sessionPaths, opts)
|
||||||
|
}
|
||||||
|
return buildLegacyRestoreActions(ctx, current, store, sessionPaths, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCommittedRestoreActions(
|
||||||
|
ctx context.Context,
|
||||||
|
current *RemoteCurrentState,
|
||||||
|
store storage.ObjectStore,
|
||||||
|
sessionPaths artifacts.SessionPaths,
|
||||||
|
opts RestorePlanOptions,
|
||||||
|
) ([]RestoreAction, error) {
|
||||||
|
if current == nil || current.Commit == nil {
|
||||||
|
return nil, fmt.Errorf("committed remote current state is required")
|
||||||
|
}
|
||||||
|
runPrefix := artifacts.S3RunPrefix(current.SessionPrefix, current.RunID)
|
||||||
|
if runPrefix == "" {
|
||||||
|
return nil, fmt.Errorf("committed run prefix is required")
|
||||||
|
}
|
||||||
|
actions := make([]RestoreAction, 0, len(current.Commit.Artifacts))
|
||||||
|
for _, artifact := range current.Commit.Artifacts {
|
||||||
|
rel, include, err := restoreLocalRelativePathForCommittedArtifact(runPrefix, artifact, opts.IncludeAudio)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("map committed object %q: %w", artifact.DestinationKey, err)
|
||||||
|
}
|
||||||
|
if !include {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("map committed object %q: %w", artifact.DestinationKey, err)
|
||||||
|
}
|
||||||
|
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{
|
||||||
|
Key: artifact.DestinationKey, Size: artifact.Size, ETag: artifact.Generation,
|
||||||
|
}, artifact.SHA256, rel, localPath, opts.Force)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("classify committed object %q: %w", artifact.DestinationKey, err)
|
||||||
|
}
|
||||||
|
actions = append(actions, action)
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildLegacyRestoreActions(
|
||||||
|
ctx context.Context,
|
||||||
|
current *RemoteCurrentState,
|
||||||
|
store storage.ObjectStore,
|
||||||
|
sessionPaths artifacts.SessionPaths,
|
||||||
|
opts RestorePlanOptions,
|
||||||
|
) ([]RestoreAction, error) {
|
||||||
|
prefix := normalizeRemoteKey(current.SessionPrefix)
|
||||||
|
if strings.TrimSpace(prefix) == "" {
|
||||||
|
return nil, fmt.Errorf("remote session prefix is required")
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(prefix, "/") {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
objects, err := store.List(ctx, prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list remote session objects under %q: %w", prefix, err)
|
||||||
|
}
|
||||||
|
candidates := make(map[string]storage.ObjectInfo, len(objects)+1)
|
||||||
|
for _, obj := range objects {
|
||||||
|
key := normalizeRemoteKey(obj.Key)
|
||||||
|
if key != "" {
|
||||||
|
obj.Key = key
|
||||||
|
candidates[key] = obj
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key := normalizeRemoteKey(current.CurrentManifestKey); key != "" {
|
||||||
|
if _, ok := candidates[key]; !ok {
|
||||||
|
candidates[key] = storage.ObjectInfo{Key: key}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actions := make([]RestoreAction, 0, len(candidates))
|
||||||
|
for key, object := range candidates {
|
||||||
|
rel, include, err := restoreLocalRelativePathForKey(prefix, normalizeRemoteKey(current.CurrentManifestKey), key, opts.IncludeAudio)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
||||||
|
}
|
||||||
|
if !include {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
||||||
|
}
|
||||||
|
action, err := classifyRestoreAction(ctx, store, object, "", rel, localPath, opts.Force)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("classify remote key %q: %w", key, err)
|
||||||
|
}
|
||||||
|
actions = append(actions, action)
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreLocalRelativePathForCommittedArtifact(runPrefix string, artifact artifacts.RemoteArtifact, includeAudio bool) (string, bool, error) {
|
||||||
|
if artifact.Type == artifacts.RemoteArtifactTypeSessionManifest {
|
||||||
|
return config.PathManifestFile, true, nil
|
||||||
|
}
|
||||||
|
if artifact.Type != artifacts.RemoteArtifactTypePublishedOutput {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
key := normalizeRemoteKey(artifact.DestinationKey)
|
||||||
|
if !strings.HasPrefix(key, runPrefix) {
|
||||||
|
return "", false, fmt.Errorf("object is outside committed run prefix %q", runPrefix)
|
||||||
|
}
|
||||||
|
rel := strings.TrimPrefix(key, runPrefix)
|
||||||
|
cleanRel, err := pathsafe.NormalizeRelativeDestination(rel)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("committed destination is unsafe: %w", err)
|
||||||
|
}
|
||||||
|
if cleanRel == config.PathManifestFile {
|
||||||
|
return "", false, fmt.Errorf("published output conflicts with the session manifest path")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(cleanRel, config.PathTranscriptsSegment+"/") || strings.HasPrefix(cleanRel, config.PathArtifactsDirSegment+"/") {
|
||||||
|
return cleanRel, true, nil
|
||||||
|
}
|
||||||
|
if includeAudio && strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/") {
|
||||||
|
return cleanRel, true, nil
|
||||||
|
}
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeRemoteKey(v string) string {
|
func normalizeRemoteKey(v string) string {
|
||||||
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(v), "\\", "/"), "/")
|
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(v), "\\", "/"), "/")
|
||||||
}
|
}
|
||||||
@@ -250,7 +349,9 @@ func buildPreviousCacheRestoreActions(
|
|||||||
}
|
}
|
||||||
actions := make([]RestoreAction, 0, len(plan.Records))
|
actions := make([]RestoreAction, 0, len(plan.Records))
|
||||||
for _, record := range plan.Records {
|
for _, record := range plan.Records {
|
||||||
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{Key: record.RemoteKey}, record.LocalRelativePath, record.LocalPath, force)
|
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{
|
||||||
|
Key: record.RemoteKey, Size: record.Size, ETag: record.Generation,
|
||||||
|
}, record.SHA256, record.LocalRelativePath, record.LocalPath, force)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("classify previous-session cache object %q: %w", record.RemoteKey, err)
|
return nil, fmt.Errorf("classify previous-session cache object %q: %w", record.RemoteKey, err)
|
||||||
}
|
}
|
||||||
@@ -263,6 +364,7 @@ func classifyRestoreAction(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
store storage.ObjectStore,
|
store storage.ObjectStore,
|
||||||
object storage.ObjectInfo,
|
object storage.ObjectInfo,
|
||||||
|
expectedSHA256 string,
|
||||||
localRelPath string,
|
localRelPath string,
|
||||||
localPath string,
|
localPath string,
|
||||||
force bool,
|
force bool,
|
||||||
@@ -271,9 +373,13 @@ func classifyRestoreAction(
|
|||||||
RemoteKey: normalizeRemoteKey(object.Key),
|
RemoteKey: normalizeRemoteKey(object.Key),
|
||||||
LocalRelativePath: localRelPath,
|
LocalRelativePath: localRelPath,
|
||||||
LocalPath: localPath,
|
LocalPath: localPath,
|
||||||
|
SHA256: strings.TrimSpace(expectedSHA256),
|
||||||
Size: object.Size,
|
Size: object.Size,
|
||||||
ETag: object.ETag,
|
ETag: object.ETag,
|
||||||
}
|
}
|
||||||
|
if action.SHA256 != "" {
|
||||||
|
action.Generation = strings.TrimSpace(object.ETag)
|
||||||
|
}
|
||||||
|
|
||||||
info, err := os.Stat(localPath)
|
info, err := os.Stat(localPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -289,9 +395,39 @@ func classifyRestoreAction(
|
|||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
action.Kind = RestoreActionConflict
|
action.Kind = RestoreActionConflict
|
||||||
action.Conflict = true
|
action.Conflict = true
|
||||||
|
action.ConflictKind = RestoreConflictDirectory
|
||||||
action.Reason = "local path is a directory"
|
action.Reason = "local path is a directory"
|
||||||
return action, nil
|
return action, nil
|
||||||
}
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
action.Kind = RestoreActionConflict
|
||||||
|
action.Conflict = true
|
||||||
|
action.ConflictKind = RestoreConflictNonRegular
|
||||||
|
action.Reason = "local path is not a regular file"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
if action.SHA256 != "" {
|
||||||
|
localDigest, err := artifacts.SHA256File(localPath)
|
||||||
|
if err != nil {
|
||||||
|
return RestoreAction{}, fmt.Errorf("checksum local file: %w", err)
|
||||||
|
}
|
||||||
|
if localDigest == action.SHA256 {
|
||||||
|
action.Kind = RestoreActionSkipSame
|
||||||
|
action.SameLocal = true
|
||||||
|
action.Reason = "local file matches committed content"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
if force {
|
||||||
|
action.Kind = RestoreActionDownload
|
||||||
|
action.Reason = "local file differs from committed content; overwrite with --force"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
action.Kind = RestoreActionConflict
|
||||||
|
action.Conflict = true
|
||||||
|
action.ConflictKind = RestoreConflictContentMismatch
|
||||||
|
action.Reason = "local file differs from committed content"
|
||||||
|
return action, nil
|
||||||
|
}
|
||||||
|
|
||||||
if restoreRelativePathIsAudio(localRelPath) {
|
if restoreRelativePathIsAudio(localRelPath) {
|
||||||
if object.Size > 0 {
|
if object.Size > 0 {
|
||||||
@@ -308,6 +444,7 @@ func classifyRestoreAction(
|
|||||||
}
|
}
|
||||||
action.Kind = RestoreActionConflict
|
action.Kind = RestoreActionConflict
|
||||||
action.Conflict = true
|
action.Conflict = true
|
||||||
|
action.ConflictKind = RestoreConflictContentMismatch
|
||||||
action.Reason = "local audio differs (size mismatch)"
|
action.Reason = "local audio differs (size mismatch)"
|
||||||
return action, nil
|
return action, nil
|
||||||
}
|
}
|
||||||
@@ -318,6 +455,7 @@ func classifyRestoreAction(
|
|||||||
}
|
}
|
||||||
action.Kind = RestoreActionConflict
|
action.Kind = RestoreActionConflict
|
||||||
action.Conflict = true
|
action.Conflict = true
|
||||||
|
action.ConflictKind = RestoreConflictContentMismatch
|
||||||
action.Reason = "local audio exists; remote size unavailable"
|
action.Reason = "local audio exists; remote size unavailable"
|
||||||
return action, nil
|
return action, nil
|
||||||
}
|
}
|
||||||
@@ -330,6 +468,7 @@ func classifyRestoreAction(
|
|||||||
}
|
}
|
||||||
action.Kind = RestoreActionConflict
|
action.Kind = RestoreActionConflict
|
||||||
action.Conflict = true
|
action.Conflict = true
|
||||||
|
action.ConflictKind = RestoreConflictContentMismatch
|
||||||
action.Reason = "local file differs (size mismatch)"
|
action.Reason = "local file differs (size mismatch)"
|
||||||
return action, nil
|
return action, nil
|
||||||
}
|
}
|
||||||
@@ -364,6 +503,7 @@ func classifyRestoreAction(
|
|||||||
|
|
||||||
action.Kind = RestoreActionConflict
|
action.Kind = RestoreActionConflict
|
||||||
action.Conflict = true
|
action.Conflict = true
|
||||||
|
action.ConflictKind = RestoreConflictContentMismatch
|
||||||
action.Reason = "local file differs"
|
action.Reason = "local file differs"
|
||||||
return action, nil
|
return action, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
|
|||||||
if stdout.Len() != 0 {
|
if stdout.Len() != 0 {
|
||||||
t.Fatalf("stdout = %q, want empty on conflict failure", stdout.String())
|
t.Fatalf("stdout = %q, want empty on conflict failure", stdout.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "restore conflict: 1 conflicting path(s); rerun with --force to overwrite") {
|
if !strings.Contains(stderr.String(), "restore conflict: 1 conflicting path(s)") {
|
||||||
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
|
||||||
}
|
}
|
||||||
if strings.Contains(stderr.String(), "phase 4: restore execution") {
|
if strings.Contains(stderr.String(), "phase 4: restore execution") {
|
||||||
@@ -328,6 +328,45 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteRestoreForceStillBlocksUnresolvedConflict(t *testing.T) {
|
||||||
|
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 &storage.FakeBackend{}, nil
|
||||||
|
}
|
||||||
|
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
|
||||||
|
return &RemoteCurrentState{SessionID: "2026-05-03", Campaign: "sample-campaign", RunID: "20260519T010203Z-a1b2c3d4"}, nil
|
||||||
|
}
|
||||||
|
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
|
||||||
|
return &RestorePlan{Actions: []RestoreAction{{Kind: RestoreActionConflict, LocalRelativePath: "artifacts/session_recap.md", Reason: "local path is a directory"}}, ConflictCount: 1}, nil
|
||||||
|
}
|
||||||
|
executed := false
|
||||||
|
executeRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, *RestoreReport, storage.ObjectStore) (*RestoreExecutionResult, error) {
|
||||||
|
executed = true
|
||||||
|
return &RestoreExecutionResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
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 conflict failure")
|
||||||
|
}
|
||||||
|
if executed {
|
||||||
|
t.Fatal("restore execution ran despite unresolved conflict")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
|
func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
|
||||||
origStoreFn := newObjectStoreFromConfigFn
|
origStoreFn := newObjectStoreFromConfigFn
|
||||||
origDiscoverFn := discoverRemoteCurrentStateFn
|
origDiscoverFn := discoverRemoteCurrentStateFn
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ type Record struct {
|
|||||||
LocalRelativePath string
|
LocalRelativePath string
|
||||||
LocalPath string
|
LocalPath string
|
||||||
RemoteKey string
|
RemoteKey string
|
||||||
|
SHA256 string
|
||||||
|
Size int64
|
||||||
|
Generation string
|
||||||
S3Bucket string
|
S3Bucket string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,13 +122,23 @@ func BuildPlan(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result.Records = append(result.Records, Record{
|
manifestRecord := Record{
|
||||||
Kind: InputKindManifest,
|
Kind: InputKindManifest,
|
||||||
LocalRelativePath: manifestRel,
|
LocalRelativePath: manifestRel,
|
||||||
LocalPath: paths.PreviousManifestPath,
|
LocalPath: paths.PreviousManifestPath,
|
||||||
RemoteKey: currentManifestKey,
|
RemoteKey: currentManifestKey,
|
||||||
S3Bucket: bucket,
|
S3Bucket: bucket,
|
||||||
})
|
}
|
||||||
|
if current.Commit != nil {
|
||||||
|
committedManifest, ok := current.Commit.Artifact(artifacts.RemoteArtifactTypeSessionManifest)
|
||||||
|
if !ok || committedManifest.DestinationKey != currentManifestKey {
|
||||||
|
return nil, fmt.Errorf("previous-session remote commit does not declare its session manifest")
|
||||||
|
}
|
||||||
|
manifestRecord.SHA256 = committedManifest.SHA256
|
||||||
|
manifestRecord.Size = committedManifest.Size
|
||||||
|
manifestRecord.Generation = committedManifest.Generation
|
||||||
|
}
|
||||||
|
result.Records = append(result.Records, manifestRecord)
|
||||||
|
|
||||||
for _, requirement := range orderedRequirements {
|
for _, requirement := range orderedRequirements {
|
||||||
candidates := artifactRelativePathCandidates(requirement.Name, previousManifest, cfg)
|
candidates := artifactRelativePathCandidates(requirement.Name, previousManifest, cfg)
|
||||||
@@ -142,19 +155,29 @@ func BuildPlan(
|
|||||||
|
|
||||||
selectedRel := ""
|
selectedRel := ""
|
||||||
selectedKey := ""
|
selectedKey := ""
|
||||||
|
var selectedArtifact *artifacts.RemoteArtifact
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
|
if current.Commit != nil {
|
||||||
|
artifact, ok := committedPublishedArtifact(current.Commit, previousSessionPrefix, candidate)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
selectedRel = candidate
|
||||||
|
selectedKey = artifact.DestinationKey
|
||||||
|
selectedArtifact = &artifact
|
||||||
|
break
|
||||||
|
}
|
||||||
remoteKey := artifacts.S3PublishedOutputKey(previousSessionPrefix, candidate)
|
remoteKey := artifacts.S3PublishedOutputKey(previousSessionPrefix, candidate)
|
||||||
exists, err := store.Exists(ctx, remoteKey)
|
exists, err := store.Exists(ctx, remoteKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("check previous-session artifact object %q: %w", remoteKey, err)
|
return nil, fmt.Errorf("check previous-session artifact object %q: %w", remoteKey, err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if exists {
|
||||||
continue
|
|
||||||
}
|
|
||||||
selectedRel = candidate
|
selectedRel = candidate
|
||||||
selectedKey = remoteKey
|
selectedKey = remoteKey
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if selectedRel == "" {
|
if selectedRel == "" {
|
||||||
if requirement.Required {
|
if requirement.Required {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -174,7 +197,7 @@ func BuildPlan(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result.Records = append(result.Records, Record{
|
record := Record{
|
||||||
Kind: InputKindArtifact,
|
Kind: InputKindArtifact,
|
||||||
RequirementName: requirement.Name,
|
RequirementName: requirement.Name,
|
||||||
Required: requirement.Required,
|
Required: requirement.Required,
|
||||||
@@ -182,7 +205,13 @@ func BuildPlan(
|
|||||||
LocalPath: localPath,
|
LocalPath: localPath,
|
||||||
RemoteKey: selectedKey,
|
RemoteKey: selectedKey,
|
||||||
S3Bucket: bucket,
|
S3Bucket: bucket,
|
||||||
})
|
}
|
||||||
|
if selectedArtifact != nil {
|
||||||
|
record.SHA256 = selectedArtifact.SHA256
|
||||||
|
record.Size = selectedArtifact.Size
|
||||||
|
record.Generation = selectedArtifact.Generation
|
||||||
|
}
|
||||||
|
result.Records = append(result.Records, record)
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Strings(result.SkippedMissing)
|
sort.Strings(result.SkippedMissing)
|
||||||
@@ -195,6 +224,19 @@ func BuildPlan(
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func committedPublishedArtifact(commit *artifacts.RemoteCommitManifest, sessionPrefix, relativePath string) (artifacts.RemoteArtifact, bool) {
|
||||||
|
if commit == nil {
|
||||||
|
return artifacts.RemoteArtifact{}, false
|
||||||
|
}
|
||||||
|
want := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, commit.RunID), relativePath)
|
||||||
|
for _, artifact := range commit.Artifacts {
|
||||||
|
if artifact.Type == artifacts.RemoteArtifactTypePublishedOutput && artifact.DestinationKey == want {
|
||||||
|
return artifact, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return artifacts.RemoteArtifact{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func requiredPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string {
|
func requiredPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string {
|
||||||
names := make([]string, 0, len(requirements))
|
names := make([]string, 0, len(requirements))
|
||||||
for _, requirement := range requirements {
|
for _, requirement := range requirements {
|
||||||
|
|||||||
Reference in New Issue
Block a user