88// `Authorization` header through. Blueprint: cloudflare/claude-managed-agents.
99
1010import { 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
1318export { 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`). */
3543const SERVE_START_CMD = "/usr/local/bin/helmor-start-serve" ;
3644
3745const 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+
3952export 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 } ,
0 commit comments