@bivy/bivy 0.3.0-staging.40 → 0.3.0-staging.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/control-plane-tasks.js +5 -0
- package/dist/ephemeral-exec.js +1 -0
- package/dist/ephemeral-teardown.js +66 -0
- package/dist/pairing-crypto.js +3 -1
- package/dist/server.js +154 -1
- package/dist/session/snapshot.js +61 -0
- package/dist/wire-format.js +4 -0
- package/package.json +1 -1
|
@@ -117,6 +117,11 @@ export class ControlPlaneTaskPoller {
|
|
|
117
117
|
poke() {
|
|
118
118
|
void this.tick();
|
|
119
119
|
}
|
|
120
|
+
/** Number of queue items currently running on this node. Lets an ephemeral
|
|
121
|
+
* machine's self-teardown avoid exiting while it's mid-work. */
|
|
122
|
+
inFlightCount() {
|
|
123
|
+
return this.inFlight.size;
|
|
124
|
+
}
|
|
120
125
|
/**
|
|
121
126
|
* Replace the routing labels this live poller serves.
|
|
122
127
|
*
|
package/dist/ephemeral-exec.js
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
/** Parse the daemon's ephemeral-teardown config from the process env. */
|
|
4
|
+
export function readEphemeralTeardownConfig(env = process.env) {
|
|
5
|
+
return {
|
|
6
|
+
enabled: env.BIVY_EPHEMERAL === "1",
|
|
7
|
+
provider: String(env.BIVY_EPHEMERAL_PROVIDER || "").toLowerCase(),
|
|
8
|
+
ttlMin: Number(env.BIVY_EPHEMERAL_TTL_MIN) || 60,
|
|
9
|
+
onFinish: env.BIVY_TEARDOWN_ON_FINISH === "1",
|
|
10
|
+
finishGraceMs: Number(env.BIVY_TEARDOWN_FINISH_GRACE_MS) || 10_000,
|
|
11
|
+
idleGraceMs: Number(env.BIVY_SESSION_IDLE_CLOSE_MS) || 30 * 60 * 1000,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Pure decision: should this ephemeral machine tear itself down now? True only
|
|
16
|
+
* when it has done work at least once and is now fully quiet — nothing running,
|
|
17
|
+
* no device attached, no queue work in flight — for longer than the applicable
|
|
18
|
+
* grace (short after an agent finishes, else the idle window).
|
|
19
|
+
*/
|
|
20
|
+
export function shouldSelfTeardown(cfg, state) {
|
|
21
|
+
if (!cfg.enabled)
|
|
22
|
+
return false;
|
|
23
|
+
if (!state.everBusy)
|
|
24
|
+
return false;
|
|
25
|
+
if (state.anyWorking || state.anyRemoteActive || state.inFlightWork > 0)
|
|
26
|
+
return false;
|
|
27
|
+
const grace = cfg.onFinish ? cfg.finishGraceMs : cfg.idleGraceMs;
|
|
28
|
+
return state.idleForMs >= grace;
|
|
29
|
+
}
|
|
30
|
+
let torndown = false;
|
|
31
|
+
/** Reset the once-only latch — tests only. */
|
|
32
|
+
export function __resetTeardownLatch() {
|
|
33
|
+
torndown = false;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Execute the teardown. Signals the control plane, then stops the daemon:
|
|
37
|
+
* - Fly: exiting the init process trips `auto_destroy` — the machine is reaped.
|
|
38
|
+
* - EC2: `shutdown -h now` self-terminates the instance, then we exit.
|
|
39
|
+
* - Hetzner: exiting takes the node offline; the control-plane reconciler issues
|
|
40
|
+
* the provider DELETE (it can't self-reap on OS shutdown). The /node/settled
|
|
41
|
+
* signal makes that prompt; the CP timer is the backstop.
|
|
42
|
+
* Once-only: a racing idle sweep + agent_end can both call this safely.
|
|
43
|
+
*/
|
|
44
|
+
export async function performSelfTeardown(deps) {
|
|
45
|
+
if (torndown)
|
|
46
|
+
return;
|
|
47
|
+
torndown = true;
|
|
48
|
+
const log = deps.log ?? ((m) => console.log(`[ephemeral-teardown] ${m}`));
|
|
49
|
+
const exit = deps.exit ?? ((c) => process.exit(c));
|
|
50
|
+
log(`machine idle — self-teardown (provider=${deps.provider})`);
|
|
51
|
+
try {
|
|
52
|
+
await deps.signalSettled?.();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* best effort — the CP timer reconciler and TTL are the backstops */
|
|
56
|
+
}
|
|
57
|
+
if (deps.provider === "aws") {
|
|
58
|
+
try {
|
|
59
|
+
deps.shutdown?.();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* fall through to exit; the TTL shutdown still backstops */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
exit(0);
|
|
66
|
+
}
|
package/dist/pairing-crypto.js
CHANGED
|
@@ -35,6 +35,7 @@ const PAIR_INFO = Buffer.from(HKDF_INFO.pair);
|
|
|
35
35
|
const ROTATE_INFO = Buffer.from(HKDF_INFO.rotate);
|
|
36
36
|
const MODEL_AUTH_VAULT_INFO = Buffer.from(HKDF_INFO.modelAuthVault);
|
|
37
37
|
const GITHUB_APP_VAULT_INFO = Buffer.from(HKDF_INFO.githubAppVault);
|
|
38
|
+
const DEVICE_VAULT_INFO = Buffer.from(HKDF_INFO.deviceVault);
|
|
38
39
|
const EMPTY_SALT = Buffer.alloc(0);
|
|
39
40
|
/** Generate a fresh X25519 keypair (node identity or device ephemeral). */
|
|
40
41
|
export function generatePairingKeypair() {
|
|
@@ -73,7 +74,8 @@ export function deriveWrapKey(ourPrivateKeyB64, theirPublicKeyB64, purpose) {
|
|
|
73
74
|
const info = purpose === "pair" ? PAIR_INFO
|
|
74
75
|
: purpose === "rotate" ? ROTATE_INFO
|
|
75
76
|
: purpose === "github-app-vault" ? GITHUB_APP_VAULT_INFO
|
|
76
|
-
:
|
|
77
|
+
: purpose === "device-vault" ? DEVICE_VAULT_INFO
|
|
78
|
+
: MODEL_AUTH_VAULT_INFO;
|
|
77
79
|
return Buffer.from(hkdfSync("sha256", shared, EMPTY_SALT, info, WRAP_KEY_BYTES));
|
|
78
80
|
}
|
|
79
81
|
/** A fresh 32-byte symmetric room key. */
|
package/dist/server.js
CHANGED
|
@@ -40,6 +40,9 @@ import { collectNodeStats } from "./node-stats.js";
|
|
|
40
40
|
import { SessionEventCoalescer } from "./session-event-coalescer.js";
|
|
41
41
|
import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed } from "./auth.js";
|
|
42
42
|
import { RelayConnector, loadRelayConfig } from "./relay-client.js";
|
|
43
|
+
import { readEphemeralTeardownConfig, shouldSelfTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
|
|
44
|
+
import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
|
|
45
|
+
import { createCheckpointBundle, applyCheckpointBundle, materializeCheckpoint } from "./session/checkpoint-pack.js";
|
|
43
46
|
import { PolicyEngine } from "./policy/policy-engine.js";
|
|
44
47
|
import { TerminalManager } from "./terminal.js";
|
|
45
48
|
import { listMultiplexerSessions, attachCommand } from "./multiplexer.js";
|
|
@@ -6142,10 +6145,153 @@ async function deleteSessionFile(opts) {
|
|
|
6142
6145
|
scheduleAdvertise();
|
|
6143
6146
|
return { sessionId: deletedSessionId, sessionFile: inRoot ? resolved : undefined };
|
|
6144
6147
|
}
|
|
6145
|
-
const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessions(); pruneEmptySessions(); }, idleCloseSweepMs);
|
|
6148
|
+
const idleCloseTimer = setInterval(() => { closeIdleSessions(); pruneGhostSessions(); pruneEmptySessions(); evaluateEphemeralTeardown(); }, idleCloseSweepMs);
|
|
6146
6149
|
idleCloseTimer.unref?.();
|
|
6147
6150
|
const worktreeCleanupTimer = setInterval(() => void sweepDiskGuardrails(), worktreeCleanupSweepMs);
|
|
6148
6151
|
worktreeCleanupTimer.unref?.();
|
|
6152
|
+
// --- server-side ephemeral teardown ----------------------------------------
|
|
6153
|
+
// On a disposable machine (bootstrap set BIVY_EPHEMERAL=1) the daemon ends the
|
|
6154
|
+
// machine ITSELF once it goes idle, so teardown no longer needs the launching
|
|
6155
|
+
// device online. See src/ephemeral-teardown.ts + docs/ephemeral-sessions.md.
|
|
6156
|
+
const ephemeralTeardownCfg = readEphemeralTeardownConfig();
|
|
6157
|
+
let ephemeralEverBusy = false;
|
|
6158
|
+
let ephemeralLastBusyAt = Date.now();
|
|
6159
|
+
/** Best-effort "I've settled — reap me" signal to the control plane. Non-secret
|
|
6160
|
+
* (node id via the enrollment bearer); lets a hosted machine whose provider
|
|
6161
|
+
* can't self-reap on exit (Hetzner) be destroyed server-side. */
|
|
6162
|
+
async function signalSettledToControlPlane() {
|
|
6163
|
+
if (!sessionAdvertiseTarget)
|
|
6164
|
+
return;
|
|
6165
|
+
await fetch(`${sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "")}/node/settled`, {
|
|
6166
|
+
method: "POST",
|
|
6167
|
+
headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}`, "content-type": "application/json" },
|
|
6168
|
+
body: "{}",
|
|
6169
|
+
}).catch(() => { });
|
|
6170
|
+
}
|
|
6171
|
+
/** Rebuild-resume (Gap B): on a freshly re-provisioned machine booted with
|
|
6172
|
+
* `BIVY_RESTORE=<sessionId>`, fetch the session's control-plane snapshot,
|
|
6173
|
+
* decrypt it with this machine's room key (reused from the torn-down session so
|
|
6174
|
+
* the seal matches), and apply it — restoring the transcript (EventLog) and the
|
|
6175
|
+
* git checkpoint into a repo the session can open. The runtime process starts
|
|
6176
|
+
* fresh/seeded from the restored transcript ("reconstructed", not byte-identical
|
|
6177
|
+
* — see docs/ephemeral-sessions.md). Best-effort: a missing/undecryptable
|
|
6178
|
+
* snapshot leaves a clean fresh machine. Reuses the standby-replica machinery. */
|
|
6179
|
+
async function restoreSessionFromSnapshot(sessionId) {
|
|
6180
|
+
if (!sessionAdvertiseTarget)
|
|
6181
|
+
return;
|
|
6182
|
+
const cpBaseUrl = sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "");
|
|
6183
|
+
try {
|
|
6184
|
+
const res = await fetch(`${cpBaseUrl}/node/session-snapshot/${encodeURIComponent(sessionId)}`, {
|
|
6185
|
+
headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}` },
|
|
6186
|
+
});
|
|
6187
|
+
if (!res.ok) {
|
|
6188
|
+
console.error(`[restore] no snapshot for ${sessionId} (${res.status})`);
|
|
6189
|
+
return;
|
|
6190
|
+
}
|
|
6191
|
+
const data = (await res.json());
|
|
6192
|
+
if (!data.ciphertext)
|
|
6193
|
+
return;
|
|
6194
|
+
const applied = await applySessionSnapshot(data.ciphertext, pairingStore.roomKey(), {
|
|
6195
|
+
persistRecords: (id, records) => eventLog.rewrite(id, records),
|
|
6196
|
+
applyBundle: async (id, buf) => applyCheckpointBundle(await ensureReplicaRepo(id), id, buf),
|
|
6197
|
+
materialize: async (id) => materializeCheckpoint(await ensureReplicaRepo(id), id),
|
|
6198
|
+
});
|
|
6199
|
+
// Register the rebuilt session so it lists and opens (mirrors the standby's
|
|
6200
|
+
// upsertReplicaMeta); the transcript replays from the restored EventLog.
|
|
6201
|
+
try {
|
|
6202
|
+
metadata.upsertSession({ id: sessionId, source: "restored", status: "saved" });
|
|
6203
|
+
}
|
|
6204
|
+
catch {
|
|
6205
|
+
/* best-effort listing */
|
|
6206
|
+
}
|
|
6207
|
+
console.log(`[restore] session ${sessionId}: ${applied.recordCount} records, checkpoint ${applied.checkpointCommit ?? "none"}`);
|
|
6208
|
+
}
|
|
6209
|
+
catch (e) {
|
|
6210
|
+
console.error(`[restore] session ${sessionId} failed: ${e?.message || e}`);
|
|
6211
|
+
}
|
|
6212
|
+
}
|
|
6213
|
+
/** Flush a durable, E2E-encrypted snapshot of each open session to the control
|
|
6214
|
+
* plane before this disposable machine is torn down, so a destroy-lane session
|
|
6215
|
+
* can be rebuilt on a fresh machine later (Gap B). Sealed under the node room
|
|
6216
|
+
* key — the same key that seals the session title — so a restore machine that
|
|
6217
|
+
* reuses this session's room key can decrypt it; the control plane sees only
|
|
6218
|
+
* ciphertext. Best-effort per session; never blocks teardown for long. */
|
|
6219
|
+
async function flushSessionSnapshots() {
|
|
6220
|
+
if (!sessionAdvertiseTarget)
|
|
6221
|
+
return;
|
|
6222
|
+
const roomKey = pairingStore.roomKey();
|
|
6223
|
+
const cpBaseUrl = sessionAdvertiseTarget.controlPlaneUrl.replace(/\/$/, "");
|
|
6224
|
+
for (const record of new Set(openSessions.values())) {
|
|
6225
|
+
try {
|
|
6226
|
+
const sealed = await buildSessionSnapshot(record.id, roomKey, {
|
|
6227
|
+
readRecords: (id) => eventLog.entries(id),
|
|
6228
|
+
epochOf: () => 0,
|
|
6229
|
+
checkpointHead: async (id) => {
|
|
6230
|
+
try {
|
|
6231
|
+
return (await harness.checkpoints(id))[0]?.id;
|
|
6232
|
+
}
|
|
6233
|
+
catch {
|
|
6234
|
+
return undefined;
|
|
6235
|
+
}
|
|
6236
|
+
},
|
|
6237
|
+
bundleCheckpoint: async (id, since) => createCheckpointBundle(harnessDirFor(record), id, since),
|
|
6238
|
+
runtimeSessionRef: (id) => openSessions.get(id)?.sessionFile,
|
|
6239
|
+
worktreeSync: () => true,
|
|
6240
|
+
});
|
|
6241
|
+
if (!sealed)
|
|
6242
|
+
continue;
|
|
6243
|
+
await fetch(`${cpBaseUrl}/node/session-snapshot/${encodeURIComponent(record.id)}`, {
|
|
6244
|
+
method: "PUT",
|
|
6245
|
+
headers: { authorization: `Bearer ${sessionAdvertiseTarget.enrollmentToken}`, "content-type": "application/json" },
|
|
6246
|
+
body: JSON.stringify({ ciphertext: sealed }),
|
|
6247
|
+
}).catch(() => { });
|
|
6248
|
+
}
|
|
6249
|
+
catch {
|
|
6250
|
+
/* best effort per session — TTL/branch push remain the backstops */
|
|
6251
|
+
}
|
|
6252
|
+
}
|
|
6253
|
+
}
|
|
6254
|
+
let ephemeralTearingDown = false;
|
|
6255
|
+
/** Evaluate the quiet condition and self-terminate if the machine is done. Reads
|
|
6256
|
+
* live session/queue state each call, so it's safe to invoke from the idle
|
|
6257
|
+
* sweep and from agent_end. No-op on a persistent node (env absent). */
|
|
6258
|
+
function evaluateEphemeralTeardown() {
|
|
6259
|
+
if (!ephemeralTeardownCfg.enabled || ephemeralTearingDown)
|
|
6260
|
+
return;
|
|
6261
|
+
const records = new Set(openSessions.values());
|
|
6262
|
+
const anyWorking = [...records].some((r) => r.isWorking);
|
|
6263
|
+
const anyRemoteActive = [...records].some((r) => r.remoteActive);
|
|
6264
|
+
const inFlightWork = controlPlanePoller?.inFlightCount() ?? 0;
|
|
6265
|
+
if (anyWorking || anyRemoteActive || inFlightWork > 0) {
|
|
6266
|
+
ephemeralEverBusy = true;
|
|
6267
|
+
ephemeralLastBusyAt = Date.now();
|
|
6268
|
+
return;
|
|
6269
|
+
}
|
|
6270
|
+
const idleForMs = Date.now() - ephemeralLastBusyAt;
|
|
6271
|
+
if (!shouldSelfTeardown(ephemeralTeardownCfg, { everBusy: ephemeralEverBusy, anyWorking, anyRemoteActive, inFlightWork, idleForMs }))
|
|
6272
|
+
return;
|
|
6273
|
+
ephemeralTearingDown = true;
|
|
6274
|
+
void (async () => {
|
|
6275
|
+
// Persist a rebuild snapshot BEFORE the machine goes away (Gap B), then reap.
|
|
6276
|
+
await flushSessionSnapshots();
|
|
6277
|
+
await performSelfTeardown({
|
|
6278
|
+
provider: ephemeralTeardownCfg.provider,
|
|
6279
|
+
signalSettled: signalSettledToControlPlane,
|
|
6280
|
+
shutdown: () => { try {
|
|
6281
|
+
spawnSync("shutdown", ["-h", "now"], { stdio: "ignore" });
|
|
6282
|
+
}
|
|
6283
|
+
catch { /* TTL backstops */ } },
|
|
6284
|
+
});
|
|
6285
|
+
})();
|
|
6286
|
+
}
|
|
6287
|
+
if (ephemeralTeardownCfg.enabled) {
|
|
6288
|
+
// Sample often enough to honour the finish grace (~10s) without waiting for the
|
|
6289
|
+
// 1–5min idle sweep. Ephemeral-only, so no cost on a persistent node.
|
|
6290
|
+
const ephemeralEvalMs = Math.max(2_000, Math.min(ephemeralTeardownCfg.finishGraceMs, 15_000));
|
|
6291
|
+
const ephemeralTeardownTimer = setInterval(() => evaluateEphemeralTeardown(), ephemeralEvalMs);
|
|
6292
|
+
ephemeralTeardownTimer.unref?.();
|
|
6293
|
+
console.log(`[ephemeral-teardown] armed: provider=${ephemeralTeardownCfg.provider} onFinish=${ephemeralTeardownCfg.onFinish} ttl=${ephemeralTeardownCfg.ttlMin}m`);
|
|
6294
|
+
}
|
|
6149
6295
|
setTimeout(() => void sweepDiskGuardrails(), 30_000).unref?.();
|
|
6150
6296
|
// One sweep shortly after boot clears ghosts left by a previous run before any
|
|
6151
6297
|
// client paints its sidebar; the idle timer keeps it clean thereafter.
|
|
@@ -6382,6 +6528,9 @@ function attachSessionListeners(record) {
|
|
|
6382
6528
|
// itself (gh/API/web) so the badge lights up.
|
|
6383
6529
|
void maybePushWorktreeBranch(record)
|
|
6384
6530
|
.then(() => maybeDetectPullRequest(record));
|
|
6531
|
+
// On a disposable machine, a finished turn with nobody watching is the cue
|
|
6532
|
+
// to consider self-teardown promptly (the idle sweep is the backstop).
|
|
6533
|
+
evaluateEphemeralTeardown();
|
|
6385
6534
|
}
|
|
6386
6535
|
const sessionEventPayload = { type: "session.event", sessionId: record.id, event };
|
|
6387
6536
|
if (event.type === "message_update") {
|
|
@@ -9432,6 +9581,10 @@ const server = app.listen(port, host, async () => {
|
|
|
9432
9581
|
console.log(`Agent data dir: ${piDir}`);
|
|
9433
9582
|
console.log(`Workspace: ${defaultWorkspace}`);
|
|
9434
9583
|
startRelayIfConfigured();
|
|
9584
|
+
// Rebuild-resume (Gap B): if this machine was re-provisioned to restore a
|
|
9585
|
+
// torn-down session, pull + apply its snapshot before serving. Non-blocking.
|
|
9586
|
+
if (process.env.BIVY_RESTORE)
|
|
9587
|
+
void restoreSessionFromSnapshot(String(process.env.BIVY_RESTORE));
|
|
9435
9588
|
startModelAuthWatcher();
|
|
9436
9589
|
startGithubAppSyncWatcher();
|
|
9437
9590
|
await startGitHubTasksIfConfigured();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
//
|
|
4
|
+
// Node-independent, encrypted SESSION SNAPSHOT (Gap B in docs/ephemeral-sessions.md).
|
|
5
|
+
//
|
|
6
|
+
// Warm replication (replicator.ts) ships a session's state node→node so a live
|
|
7
|
+
// standby can take over — but that copy evaporates if every node goes away, which
|
|
8
|
+
// is exactly the destroy-lane ephemeral case (Fly Machines/Hetzner/EC2 that are
|
|
9
|
+
// torn down when the agent finishes). This reuses the SAME replication frame
|
|
10
|
+
// (full transcript + git checkpoint bundle + runtimeSessionRef) but seals it under
|
|
11
|
+
// the session room key and stores it as an opaque control-plane BLOB, so a
|
|
12
|
+
// torn-down session can be rebuilt onto a fresh machine later. The control plane
|
|
13
|
+
// only ever sees ciphertext — same posture as the E2E session title.
|
|
14
|
+
//
|
|
15
|
+
// This module is the pure build/apply core (injected deps, no daemon/relay), so
|
|
16
|
+
// it's unit-testable like replicator.ts; the daemon wires the real EventLog /
|
|
17
|
+
// checkpoint deps, the room key from relay.json, and the control-plane transport.
|
|
18
|
+
import { OwnerReplicator, StandbyApplier } from "./replicator.js";
|
|
19
|
+
import { seal, open } from "../e2e.js";
|
|
20
|
+
/**
|
|
21
|
+
* Build a full, sealed snapshot of a session: a complete replication frame (ALL
|
|
22
|
+
* transcript records + a full git checkpoint bundle + the runtime resume token),
|
|
23
|
+
* serialized and encrypted under the session room key. Returns null when there's
|
|
24
|
+
* nothing to snapshot yet (no records and no checkpoint).
|
|
25
|
+
*
|
|
26
|
+
* A fresh `OwnerReplicator` has no cursor, so `buildTurnFrame` produces a FULL
|
|
27
|
+
* frame + full bundle — exactly what a from-scratch rebuild on a new machine
|
|
28
|
+
* needs (it holds no base to delta against).
|
|
29
|
+
*/
|
|
30
|
+
export async function buildSessionSnapshot(sessionId, roomKey, deps) {
|
|
31
|
+
const owner = new OwnerReplicator({ ...deps, worktreeSync: () => true });
|
|
32
|
+
const frame = await owner.buildTurnFrame(sessionId);
|
|
33
|
+
// Nothing worth persisting yet: no transcript and no checkpoint (a fresh owner
|
|
34
|
+
// has no cursor, so buildReplFrame emits a zero-record frame rather than null).
|
|
35
|
+
if (!frame || (frame.records.length === 0 && !frame.checkpointCommit))
|
|
36
|
+
return null;
|
|
37
|
+
return seal(roomKey, JSON.stringify(frame));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Decrypt and apply a sealed snapshot onto a fresh machine: rewrite the session's
|
|
41
|
+
* EventLog transcript, land the git checkpoint into the (replica) repo and
|
|
42
|
+
* materialize the working tree, and return the runtimeSessionRef so the caller
|
|
43
|
+
* can re-derive a resumable runtime session (writeHistory / seeded fallback).
|
|
44
|
+
* Throws on a bad key / corrupt blob, or if the frame can't apply cleanly.
|
|
45
|
+
*/
|
|
46
|
+
export async function applySessionSnapshot(sealed, roomKey, deps) {
|
|
47
|
+
const frame = JSON.parse(open(roomKey, sealed));
|
|
48
|
+
const applier = new StandbyApplier(deps);
|
|
49
|
+
const ack = await applier.receive(frame);
|
|
50
|
+
// We always ship a FULL frame + full bundle, so a fresh applier applies it
|
|
51
|
+
// outright ("applied"); "resync" is a benign already-current signal. A
|
|
52
|
+
// "needFull"/"stale" here means a corrupt/foreign blob.
|
|
53
|
+
if (ack.status !== "applied" && ack.status !== "resync") {
|
|
54
|
+
throw new Error(`snapshot apply failed: ${ack.status}`);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
runtimeSessionRef: frame.runtimeSessionRef,
|
|
58
|
+
recordCount: frame.records.length,
|
|
59
|
+
checkpointCommit: frame.checkpointCommit,
|
|
60
|
+
};
|
|
61
|
+
}
|
package/dist/wire-format.js
CHANGED
|
@@ -29,6 +29,10 @@ export const HKDF_INFO = {
|
|
|
29
29
|
modelAuthVault: "bivy-model-auth-vault-v1",
|
|
30
30
|
/** GitHub App private-key vault delivery (node-only, opt-in — issue #88). */
|
|
31
31
|
githubAppVault: "bivy-github-app-vault-v1",
|
|
32
|
+
/** Device→device ephemeral-provider-token vault delivery (opt-in). Unlike the
|
|
33
|
+
* node vaults above, the recipients are the account's paired DEVICES, so a
|
|
34
|
+
* second device can wake/reach a machine the first launched. */
|
|
35
|
+
deviceVault: "bivy-device-vault-v1",
|
|
32
36
|
};
|
|
33
37
|
/** Version byte stamped into every sealed frame's authenticated plaintext. */
|
|
34
38
|
export const FRAME_VERSION = 1;
|
package/package.json
CHANGED