Skip to content

Commit d89b5f6

Browse files
committed
feat(cloud): R2 sleep persistence for cloud sessions (Phase 2b)
Persist the container's Helmor data dir to R2 so chat/session history survives a sandbox sleep (CF wipes the disk on sleep). Per finished agent turn the serve process folds the SQLite WAL into helmor.db (synchronous wal_checkpoint(TRUNCATE), env-gated on HELMOR_CLOUD_AUTOPUSH); after the turn stream closes the Worker snapshots /home/helmor to R2 in localBucket mode (excluding the git worktrees, which are restored via autopush) and stores the DirectoryBackup handle in D1 teams.backup_handle. On cold start ensureServe restores from that handle before launching serve. Relocate the data dir to /home/helmor (an allowed backup root) via HELMOR_DATA_DIR in both start-serve.sh and boot.sh so serve and boot share one DB. Zero pipeline-snapshot drift; all team UI/proxy paths unchanged.
1 parent 462928b commit d89b5f6

8 files changed

Lines changed: 272 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"helmor": patch
3+
---
4+
5+
Team cloud: chat sessions now survive a sandbox going to sleep — after each turn the cloud backend snapshots its database to durable storage and restores it on the next wake.

cloud/scripts/boot.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ HELMOR_HOME="${HELMOR_HOME:-/opt/helmor}"
2222
export PATH="${HELMOR_HOME}/vendor/gh:${HELMOR_HOME}:${PATH}"
2323
HELMOR_CLI="${HELMOR_HOME}/helmor-cli"
2424

25+
# Phase 2b: write to the SAME data dir as `helmor serve` (under /home for backup
26+
# eligibility). Without this, helmor-cli would default to $HOME/helmor while serve
27+
# uses /home/helmor — two divergent DBs. The Worker may override via env.
28+
export HELMOR_DATA_DIR="${HELMOR_DATA_DIR:-/home/helmor}"
29+
2530
: "${HELMOR_REPO_URL:?HELMOR_REPO_URL must be set (the git repo to clone)}"
2631
REPO_BRANCH="${HELMOR_REPO_BRANCH:-}"
2732
WORK_ROOT="${HELMOR_WORK_ROOT:-/workspace}"

cloud/scripts/start-serve.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export DISPLAY=":${DISPLAY_NUM}"
2222
export PATH="${HELMOR_HOME}/vendor/gh:${HELMOR_HOME}:${PATH}"
2323
export HELMOR_SIDECAR_PATH="${HELMOR_SIDECAR_PATH:-${HELMOR_HOME}/helmor-sidecar}"
2424

25+
# Phase 2b: the data dir must live under an allowed backup root (/home, /workspace,
26+
# /tmp, /var/tmp, /app) so the Sandbox backup API can snapshot it. Default to
27+
# /home/helmor; the Worker may override via startProcess env. `helmor serve` AND
28+
# boot.sh both honor this default so they share ONE database.
29+
export HELMOR_DATA_DIR="${HELMOR_DATA_DIR:-/home/helmor}"
30+
2531
# Cloud workflow closure (PR6): commit + push each finished agent turn so the
2632
# ephemeral sandbox disk never loses code. On by default in the container.
2733
export HELMOR_CLOUD_AUTOPUSH="${HELMOR_CLOUD_AUTOPUSH:-1}"

cloud/src/index.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
// `Authorization` header through. Blueprint: cloudflare/claude-managed-agents.
99

1010
import { getSandbox, proxyToSandbox, type Sandbox } from "@cloudflare/sandbox";
11-
import { handleTeamRoute, lookupMemberId } from "./team";
11+
import {
12+
handleTeamRoute,
13+
lookupMemberId,
14+
readBackupHandle,
15+
writeBackupHandle,
16+
} from "./team";
1217

1318
export { Sandbox } from "@cloudflare/sandbox";
1419

@@ -29,15 +34,27 @@ export interface Env {
2934
* subscription. Phase-0 passes the whole credential through; Phase-1 will have
3035
* the control-plane broker mint a per-turn short-lived token instead. */
3136
CODEX_AUTH_JSON?: string;
37+
/** R2 bucket for Sandbox backups (Phase 2b). Bound so the Sandbox DO can
38+
* resolve BACKUP_BUCKET for localBucket-mode createBackup/restoreBackup. */
39+
BACKUP_BUCKET?: R2Bucket;
3240
}
3341

3442
/** Boot script staged in the image (Xvfb daemon → readiness → `helmor serve`). */
3543
const SERVE_START_CMD = "/usr/local/bin/helmor-start-serve";
3644

3745
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
3846

47+
/** Agent-stream RPC path. After this stream closes, the in-container WAL
48+
* checkpoint (gated by HELMOR_CLOUD_AUTOPUSH) has already folded helmor.db, so
49+
* a backup taken now snapshots a consistent DB. */
50+
const AGENT_STREAM_PATH = "/rpc-stream/send_agent_message_stream";
51+
3952
export default {
40-
async fetch(request: Request, env: Env): Promise<Response> {
53+
async fetch(
54+
request: Request,
55+
env: Env,
56+
ctx: ExecutionContext,
57+
): Promise<Response> {
4158
// Security (EVERY path): strip any client-supplied X-Helmor-Member-Id up
4259
// front — before proxyToSandbox — so the "never client-asserted" invariant
4360
// holds on the SDK preview path too, not only the derived proxy hop. The
@@ -89,10 +106,69 @@ export default {
89106
// Transparent HTTP proxy: method, path, body, and streaming responses
90107
// pass straight through; headers carry the derived member id + shared
91108
// companion token (see `deriveForwardedRequest`).
92-
return sandbox.containerFetch(forwarded, port);
109+
const response = await sandbox.containerFetch(forwarded, port);
110+
111+
// Phase 2b sleep persistence: ONLY for the agent-stream path, snapshot
112+
// /home/helmor to R2 after the turn so the session survives sandbox
113+
// sleep. We don't block the client — `waitUntil` keeps the Worker alive
114+
// past the response while the backup runs. The backup must start AFTER
115+
// the stream drains (the in-container WAL checkpoint runs just before the
116+
// terminal `done`), so we tee the body and await the clone's completion
117+
// before snapshotting. All other paths return byte-unchanged.
118+
if (url.pathname === AGENT_STREAM_PATH && response.body) {
119+
const [toClient, toDrain] = response.body.tee();
120+
ctx.waitUntil(backupAfterStream(toDrain, sandbox, env));
121+
return new Response(toClient, response);
122+
}
123+
124+
return response;
93125
},
94126
};
95127

128+
/**
129+
* Wait for the agent-stream body to fully drain (so the in-container WAL
130+
* checkpoint + autopush that fire just before the terminal `done` have run),
131+
* then snapshot the data dir to R2 and persist the handle. Best-effort: a
132+
* backup failure must never surface to the client (the response already left).
133+
*/
134+
async function backupAfterStream(
135+
body: ReadableStream<Uint8Array>,
136+
sandbox: Sandbox,
137+
env: Env,
138+
): Promise<void> {
139+
try {
140+
// Drain to EOF — resolves when the companion closes the stream, i.e.
141+
// after the turn finalized (checkpoint folded helmor.db).
142+
await body.pipeTo(new WritableStream());
143+
} catch {
144+
// A client disconnect can abort the tee; still attempt the backup with
145+
// whatever is on disk — a slightly-stale snapshot beats none.
146+
}
147+
await backupAndStore(sandbox, env);
148+
}
149+
150+
/**
151+
* Snapshot `/home/helmor` (the relocated data dir) to R2 in localBucket mode
152+
* and persist the returned handle in D1. Excludes the bulky, regenerable trees
153+
* (workspaces are pushed to git by autopush; cache/logs/run/local-llm are
154+
* disposable) so the backup is just the SQLite DB + small settings. All errors
155+
* are logged and swallowed — cold-starting with an empty DB is acceptable.
156+
*/
157+
async function backupAndStore(sandbox: Sandbox, env: Env): Promise<void> {
158+
try {
159+
const handle = await sandbox.createBackup({
160+
dir: "/home/helmor",
161+
localBucket: true,
162+
name: `helmor-${new Date().toISOString()}`,
163+
ttl: 259200, // 3 days
164+
excludes: ["workspaces", "cache", "logs", "run", "local-llm"],
165+
});
166+
await writeBackupHandle(env, handle);
167+
} catch (error) {
168+
console.error("Phase 2b backup failed", error);
169+
}
170+
}
171+
96172
/**
97173
* Build the request forwarded to the companion, deriving member identity from
98174
* the client's bearer (the invite token doubles as the member's capability
@@ -166,10 +242,25 @@ async function ensureServe(
166242
): Promise<void> {
167243
if (await healthOk(sandbox, port)) return;
168244

245+
// Phase 2b sleep persistence: restore the last DB snapshot BEFORE serve
246+
// binds. Restore must precede serve (serve not yet running = no open handle
247+
// on helmor.db, safe to overwrite). A missing/failed restore is non-fatal —
248+
// the container cold-starts with an empty DB, exactly like a brand-new team.
249+
try {
250+
const handle = await readBackupHandle(env);
251+
if (handle) await sandbox.restoreBackup(handle);
252+
} catch (error) {
253+
console.error("Phase 2b restore failed (cold-starting empty)", error);
254+
}
255+
169256
await sandbox.startProcess(SERVE_START_CMD, {
170257
env: {
171258
HELMOR_COMPANION_TOKEN: env.HELMOR_COMPANION_TOKEN,
172259
HELMOR_SERVE_PORT: String(port),
260+
// Phase 2b: relocate the data dir under /home so it lands in the
261+
// backed-up /home/helmor tree (createBackup dir must be under
262+
// /workspace|/home|/tmp|/var/tmp|/app).
263+
HELMOR_DATA_DIR: "/home/helmor",
173264
...(env.GITHUB_TOKEN ? { GITHUB_TOKEN: env.GITHUB_TOKEN } : {}),
174265
...(env.CODEX_AUTH_JSON ? { CODEX_AUTH_JSON: env.CODEX_AUTH_JSON } : {}),
175266
},

cloud/src/team.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// - `/team/accept` is OPEN — the token itself is the credential.
1111
// All queries are parameterized (`prepare().bind()`) — never interpolated.
1212

13+
import type { DirectoryBackup } from "@cloudflare/sandbox";
1314
import type { Env } from "./index";
1415

1516
const TEAM_ID = "team-0";
@@ -47,6 +48,47 @@ export async function lookupMemberId(
4748
return row?.member_id ?? null;
4849
}
4950

51+
/**
52+
* Read the stored Sandbox backup handle for this Worker's sandbox (Phase 2b).
53+
* Returns null when no backup has been taken yet (cold start → empty DB), or
54+
* when the stored value can't be parsed (treated as absent — re-snapshot next
55+
* turn). Keyed by `HELMOR_SANDBOX_ID` so it tracks the routing key, not TEAM_ID.
56+
*/
57+
export async function readBackupHandle(
58+
env: Env,
59+
): Promise<DirectoryBackup | null> {
60+
const row = await env.DB.prepare(
61+
"SELECT backup_handle FROM teams WHERE sandbox_id = ?1",
62+
)
63+
.bind(env.HELMOR_SANDBOX_ID)
64+
.first<{ backup_handle: string | null }>();
65+
if (!row?.backup_handle) return null;
66+
try {
67+
return JSON.parse(row.backup_handle) as DirectoryBackup;
68+
} catch {
69+
return null;
70+
}
71+
}
72+
73+
/**
74+
* Persist the latest Sandbox backup handle (Phase 2b). UPSERTs the single team
75+
* row keyed by `id` (the PK), recording the handle JSON so the next cold start's
76+
* `readBackupHandle` can restore it. Mirrors `bootstrap`'s upsert shape.
77+
*/
78+
export async function writeBackupHandle(
79+
env: Env,
80+
handle: DirectoryBackup,
81+
): Promise<void> {
82+
await env.DB.prepare(
83+
`INSERT INTO teams (id, sandbox_id, backup_handle) VALUES (?1, ?2, ?3)
84+
ON CONFLICT(id) DO UPDATE SET
85+
sandbox_id = excluded.sandbox_id,
86+
backup_handle = excluded.backup_handle`,
87+
)
88+
.bind(TEAM_ID, env.HELMOR_SANDBOX_ID, JSON.stringify(handle))
89+
.run();
90+
}
91+
5092
function json(body: unknown, status = 200): Response {
5193
return new Response(JSON.stringify(body), {
5294
status,

cloud/wrangler.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ binding = "DB"
4141
database_name = "helmor-team"
4242
database_id = "8eb39746-fda1-47e3-8006-2dab22175b53"
4343

44+
# R2 bucket for Sandbox backups (Phase 2b sleep persistence). The Sandbox DO
45+
# resolves BACKUP_BUCKET as a native R2 binding for localBucket-mode backup/
46+
# restore (R2 binding + squashfs, no presigned creds / FUSE).
47+
[[r2_buckets]]
48+
binding = "BACKUP_BUCKET"
49+
bucket_name = "helmor-team-backups"
50+
4451
[vars]
4552
# Fixed sandbox id for Phase 0 (B2 single-backend model). Phase 1 derives it
4653
# from the team / token.

src-tauri/src/agents/streaming/cloud_autopush.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,44 @@ pub(super) fn maybe_autopush_after_turn(working_directory: &str) {
5151
.ok();
5252
}
5353

54+
/// Fold the SQLite WAL back into `helmor.db` after a finalized turn so the
55+
/// next Sandbox backup snapshots a self-contained DB file.
56+
///
57+
/// Runs SYNCHRONOUSLY (unlike `maybe_autopush_after_turn`, which detaches): the
58+
/// caller invokes this on the turn-finalize seam, before the terminal `done`
59+
/// event is emitted, so the checkpoint completes before the Worker's
60+
/// post-stream backup snapshots `helmor.db`. A `TRUNCATE` checkpoint also keeps
61+
/// the `-wal` sidecar from being captured separately by the squashfs backup.
62+
///
63+
/// No-op (returns immediately) on the desktop — the env gate is unset there, so
64+
/// this never opens a connection and changes no existing behavior.
65+
pub(super) fn maybe_checkpoint_db_after_turn() {
66+
if !enabled() {
67+
return;
68+
}
69+
if let Err(error) = checkpoint_db() {
70+
tracing::warn!(
71+
error = %format!("{error:#}"),
72+
"cloud WAL checkpoint failed"
73+
);
74+
}
75+
}
76+
77+
fn checkpoint_db() -> anyhow::Result<()> {
78+
let path = crate::data_dir::db_path()?;
79+
// Short-lived connection: open, checkpoint, drop. This seam runs after
80+
// `persist_result_and_finalize` has COMMITTED (the writer pool slot is still
81+
// held but carries no open transaction), so TRUNCATE folds every WAL frame
82+
// into helmor.db. SQLite's TRUNCATE checkpoint never errors or blocks even
83+
// with other pool connections open — at worst the zero-truncation step is
84+
// skipped when a concurrent reader pins an active snapshot, in which case the
85+
// -wal/-shm sidecars (also captured by the backup) keep restore consistent.
86+
let conn = rusqlite::Connection::open(&path)?;
87+
conn.pragma_update(None, "wal_checkpoint", "TRUNCATE")?;
88+
tracing::debug!(db = %path.display(), "cloud WAL checkpoint (TRUNCATE) complete");
89+
Ok(())
90+
}
91+
5492
fn autopush(workspace_dir: &Path) -> anyhow::Result<()> {
5593
// Nothing changed → nothing to do (the common case mid-conversation when a
5694
// turn only read files / answered a question).
@@ -88,3 +126,72 @@ fn autopush(workspace_dir: &Path) -> anyhow::Result<()> {
88126
tracing::info!(dir = %dir, "cloud auto-push: committed + pushed turn changes");
89127
Ok(())
90128
}
129+
130+
#[cfg(test)]
131+
mod tests {
132+
use super::*;
133+
use crate::data_dir::TEST_ENV_LOCK;
134+
use std::env;
135+
136+
/// Open `helmor.db` in WAL mode and write a row so a non-empty `-wal`
137+
/// sidecar exists; returns the data-dir path. Mirrors the streaming DB's
138+
/// journal mode so the checkpoint test exercises a real WAL fold.
139+
fn seed_wal_db(data_dir: &std::path::Path) {
140+
env::set_var("HELMOR_DATA_DIR", data_dir);
141+
let path = crate::data_dir::db_path().unwrap();
142+
let conn = rusqlite::Connection::open(&path).unwrap();
143+
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
144+
conn.execute_batch("CREATE TABLE t (v INTEGER); INSERT INTO t VALUES (1);")
145+
.unwrap();
146+
// Leave the connection open so the write lands in `-wal` (a TRUNCATE
147+
// checkpoint with no other readers will still fold + truncate it).
148+
drop(conn);
149+
}
150+
151+
#[test]
152+
fn checkpoint_is_noop_when_env_gate_off() {
153+
let dir = tempfile::tempdir().unwrap();
154+
let _guard = TEST_ENV_LOCK.lock().unwrap();
155+
env::remove_var(AUTOPUSH_ENV);
156+
seed_wal_db(dir.path());
157+
158+
// Gate off → returns immediately, never opens a connection. The point
159+
// is that this is a hard no-op on the desktop; assert it doesn't panic
160+
// and the DB file is otherwise untouched.
161+
maybe_checkpoint_db_after_turn();
162+
163+
let wal = dir.path().join("helmor.db-wal");
164+
// The seeding connection was dropped, so SQLite may have auto-folded the
165+
// WAL already; the only invariant we assert here is that our function
166+
// performed no work — proven by it returning without error/panic.
167+
let _ = wal;
168+
env::remove_var("HELMOR_DATA_DIR");
169+
}
170+
171+
#[test]
172+
fn checkpoint_runs_when_env_gate_on() {
173+
let dir = tempfile::tempdir().unwrap();
174+
let _guard = TEST_ENV_LOCK.lock().unwrap();
175+
env::set_var(AUTOPUSH_ENV, "1");
176+
seed_wal_db(dir.path());
177+
178+
// Gate on → opens helmor.db and runs PRAGMA wal_checkpoint(TRUNCATE).
179+
// Must complete without error and leave the DB readable.
180+
maybe_checkpoint_db_after_turn();
181+
182+
let path = crate::data_dir::db_path().unwrap();
183+
let conn = rusqlite::Connection::open(&path).unwrap();
184+
let v: i64 = conn.query_row("SELECT v FROM t", [], |r| r.get(0)).unwrap();
185+
assert_eq!(v, 1, "data survives the checkpoint");
186+
// TRUNCATE checkpoint zeroes the WAL sidecar (or removes it). Either way
187+
// it must not be a large pending log.
188+
let wal = path.with_extension("db-wal");
189+
if wal.exists() {
190+
let len = std::fs::metadata(&wal).unwrap().len();
191+
assert_eq!(len, 0, "WAL truncated to zero after checkpoint");
192+
}
193+
194+
env::remove_var(AUTOPUSH_ENV);
195+
env::remove_var("HELMOR_DATA_DIR");
196+
}
197+
}

src-tauri/src/agents/streaming/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,12 @@ pub(super) fn stream_via_sidecar(
764764
cloud_autopush::maybe_autopush_after_turn(
765765
&turn_session.ctx.working_directory,
766766
);
767+
// Cloud serve mode only (env-gated):
768+
// fold the WAL into helmor.db NOW
769+
// (synchronously, before `done`) so the
770+
// Worker's post-stream backup snapshots
771+
// a consistent, self-contained DB.
772+
cloud_autopush::maybe_checkpoint_db_after_turn();
767773
}
768774
Err(error) => {
769775
tracing::error!(rid = %rid, "Failed to finalize exchange: {error}");

0 commit comments

Comments
 (0)