Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,711 changes: 1,711 additions & 0 deletions cmd/relayfile-cli/checkpoint_lifecycle.go

Large diffs are not rendered by default.

967 changes: 967 additions & 0 deletions cmd/relayfile-cli/checkpoint_lifecycle_test.go

Large diffs are not rendered by default.

61 changes: 59 additions & 2 deletions cmd/relayfile-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,10 @@ type daemonPIDState struct {
// stop/restart confirm a recorded PID still belongs to a relayfile
// daemon before signaling it, guarding against PID reuse.
Executable string `json:"executable,omitempty"`
// CheckpointConfig is the complete non-secret restart contract for this
// daemon. It is copied into a private resume record before live migration;
// bearer tokens are never persisted in argv or this catalog.
CheckpointConfig *checkpointMountConfig `json:"checkpointConfig,omitempty"`
}

type mountDaemonProcess struct {
Expand Down Expand Up @@ -590,7 +594,12 @@ func main() {
log.SetFlags(0)
if err := run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
exitCode := 1
var coded interface{ ExitCode() int }
if errors.As(err, &coded) {
exitCode = coded.ExitCode()
}
os.Exit(exitCode)
}
}

Expand Down Expand Up @@ -635,6 +644,18 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
// `start` and `on` are friendlier aliases for `mount`. Same flags,
// same foreground/background behavior; pass --background to detach.
// `on` migrates the agent-relay `relay on` mount UX into relayfile.
if len(args) > 1 && args[1] == "checkpoint-seal" {
return runMountCheckpointSeal(args[2:], stdout)
}
if len(args) > 1 && args[1] == "resume-seal" {
return runMountResumeSeal(args[2:], stdin, stdout)
}
if len(args) > 1 && args[1] == "verify-seal" {
return runMountVerifySeal(args[2:], stdin, stdout)
}
if len(args) > 1 && args[1] == "handback-seal" {
return runMountHandbackSeal(args[2:], stdin, stdout)
}
return runMount(args[1:])
case "restart":
return runRestart(args[1:], stdout)
Expand Down Expand Up @@ -738,7 +759,17 @@ func printHelpForArgs(args []string, stdout io.Writer) {
case "pull":
fmt.Fprintln(stdout, "Usage: relayfile pull [--workspace NAME] [--provider PROVIDER] [--reason TEXT]")
case "mount", "start", "on":
printMountHelp(stdout)
if subcommand == "checkpoint-seal" {
fmt.Fprintln(stdout, "Usage: relayfile mount checkpoint-seal --root ABS_LOCAL_ROOT --lifecycle-id STABLE_ID --session ID --generation N [--timeout 30s] [--ttl 60s] --json")
} else if subcommand == "resume-seal" {
fmt.Fprintln(stdout, "Usage: printf '{\"resumeId\":\"...\"}' | relayfile mount resume-seal --root ABS_LOCAL_ROOT [--timeout 60s] --json")
} else if subcommand == "verify-seal" {
fmt.Fprintln(stdout, "Usage: printf '{\"verificationId\":\"...\",\"receipt\":{...}}' | relayfile mount verify-seal --root ABS_LOCAL_ROOT [--timeout 60s] --json")
} else if subcommand == "handback-seal" {
fmt.Fprintln(stdout, "Usage: printf '{\"handbackId\":\"...\",\"consumerIdempotencyKey\":\"...\",\"receipt\":{...}}' | relayfile mount handback-seal --root ABS_LOCAL_ROOT [--timeout 60s] --json")
} else {
printMountHelp(stdout)
}
case "restart":
fmt.Fprintln(stdout, "Usage: relayfile restart [WORKSPACE] [--foreground]")
case "tree", "ls":
Expand Down Expand Up @@ -7545,6 +7576,32 @@ func runMount(args []string) error {
LogFile: logFile,
StartedAt: time.Now().UTC().Format(time.RFC3339),
Executable: resolvedSelfExecutable(),
CheckpointConfig: &checkpointMountConfig{
Version: 1,
Server: strings.TrimRight(strings.TrimSpace(*server), "/"),
CredentialsFile: absolutePathIfSet(delegatedCredsPath),
WorkspaceID: workspaceID,
LocalRoot: absLocalDir,
RemotePaths: append([]string(nil), record.RemotePaths...),
LocalLayout: resolvedLocalLayout,
EventProvider: strings.TrimSpace(*eventProvider),
StateFile: absolutePathIfSet(*stateFile),
StateDir: absolutePathIfSet(*stateDir),
MountKind: mountsync.NormalizeMountKind(*mountKind),
Mode: defaultMountMode,
Interval: interval.String(),
IntervalJitter: *intervalJitter,
Timeout: timeout.String(),
BootstrapTimeout: bootstrapTimeout.String(),
BootstrapMaxFilesPerCycle: *bootstrapMaxFiles,
FullPullMinInterval: fullPullMinInterval.String(),
CursorTimeout: cursorTimeout.String(),
ForceFullReconcile: *fullReconcile,
WebsocketEnabled: *websocketEnabled,
LowMemory: *lowMemory,
PprofAddr: strings.TrimSpace(*pprofAddr),
MemlogInterval: memlogInterval.String(),
},
}); err != nil {
return err
}
Expand Down
47 changes: 47 additions & 0 deletions cmd/relayfile-mount/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ type mountConfig struct {
once bool
flushOutboxOnce bool
pushLocalOnce bool
checkpointAndSeal bool
checkpointSession string
checkpointGeneration uint64
checkpointSealTTL time.Duration
mode string
fuseContentTTL time.Duration
}
Expand Down Expand Up @@ -125,6 +129,10 @@ func main() {
once := flag.Bool("once", false, "run one sync cycle and exit")
flushOutboxOnce := flag.Bool("flush-outbox-once", false, "flush durable writeback outbox once and exit without reconciling the local mirror")
pushLocalOnce := flag.Bool("push-local-once", false, "ingest pending local writeback drafts (one pushLocal pass) then flush the outbox once and exit; no pullRemote/digest/reconcile — the teardown drain for last-moment drafts")
checkpointAndSeal := flag.Bool("checkpoint-and-seal", false, "drain a managed mount, verify durable convergence, emit a one-use server checkpoint seal as JSON, and exit")
checkpointSession := flag.String("checkpoint-session", "", "session identifier bound into --checkpoint-and-seal")
checkpointGeneration := flag.Uint64("checkpoint-generation", 0, "strictly increasing migration generation bound into --checkpoint-and-seal")
checkpointSealTTL := flag.Duration("checkpoint-seal-ttl", mountsync.DefaultCheckpointSealTTL, "one-use checkpoint seal lifetime (maximum 5m)")
flag.Parse()
fullPullMinInterval, err := parseDurationWithNegativeOne(*fullPullMinIntervalArg)
if err != nil {
Expand Down Expand Up @@ -220,6 +228,10 @@ func main() {
once: *once,
flushOutboxOnce: *flushOutboxOnce,
pushLocalOnce: *pushLocalOnce,
checkpointAndSeal: *checkpointAndSeal,
checkpointSession: strings.TrimSpace(*checkpointSession),
checkpointGeneration: *checkpointGeneration,
checkpointSealTTL: *checkpointSealTTL,
mode: resolvedMode,
fuseContentTTL: *fuseContentTTL,
}
Expand Down Expand Up @@ -276,6 +288,18 @@ func resolveSyncMode(mode string) (string, error) {
}

func executeMount(rootCtx context.Context, cfg mountConfig, runPoll pollRunner, runFuse fuseRunner) error {
if cfg.checkpointAndSeal && cfg.mode != mountModePoll {
return fmt.Errorf("--checkpoint-and-seal requires --mode=%s", mountModePoll)
}
if cfg.checkpointAndSeal && (cfg.once || cfg.flushOutboxOnce || cfg.pushLocalOnce) {
return errors.New("--checkpoint-and-seal cannot be combined with --once, --flush-outbox-once, or --push-local-once")
}
if cfg.checkpointAndSeal && (cfg.checkpointSession == "" || cfg.checkpointGeneration == 0) {
return errors.New("--checkpoint-and-seal requires --checkpoint-session and a positive --checkpoint-generation")
}
if cfg.checkpointAndSeal && (cfg.checkpointSealTTL < time.Second || cfg.checkpointSealTTL > mountsync.MaxCheckpointSealTTL) {
return fmt.Errorf("--checkpoint-seal-ttl must be between 1s and %s", mountsync.MaxCheckpointSealTTL)
}
if cfg.mode == mountModeFuse && cfg.syncMode == syncModePullOnly {
return fmt.Errorf("--sync-mode=%s is not supported with --mode=%s; use --mode=%s", syncModePullOnly, mountModeFuse, mountModePoll)
}
Expand Down Expand Up @@ -465,6 +489,21 @@ func runSinglePollingMount(rootCtx context.Context, cfg mountConfig) error {
if _, err := mountsync.StartDiagnostics(rootCtx, cfg.pprofAddr, cfg.memlogInterval, log.Default()); err != nil {
return fmt.Errorf("start diagnostics: %w", err)
}
if cfg.checkpointAndSeal {
ctx, cancel := context.WithTimeout(rootCtx, checkpointOperationTimeout(cfg.timeout))
defer cancel()
seal, err := syncer.CheckpointAndSeal(ctx, mountsync.CheckpointAndSealOptions{
SessionID: cfg.checkpointSession, Generation: cfg.checkpointGeneration,
TTLSeconds: int(cfg.checkpointSealTTL / time.Second),
})
if err != nil {
return fmt.Errorf("checkpoint and seal: %w", err)
}
if err := json.NewEncoder(os.Stdout).Encode(seal); err != nil {
return fmt.Errorf("encode checkpoint seal: %w", err)
}
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if cfg.pushLocalOnce {
ctx, cancel := context.WithTimeout(rootCtx, cfg.timeout)
defer cancel()
Expand Down Expand Up @@ -607,6 +646,14 @@ func runSinglePollingMount(rootCtx context.Context, cfg mountConfig) error {
}
}

func checkpointOperationTimeout(configured time.Duration) time.Duration {
const minimum = 30 * time.Second
if configured < minimum {
return minimum
}
return configured
}

type mountCredsFile struct {
Token string `json:"token"`
AccessToken string `json:"accessToken,omitempty"`
Expand Down
63 changes: 63 additions & 0 deletions cmd/relayfile-mount/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,69 @@ func TestResolveMountMode(t *testing.T) {
}
}

func TestCheckpointAndSealCLIValidationFailsBeforeStartingMount(t *testing.T) {
tests := []struct {
name string
cfg mountConfig
want string
}{
{
name: "fuse stays mounted",
cfg: mountConfig{checkpointAndSeal: true, mode: mountModeFuse, checkpointSession: "session-1", checkpointGeneration: 1, checkpointSealTTL: time.Minute},
want: "requires --mode=poll",
},
{
name: "identity required",
cfg: mountConfig{checkpointAndSeal: true, mode: mountModePoll, checkpointSealTTL: time.Minute},
want: "requires --checkpoint-session",
},
{
name: "ttl bounded",
cfg: mountConfig{checkpointAndSeal: true, mode: mountModePoll, checkpointSession: "session-1", checkpointGeneration: 1, checkpointSealTTL: mountsync.MaxCheckpointSealTTL + time.Second},
want: "--checkpoint-seal-ttl must be between",
},
{
name: "one-shot modes are exclusive",
cfg: mountConfig{checkpointAndSeal: true, once: true, mode: mountModePoll, checkpointSession: "session-1", checkpointGeneration: 1, checkpointSealTTL: time.Minute},
want: "cannot be combined",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pollCalled := false
fuseCalled := false
err := executeMount(
context.Background(),
tc.cfg,
func(context.Context, mountConfig) error { pollCalled = true; return nil },
func(context.Context, mountConfig) error { fuseCalled = true; return nil },
)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("expected error containing %q, got %v", tc.want, err)
}
if pollCalled || fuseCalled {
t.Fatal("invalid checkpoint request must fail before starting a mount")
}
})
}
}

func TestCheckpointOperationTimeoutHasThirtySecondFloor(t *testing.T) {
for _, tc := range []struct {
configured time.Duration
want time.Duration
}{
{configured: 0, want: 30 * time.Second},
{configured: 15 * time.Second, want: 30 * time.Second},
{configured: 30 * time.Second, want: 30 * time.Second},
{configured: 45 * time.Second, want: 45 * time.Second},
} {
if got := checkpointOperationTimeout(tc.configured); got != tc.want {
t.Fatalf("checkpointOperationTimeout(%s) = %s, want %s", tc.configured, got, tc.want)
}
}
}

func TestResolveLocalLayout(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading