Skip to content

Commit 808e03c

Browse files
kapaleshreyasclaude
andcommitted
feat(sandboxes): Wedge 1.13 — heartbeat + pluggable state snapshot/restore (S3 first)
Three additions on top of Wedge 1.12's warm sandbox pool: 1) Auto-warm: POST /sandboxes/:id/heartbeat refreshes idleExpiresAt without an LLM round-trip. Client (browser) fires it on keystroke + tab focus so the substrate doesn't die while the user is composing. ~5ms; returns the new clock. 2) Snapshot: POST /sandboxes/:id/snapshot captures the workdir (gzipped tar, built via tar-stream from agent.listWorkdir + fetchArtifact) plus metadata (config, turnCount, usage, sessionStoreRef if any) to a pluggable StateStore. 409 if state=busy. 3) Restore: POST /sandboxes/restore with target="new" creates a fresh sandbox from a snapshot (workdir overlaid via the existing attachments wire; conversation memory replays if a sessionStore was wired). target="<existingId>" disposes the substrate of an existing slot and swaps in a new ComputerAgent with the same sandboxId — a new "restoring" state guards against concurrent /chat during this surgery. Plus: GET /sandboxes/snapshots, DELETE /sandboxes/snapshots/:id. And `autoSave: { stateStore: {...} }` on POST /sandboxes runs a snapshot in the registry's preDispose hook before agent.dispose() fires (TTL, DELETE, or shutdown) — survives flaky backends, never blocks tear-down. Architecture: - New protocol/src/state-store.ts: StateStore interface, SandboxSnapshot, SnapshotFilter, SessionStoreRef, StateStoreConfig — mirrors TaskStore shape from Wedge 1.11. - New @computeragent/state-store-s3 package: S3StateStore (PutObject meta.json + workdir.tar.gz under <prefix>/<id>/), s3StateStoreBuilder. AWS SDK credential chain (env / instance role) — no plaintext creds in the wire. - MemoryStateStore in the example as the always-registered fallback. - ComputerAgentServer gains stateStores registry + 5 new routes + takeSnapshot/createRestoredSandbox/replaceAgentInPlace helpers. - examples/package.json: tar-stream (snapshot/restore packing). - test.html: keystroke heartbeat pinger, snapshot/restore buttons, stateStore + autoSave toggles. - scripts/test-sandboxes.py: +5 tests (heartbeat refresh, heartbeat 404, snapshot round-trip with memory store, snapshot 409 while busy, autoSave on dispose). No SDK or runtime changes — listWorkdir/fetchArtifact + attachments were already the right primitives. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9ac0f30 commit 808e03c

14 files changed

Lines changed: 1793 additions & 23 deletions

File tree

examples/computeragent-server.ts

Lines changed: 620 additions & 22 deletions
Large diffs are not rendered by default.

examples/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@
2727
"@computeragent/sdk": "workspace:*",
2828
"@computeragent/session-store-mongo": "workspace:*",
2929
"@computeragent/task-store-mongo": "workspace:*",
30+
"@computeragent/state-store-s3": "workspace:*",
3031
"hono": "^4.6.0",
31-
"@hono/node-server": "^1.13.0"
32+
"@hono/node-server": "^1.13.0",
33+
"tar-stream": "^3.1.7"
34+
},
35+
"devDependencies": {
36+
"@types/tar-stream": "^3.1.3"
3237
}
3338
}

packages/protocol/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ export type {
1717
TaskArtifactRef,
1818
PersistedEvent,
1919
} from "./task-store.js";
20+
export { StateStoreConfig } from "./state-store.js";
21+
export type {
22+
StateStore,
23+
SandboxSnapshot,
24+
SnapshotSummary,
25+
SnapshotFilter,
26+
SandboxUsage,
27+
SessionStoreRef,
28+
} from "./state-store.js";
2029
export type { Logger, LogLevel, CreateLoggerOptions } from "./logger.js";
2130
export { createLogger, nopLogger } from "./logger.js";
2231
export type {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { z } from "zod";
2+
3+
/**
4+
* Sandbox state snapshot + restore.
5+
*
6+
* A `SandboxSnapshot` is a point-in-time capture of a warm sandbox's
7+
* external state — the workdir (tarball) + metadata + an optional reference
8+
* to a SessionStore where conversation memory lives. Engine subprocess state
9+
* isn't snapshotted byte-for-byte; restoration relies on conversation log
10+
* replay (via SessionStore) + workdir overlay (via attachments) to bring a
11+
* fresh ComputerAgent back to "where it left off".
12+
*
13+
* The shape deliberately mirrors `TaskStore` (Wedge 1.11): the interface
14+
* lives here; backends implement it; a `kind` wire descriptor lets clients
15+
* pick a backend per-request.
16+
*/
17+
18+
// ── Wire descriptor (the only thing crossing HTTP) ────────────────────────
19+
20+
/**
21+
* Lightweight descriptor a client POSTs to choose where snapshots go.
22+
* `kind` matches a registered builder in the harness server's `stateStores`
23+
* registry. `options` is opaque — the builder shape decides what it accepts.
24+
*/
25+
export const StateStoreConfig = z.object({
26+
kind: z.string().min(1),
27+
options: z.unknown().optional(),
28+
});
29+
export type StateStoreConfig = z.infer<typeof StateStoreConfig>;
30+
31+
// ── Per-snapshot shape ────────────────────────────────────────────────────
32+
33+
/**
34+
* Aggregated token + cost rollup, accumulated from every `ca_usage_snapshot`
35+
* during the sandbox's lifetime. Mirrors `TaskUsage` from `task-store.ts` so
36+
* dashboards can reuse the same renderer. Duplicated rather than re-imported
37+
* to keep the state-store module self-contained.
38+
*/
39+
export interface SandboxUsage {
40+
readonly inputTokens?: number;
41+
readonly outputTokens?: number;
42+
readonly cacheCreationInputTokens?: number;
43+
readonly cacheReadInputTokens?: number;
44+
readonly costUsd?: number;
45+
}
46+
47+
/**
48+
* Optional pointer to where the conversation log lives. If set, restore
49+
* pins the new sandbox's `sessionStore` to the same kind+options+sessionId
50+
* so the engine replays prior turns on its first chat. If absent, restored
51+
* sandbox starts with a fresh conversation (workdir is still recovered).
52+
*
53+
* `options` may contain credentials (mongo URL with password). The CALLER
54+
* is responsible for redacting before save if they don't want them stored
55+
* — the protocol doesn't second-guess. Bucket-level encryption / scoped
56+
* IAM is the right defense.
57+
*/
58+
export interface SessionStoreRef {
59+
readonly kind: string;
60+
readonly options?: unknown;
61+
readonly sessionId: string;
62+
}
63+
64+
/**
65+
* One complete snapshot. Backends serialize this however they like —
66+
* S3 splits it into `meta.json` + `workdir.tar.gz`; memory keeps it intact;
67+
* a future GCS backend could do the same as S3.
68+
*/
69+
export interface SandboxSnapshot {
70+
readonly snapshotId: string;
71+
readonly sourceSandboxId: string;
72+
readonly sourceSessionId: string;
73+
readonly takenAt: Date;
74+
/** Redacted POST /sandboxes body — used by restore to recreate the agent. */
75+
readonly config: Record<string, unknown>;
76+
readonly turnCount: number;
77+
readonly usage: SandboxUsage;
78+
readonly sessionStoreRef?: SessionStoreRef;
79+
/**
80+
* gzipped tar of the workdir. Stored alongside the metadata; the wire
81+
* format is the backend's choice. The protocol passes through Buffer
82+
* — streams are a follow-up wedge if snapshots routinely exceed memory.
83+
*/
84+
readonly workdirTar: Buffer;
85+
/** Tarball byte size (compressed). */
86+
readonly workdirBytes: number;
87+
/** Number of files captured (uncompressed entry count). For UX in `list`. */
88+
readonly workdirFileCount: number;
89+
}
90+
91+
/** Snapshot minus the workdir bytes — for cheap listings. */
92+
export type SnapshotSummary = Omit<SandboxSnapshot, "workdirTar">;
93+
94+
/**
95+
* Filter for `listSnapshots`. All fields optional; backends apply them as
96+
* additive predicates. Implementations that can't push filters into the
97+
* underlying store may post-filter (S3 falls back to a prefix scan).
98+
*/
99+
export interface SnapshotFilter {
100+
readonly sourceSandboxId?: string;
101+
readonly since?: Date;
102+
readonly limit?: number;
103+
}
104+
105+
// ── The interface backends implement ──────────────────────────────────────
106+
107+
/**
108+
* Abstract state storage adapter. Implementations live in their own
109+
* packages (`@computeragent/state-store-s3`, future redis/gcs/azure
110+
* backends). The harness server only depends on this interface.
111+
*/
112+
export interface StateStore {
113+
/**
114+
* Persist a snapshot. Returns the canonical id (in case the backend
115+
* normalizes it) + the on-the-wire size. MUST be atomic from the
116+
* client's perspective — partial uploads should not surface in `list()`.
117+
*/
118+
save(snap: SandboxSnapshot): Promise<{ snapshotId: string; sizeBytes: number }>;
119+
120+
/** Full snapshot + tarball. Returns `null` if the id doesn't exist. */
121+
load(snapshotId: string): Promise<SandboxSnapshot | null>;
122+
123+
/** Snapshot summaries matching the filter, most-recent first. */
124+
list(filter?: SnapshotFilter): Promise<readonly SnapshotSummary[]>;
125+
126+
/** Hard delete. Idempotent — deleting a missing id MUST NOT throw. */
127+
delete(snapshotId: string): Promise<void>;
128+
}

packages/runtime-e2b/assets/harness-bundle.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132330,6 +132330,12 @@ var TaskStoreConfig = exports_external.object({
132330132330
kind: exports_external.string().min(1),
132331132331
options: exports_external.unknown().optional()
132332132332
});
132333+
// ../protocol/dist/state-store.js
132334+
init_zod();
132335+
var StateStoreConfig = exports_external.object({
132336+
kind: exports_external.string().min(1),
132337+
options: exports_external.unknown().optional()
132338+
});
132333132339
// ../protocol/dist/logger.js
132334132340
var LEVEL_ORDER = {
132335132341
debug: 10,

packages/runtime-local/assets/harness-bundle.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173449,6 +173449,12 @@ var TaskStoreConfig = exports_external.object({
173449173449
kind: exports_external.string().min(1),
173450173450
options: exports_external.unknown().optional()
173451173451
});
173452+
// ../protocol/dist/state-store.js
173453+
init_zod();
173454+
var StateStoreConfig = exports_external.object({
173455+
kind: exports_external.string().min(1),
173456+
options: exports_external.unknown().optional()
173457+
});
173452173458
// ../protocol/dist/logger.js
173453173459
var LEVEL_ORDER = {
173454173460
debug: 10,

packages/runtime-vzvm/assets/harness-bundle.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132330,6 +132330,12 @@ var TaskStoreConfig = exports_external.object({
132330132330
kind: exports_external.string().min(1),
132331132331
options: exports_external.unknown().optional()
132332132332
});
132333+
// ../protocol/dist/state-store.js
132334+
init_zod();
132335+
var StateStoreConfig = exports_external.object({
132336+
kind: exports_external.string().min(1),
132337+
options: exports_external.unknown().optional()
132338+
});
132333132339
// ../protocol/dist/logger.js
132334132340
var LEVEL_ORDER = {
132335132341
debug: 10,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@computeragent/state-store-s3",
3+
"version": "0.1.0",
4+
"description": "S3-backed StateStore for the ComputerAgent harness — persists workdir tarball + metadata per sandbox snapshot so clients can save and restore live sandbox state across the network.",
5+
"license": "MIT",
6+
"type": "module",
7+
"main": "./dist/index.js",
8+
"types": "./dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.js"
13+
}
14+
},
15+
"files": [
16+
"dist",
17+
"src"
18+
],
19+
"scripts": {
20+
"build": "tsc -p tsconfig.json",
21+
"typecheck": "tsc -p tsconfig.json --noEmit",
22+
"test": "vitest run --passWithNoTests",
23+
"clean": "rm -rf dist .turbo *.tsbuildinfo"
24+
},
25+
"dependencies": {
26+
"@computeragent/protocol": "workspace:*",
27+
"@aws-sdk/client-s3": "^3.700.0"
28+
},
29+
"devDependencies": {
30+
"typescript": "^5.5.0",
31+
"vitest": "^2.0.0"
32+
},
33+
"keywords": [
34+
"ai-agents",
35+
"state-store",
36+
"s3",
37+
"snapshot",
38+
"computeragent"
39+
],
40+
"homepage": "https://github.com/open-gitagent/ComputerAgent",
41+
"repository": {
42+
"type": "git",
43+
"url": "https://github.com/open-gitagent/ComputerAgent.git",
44+
"directory": "packages/state-store-s3"
45+
}
46+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import type { StateStore } from "@computeragent/protocol";
2+
import { S3StateStore, type S3StateStoreOptions } from "./s3-store.js";
3+
4+
/**
5+
* One-call builder for the harness server's `stateStores` registry. Mirrors
6+
* `mongoTaskStoreBuilder` / `mongoSessionStoreBuilder` — defaults supplied
7+
* at registration time get merged with per-call options from the wire.
8+
*
9+
* new ComputerAgentServer({
10+
* ...,
11+
* stateStores: { s3: s3StateStoreBuilder({ bucket: process.env.S3_BUCKET! }) },
12+
* });
13+
*
14+
* Per-request options merge ON TOP of the defaults — useful when different
15+
* snapshots want different prefixes under the same bucket:
16+
*
17+
* client body: stateStore: { kind: "s3", options: { prefix: "tenant-a/" } }
18+
*/
19+
export function s3StateStoreBuilder(
20+
defaults: S3StateStoreOptions,
21+
): (options?: unknown) => StateStore {
22+
if (!defaults.bucket) throw new Error("s3StateStoreBuilder: defaults.bucket is required");
23+
return (options) => {
24+
const overrides = (options ?? {}) as Partial<S3StateStoreOptions>;
25+
return new S3StateStore({ ...defaults, ...overrides });
26+
};
27+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* `@computeragent/state-store-s3` — S3 backend for the harness's
3+
* `StateStore` interface. Persists sandbox snapshots (workdir tarball +
4+
* metadata) so clients can save and restore live sandbox state.
5+
*
6+
* import { s3StateStoreBuilder } from "@computeragent/state-store-s3";
7+
*
8+
* new ComputerAgentServer({
9+
* ...,
10+
* stateStores: { s3: s3StateStoreBuilder({ bucket: process.env.S3_BUCKET! }) },
11+
* });
12+
*
13+
* Credentials follow the standard AWS SDK chain — prefer EC2 instance
14+
* role or env vars over plaintext in `S3StateStoreOptions`.
15+
*/
16+
export { S3StateStore } from "./s3-store.js";
17+
export type { S3StateStoreOptions } from "./s3-store.js";
18+
export { s3StateStoreBuilder } from "./builder.js";

0 commit comments

Comments
 (0)