Bind restore to committed remote snapshots

This commit is contained in:
2026-08-10 20:42:43 +00:00
parent eac7e155a5
commit 4158394dcf
17 changed files with 686 additions and 75 deletions

View File

@@ -27,17 +27,29 @@ const (
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.
type RestoreAction struct {
Kind RestoreActionKind
RemoteKey string
LocalRelativePath string
LocalPath string
SHA256 string
Generation string
Size int64
ETag string
ExistsLocal bool
SameLocal bool
Conflict bool
ConflictKind RestoreConflictKind
Reason string
}
@@ -66,55 +78,10 @@ func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCu
if store == nil {
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)
objects, err := store.List(ctx, prefix)
actions, err := buildCurrentRestoreActions(ctx, current, store, sessionPaths, opts)
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 == "" {
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)
return nil, err
}
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
}
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 {
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(v), "\\", "/"), "/")
}
@@ -250,7 +349,9 @@ func buildPreviousCacheRestoreActions(
}
actions := make([]RestoreAction, 0, len(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 {
return nil, fmt.Errorf("classify previous-session cache object %q: %w", record.RemoteKey, err)
}
@@ -263,6 +364,7 @@ func classifyRestoreAction(
ctx context.Context,
store storage.ObjectStore,
object storage.ObjectInfo,
expectedSHA256 string,
localRelPath string,
localPath string,
force bool,
@@ -271,9 +373,13 @@ func classifyRestoreAction(
RemoteKey: normalizeRemoteKey(object.Key),
LocalRelativePath: localRelPath,
LocalPath: localPath,
SHA256: strings.TrimSpace(expectedSHA256),
Size: object.Size,
ETag: object.ETag,
}
if action.SHA256 != "" {
action.Generation = strings.TrimSpace(object.ETag)
}
info, err := os.Stat(localPath)
if err != nil {
@@ -289,9 +395,39 @@ func classifyRestoreAction(
if info.IsDir() {
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictDirectory
action.Reason = "local path is a directory"
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 object.Size > 0 {
@@ -308,6 +444,7 @@ func classifyRestoreAction(
}
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictContentMismatch
action.Reason = "local audio differs (size mismatch)"
return action, nil
}
@@ -318,6 +455,7 @@ func classifyRestoreAction(
}
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictContentMismatch
action.Reason = "local audio exists; remote size unavailable"
return action, nil
}
@@ -330,6 +468,7 @@ func classifyRestoreAction(
}
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictContentMismatch
action.Reason = "local file differs (size mismatch)"
return action, nil
}
@@ -364,6 +503,7 @@ func classifyRestoreAction(
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictContentMismatch
action.Reason = "local file differs"
return action, nil
}