Skip to content
639 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next.1/**
2 * An agent's own computer (docs.g1t.sh/guides/agents/, "Its computer"):
3 * one Durable Object per workspace agent, named `computer:<agent_id>`, in
4 * front of one container whose process is the runner in `MODE=computer`
5 * (crates/runner computer.rs).
6 *
7 * It is a persistent home, not an always-on machine. Awake, the container
8 * runs and its seconds are metered as agent sandbox time, attributed to
9 * the agent and to whoever asked for the session that woke it. Ten minutes
10 * after the last command with nothing running, the home (`/home/agent`) is
11 * saved to object storage as one tar+zstd (`HOMES`, R2 on g1t's cloud,
12 * streamed up in parts, never buffered whole) and the container stops.
13 * Waking restores the home into a fresh container. Reset wipes the home;
14 * an archived agent's home is kept `COMPUTER_KEEP_DAYS` and then deleted
15 * by an alarm.
16 *
17 * Without `HOMES` (the bucket not made yet on an installation) everything
18 * works, but the home is forgotten at every sleep, and the status says so
19 * (`disk_attached: false`).
20 *
21 * Every wake, sleep and reset is written to the workspace's audit log, as
22 * the agents service writes its own entries.
23 */
24import { Container, type StopParams } from "@cloudflare/containers";
25
26import {
27 type AgentComputerCommand,
28 type AgentComputerState,
29 type AgentComputerStatus,
30 type ComputerExecArgs,
31 type ComputerExecResult,
32 type ComputerWakeArgs,
33 type Result,
34 COMPUTER_DISK_CAP_BYTES,
35 COMPUTER_IDLE_MINUTES,
36 COMPUTER_KEEP_DAYS,
37 COMPUTER_TRANSCRIPT_BYTES,
38 COMPUTER_TRANSCRIPTS_KEPT,
39 ComputeGate,
40 actualMicros,
41 billingClient,
42 fail,
43 ok,
44 sandboxEstimateMicros,
45} from "@g1t/contracts";
46
47import { concat, homeKey, ndjson, transcriptOf } from "./computer-shape";
48import { describeError } from "./lifecycle";
49import type { RunnerEnv } from "./index";
50
51export { gather, homeKey, outcomeLine } from "./computer-shape";
52
53/** The port the container's supervisor listens on (crates/runner computer.rs `PORT`). */
54export const COMPUTER_PORT = 8787;
55/** How long a wake is reserved for with billing before it starts: the first stretch awake. */
56const RESERVE_MINUTES = 30;
57/** Snapshots over the cap are refused; this many refusals in a row and the computer stops unsaved. */
58const MAX_FAILED_SNAPSHOTS = 6;
59/** Each part of a snapshot's multipart upload (R2 asks for at least 5 MiB, the last part excepted). */
60const PART_BYTES = 8 * 1024 * 1024;
61/** How long one command may be waited on end to end, past its own timeout. */
62const EXEC_GRACE_MS = 15_000;
63
64/** What billing reserved for a stretch awake, settled when it sleeps. */
65type Held = { id: string; workspace: string; microsPerSecond: number };
66
67/** Everything the object keeps about its computer. */
68type Kept = {
69 agentId: string;
70 workspace: string | null;
71 agentHandle: string | null;
72 askedBy: string | null;
73 state: AgentComputerState;
74 since: string;
75 lastWokeAt: string | null;
76 lastSleptAt: string | null;
77 snapshotBytes: number | null;
78 snapshotAt: string | null;
79 diskUsedBytes: number;
80 problem: string | null;
81 deleteAfter: string | null;
82 /** When the current stretch awake started, for the meter; null when not awake. */
83 meterStarted: number | null;
84 reservation: Held | null;
85 token: string | null;
86 failedSnapshots: number;
87};
88
89const iso = () => new Date().toISOString();
90
91/** One gate per isolate, as the sandboxes keep. */
92let gate: ComputeGate | null = null;
93
94/** The computer's status as reported, from what is kept. */
95export function statusOf(kept: Kept, attached: boolean): AgentComputerStatus {
96 return {
97 agent_id: kept.agentId,
98 state: kept.state,
99 since: kept.since,
100 disk_used_bytes: kept.diskUsedBytes,
101 disk_cap_bytes: COMPUTER_DISK_CAP_BYTES,
102 last_woke_at: kept.lastWokeAt,
103 last_slept_at: kept.lastSleptAt,
104 snapshot_bytes: kept.snapshotBytes,
105 snapshot_at: kept.snapshotAt,
106 where: "g1t_cloud",
107 disk_attached: attached,
108 problem: kept.problem,
109 delete_after: kept.deleteAfter,
110 };
111}
112
113export class AgentComputer extends Container<RunnerEnv> {
114 defaultPort = COMPUTER_PORT;
115 // Idle: this long after the last request to the container (every command
116 // is one), `onActivityExpired` saves the home and stops it.
117 sleepAfter = `${COMPUTER_IDLE_MINUTES}m`;
118
119 /** Commands running now: a busy computer is not put to sleep for idleness. */
120 private running = 0;
121 /** A wake in progress, so two commands arriving together start one container. */
122 private waking: Promise<Result<AgentComputerStatus>> | null = null;
123
124 private get attached(): boolean {
125 return Boolean(this.env.HOMES);
126 }
127
128 private async kept(agentId: string): Promise<Kept> {
129 const found = await this.ctx.storage.get<Kept>("computer");
130 if (found) return found;
131 const fresh: Kept = {
132 agentId,
133 workspace: null,
134 agentHandle: null,
135 askedBy: null,
136 state: "asleep",
137 since: iso(),
138 lastWokeAt: null,
139 lastSleptAt: null,
140 snapshotBytes: null,
141 snapshotAt: null,
142 diskUsedBytes: 0,
143 problem: null,
144 deleteAfter: null,
145 meterStarted: null,
146 reservation: null,
147 token: null,
148 failedSnapshots: 0,
149 };
150 await this.ctx.storage.put("computer", fresh);
151 return fresh;
152 }
153
154 private async save(kept: Kept): Promise<void> {
155 await this.ctx.storage.put("computer", kept);
156 }
157
158 /** A request to the container's supervisor, with its token. */
159 private async call(kept: Kept, path: string, init: RequestInit = {}): Promise<Response> {
160 const headers = new Headers(init.headers ?? {});
161 if (kept.token) headers.set("authorization", `Bearer ${kept.token}`);
162 return this.containerFetch(`http://computer${path}`, { ...init, headers }, COMPUTER_PORT);
163 }
164
165 // ── Status ──────────────────────────────────────────────────────────────
166
167 async status(agentId: string): Promise<AgentComputerStatus> {
168 const kept = await this.kept(agentId);
169 if (kept.state === "awake") {
170 const health = await this.call(kept, "/health").then((r) => (r.ok ? r.json<{ disk_used_bytes?: number }>() : null)).catch(() => null);
171 if (health && typeof health.disk_used_bytes === "number") {
172 kept.diskUsedBytes = health.disk_used_bytes;
173 await this.save(kept);
174 }
175 }
176 return statusOf(kept, this.attached);
177 }
178
179 async commands(agentId: string, sessionId: string | null = null): Promise<AgentComputerCommand[]> {
180 await this.kept(agentId);
181 const all = (await this.ctx.storage.get<AgentComputerCommand[]>("commands")) ?? [];
182 return sessionId ? all.filter((command) => command.session_id === sessionId) : all;
183 }
184
185 // ── Waking ──────────────────────────────────────────────────────────────
186
187 async wake(args: ComputerWakeArgs): Promise<Result<AgentComputerStatus>> {
188 if (this.waking) return this.waking;
189 this.waking = this.doWake(args).finally(() => {
190 this.waking = null;
191 });
192 return this.waking;
193 }
194
195 private async doWake(args: ComputerWakeArgs): Promise<Result<AgentComputerStatus>> {
196 const kept = await this.kept(args.agent_id);
197 if (kept.state === "awake") return ok(statusOf(kept, this.attached));
198 if (kept.state === "sleeping") return fail("conflict", "The computer is going to sleep; try again in a moment.");
199 const workspace = args.workspace.toLowerCase();
200 // Its workspace's plan says whether it may run, and what is reserved.
201 gate ??= new ComputeGate(this.env.BILLING);
202 const [microsPerSecond, ent] = await Promise.all([gate.microsPerSecond(), gate.entitlements(workspace)]);
203 const admission = await gate.admit(
204 {
205 workspace,
206 repo: { namespace: workspace, name: `agents/${args.agent_handle}` },
207 public: false,
208 kind: "agent",
209 estimateMicros: sandboxEstimateMicros(RESERVE_MINUTES, microsPerSecond),
210 // Its model is metered by the agents service; this is machine time only.
211 hostedModel: false,
212 },
213 ent,
214 );
215 if (!admission.ok) return fail("payment_required", admission.message);
216 kept.workspace = workspace;
217 kept.agentHandle = args.agent_handle;
218 kept.askedBy = args.asked_by ?? null;
219 kept.reservation = admission.reservation ? { id: admission.reservation.id, workspace, microsPerSecond } : null;
220 kept.token = crypto.randomUUID();
221 kept.state = "waking";
222 kept.since = iso();
223 kept.problem = null;
224 await this.save(kept);
225 try {
226 await this.startAndWaitForPorts(COMPUTER_PORT, undefined, {
227 envVars: { MODE: "computer", G1T_COMPUTER_TOKEN: kept.token, ...(this.env.ABUSE_WATCH === "off" ? { G1T_ABUSE: "off" } : {}) },
228 enableInternet: true,
229 });
230 } catch (error) {
231 console.error("computer not started", args.agent_id, describeError(error));
232 await this.release(kept);
233 kept.state = "asleep";
234 kept.since = iso();
235 kept.problem = `The computer could not start: ${describeError(error)}`;
236 await this.save(kept);
237 return fail("unavailable", "The computer could not start just now. Try again in a moment.");
238 }
239 // The meter runs from the moment the container is up, restore included.
240 kept.meterStarted = Date.now();
241 await this.save(kept);
242 await this.restore(kept);
243 kept.state = "awake";
244 kept.since = iso();
245 kept.lastWokeAt = kept.since;
246 await this.save(kept);
247 this.audit(kept, "computer_wake", `Woke @${kept.agentHandle}'s computer${kept.snapshotAt ? ", restoring its home" : " with a fresh home"}.`);
248 return ok(await this.status(args.agent_id));
249 }
250
251 /** Puts the saved home back into a container that just started. */
252 private async restore(kept: Kept): Promise<void> {
253 if (!this.env.HOMES || !kept.snapshotAt) return;
254 try {
255 const object = await this.env.HOMES.get(homeKey(kept.agentId));
256 if (!object) {
257 kept.snapshotAt = null;
258 kept.snapshotBytes = null;
259 return;
260 }
261 const response = await this.call(kept, "/restore", { method: "POST", body: object.body, headers: { "content-type": "application/zstd" } });
262 if (!response.ok) throw new Error(`restore answered ${response.status}: ${(await response.text().catch(() => "")).slice(0, 200)}`);
263 const answer = await response.json<{ disk_used_bytes?: number }>().catch((): { disk_used_bytes?: number } => ({}));
264 if (typeof answer.disk_used_bytes === "number") kept.diskUsedBytes = answer.disk_used_bytes;
265 } catch (error) {
266 console.error("computer home not restored", kept.agentId, describeError(error));
267 kept.problem = "Its saved home could not be restored this time, so it woke with a fresh one. The saved home is kept for the next wake.";
268 }
269 }
270
271 /** A wake that admitted and then failed gives back what was reserved. */
272 private async release(kept: Kept): Promise<void> {
273 if (!kept.reservation) return;
274 gate ??= new ComputeGate(this.env.BILLING);
275 await gate.settle(kept.reservation.id, 0);
276 kept.reservation = null;
277 }
278
279 /** Awake, or why not. */
280 private async awake(args: ComputerWakeArgs): Promise<Result<Kept>> {
281 const kept = await this.kept(args.agent_id);
282 if (kept.state !== "awake") {
283 const woke = await this.wake(args);
284 if (!woke.ok) return woke;
285 }
286 return ok(await this.kept(args.agent_id));
287 }
288
289 // ── Commands and files ─────────────────────────────────────────────────
290
291 async exec(args: ComputerExecArgs): Promise<Result<ComputerExecResult>> {
292 const cmd = String(args.cmd ?? "").trim();
293 if (!cmd) return fail("invalid", "Give the command to run.");
294 const ready = await this.awake(args);
295 if (!ready.ok) return ready;
296 const kept = ready.value;
297 const timeout = Math.max(1, Math.min(1800, Math.floor(Number(args.timeout_seconds ?? 120)) || 120));
298 const startedAt = iso();
299 this.running++;
300 const lines: { stream: string; line: string }[] = [];
301 let closing: { exit_code?: number; duration_ms?: number; truncated?: boolean; timed_out?: boolean; note?: string } | null = null;
302 let problem: string | null = null;
303 try {
304 const controller = new AbortController();
305 const timer = setTimeout(() => controller.abort(), timeout * 1000 + EXEC_GRACE_MS);
306 try {
307 const response = await this.call(kept, "/exec", {
308 method: "POST",
309 headers: { "content-type": "application/json" },
310 body: JSON.stringify({ cmd, cwd: args.cwd ?? null, timeout_seconds: timeout }),
311 signal: controller.signal,
312 });
313 if (!response.ok || !response.body) {
314 const text = await response.text().catch(() => "");
315 let message = text;
316 try {
317 message = (JSON.parse(text) as { message?: string }).message ?? text;
318 } catch {
319 // Plain text, kept as it is.
320 }
321 return fail("invalid", message || `The computer answered ${response.status}.`);
322 }
323 for await (const line of ndjson(response.body)) {
324 if (typeof line.exit_code === "number") closing = line as unknown as typeof closing;
325 else if (typeof line.line === "string") lines.push({ stream: String(line.stream ?? "stdout"), line: line.line });
326 }
327 } finally {
328 clearTimeout(timer);
329 }
330 } catch (error) {
331 problem = describeError(error);
332 console.error("computer exec failed", kept.agentId, problem);
333 } finally {
334 this.running--;
335 }
336 const shaped = transcriptOf(lines, closing, problem, COMPUTER_TRANSCRIPT_BYTES);
337 const command: AgentComputerCommand = {
338 id: `cmd_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`,
339 session_id: args.session_id ?? null,
340 asked_by: args.asked_by ?? kept.askedBy,
341 started_at: startedAt,
342 cmd,
343 cwd: args.cwd?.trim() || "/home/agent",
344 exit_code: shaped.exit_code,
345 duration_ms: shaped.duration_ms ?? Math.max(0, Date.now() - Date.parse(startedAt)),
346 output: shaped.output,
347 truncated: shaped.truncated,
348 timed_out: shaped.timed_out,
349 };
350 const all = (await this.ctx.storage.get<AgentComputerCommand[]>("commands")) ?? [];
351 await this.ctx.storage.put("commands", [command, ...all].slice(0, COMPUTER_TRANSCRIPTS_KEPT));
352 return ok({ command, status: await this.status(kept.agentId) });
353 }
354
355 async readFile(args: ComputerWakeArgs & { path: string }): Promise<Result<{ path: string; text: string; bytes: number }>> {
356 const path = String(args.path ?? "").trim();
357 if (!path) return fail("invalid", "Give the file's path under the home.");
358 const ready = await this.awake(args);
359 if (!ready.ok) return ready;
360 const response = await this.call(ready.value, `/files?path=${encodeURIComponent(path)}`).catch((error: unknown) => {
361 console.error("computer read failed", args.agent_id, describeError(error));
362 return null;
363 });
364 if (!response) return fail("unavailable", "The computer didn't answer.");
365 if (response.status === 404) return fail("not_found", `There is no file at ${path}.`);
366 if (!response.ok) return fail("invalid", await said(response));
367 const bytes = new Uint8Array(await response.arrayBuffer());
368 return ok({ path, text: new TextDecoder().decode(bytes), bytes: bytes.byteLength });
369 }
370
371 async writeFile(args: ComputerWakeArgs & { path: string; text: string }): Promise<Result<{ path: string; bytes: number }>> {
372 const path = String(args.path ?? "").trim();
373 if (!path) return fail("invalid", "Give the file's path under the home.");
374 const ready = await this.awake(args);
375 if (!ready.ok) return ready;
376 const body = new TextEncoder().encode(String(args.text ?? ""));
377 const response = await this.call(ready.value, `/files?path=${encodeURIComponent(path)}`, { method: "PUT", body, headers: { "content-type": "application/octet-stream" } }).catch((error: unknown) => {
378 console.error("computer write failed", args.agent_id, describeError(error));
379 return null;
380 });
381 if (!response) return fail("unavailable", "The computer didn't answer.");
382 if (!response.ok) return fail("invalid", await said(response));
383 return ok({ path, bytes: body.byteLength });
384 }
385
386 // ── Sleeping ────────────────────────────────────────────────────────────
387
388 async sleep(agentId: string): Promise<Result<AgentComputerStatus>> {
389 const kept = await this.kept(agentId);
390 if (kept.state === "asleep") return ok(statusOf(kept, this.attached));
391 if (kept.state !== "awake") return fail("conflict", `The computer is ${kept.state}; try again in a moment.`);
392 if (this.running > 0) return fail("conflict", "A command is still running. It sleeps once the command ends and it has been idle for ten minutes.");
393 kept.state = "sleeping";
394 kept.since = iso();
395 await this.save(kept);
396 const saved = await this.snapshot(kept);
397 if (!saved.ok) {
398 kept.failedSnapshots++;
399 kept.problem = saved.error.message;
400 if (kept.failedSnapshots < MAX_FAILED_SNAPSHOTS) {
401 kept.state = "awake";
402 kept.since = iso();
403 await this.save(kept);
404 // Another try at the next idle expiry.
405 this.renewActivityTimeout();
406 return fail("conflict", saved.error.message);
407 }
408 kept.problem = `${saved.error.message} It was stopped without saving after ${MAX_FAILED_SNAPSHOTS} tries, so changes since its last save are gone.`;
409 } else {
410 kept.failedSnapshots = 0;
411 kept.problem = null;
412 }
413 await this.save(kept);
414 await this.call(kept, "/stop", { method: "POST" }).catch(() => null);
415 await this.stop().catch(() => undefined);
416 await this.stopped(0, "asked");
417 this.audit(kept, "computer_sleep", `Put @${kept.agentHandle}'s computer to sleep${saved.ok ? ", home saved" : " without saving its home"}.`);
418 return ok(statusOf((await this.kept(agentId))!, this.attached));
419 }
420
421 /** Streams the home out of the container and up to object storage, in parts. */
422 private async snapshot(kept: Kept): Promise<Result<null>> {
423 const bucket = this.env.HOMES;
424 if (!bucket) {
425 // Nothing to save to: the home is forgotten, and the status says so.
426 kept.snapshotAt = null;
427 kept.snapshotBytes = null;
428 return ok(null);
429 }
430 let response: Response;
431 try {
432 response = await this.call(kept, "/snapshot", { method: "POST" });
433 } catch (error) {
434 return fail("unavailable", `The home could not be read for saving: ${describeError(error)}`);
435 }
436 if (response.status === 413) return fail("invalid", await said(response));
437 if (!response.ok || !response.body) return fail("unavailable", `The home could not be saved: the computer answered ${response.status}.`);
438 const upload = await bucket.createMultipartUpload(homeKey(kept.agentId));
439 const parts: R2UploadedPart[] = [];
440 let total = 0;
441 try {
442 let held: Uint8Array[] = [];
443 let size = 0;
444 const flush = async () => {
445 const part = concat(held, size);
446 held = [];
447 size = 0;
448 parts.push(await upload.uploadPart(parts.length + 1, part));
449 };
450 const reader = response.body.getReader();
451 for (;;) {
452 const { done, value } = await reader.read();
453 if (done) break;
454 held.push(value);
455 size += value.byteLength;
456 total += value.byteLength;
457 if (size >= PART_BYTES) await flush();
458 }
459 if (size > 0 || parts.length === 0) await flush();
460 await upload.complete(parts);
461 } catch (error) {
462 await upload.abort().catch(() => undefined);
463 return fail("unavailable", `The home could not be saved: ${describeError(error)}`);
464 }
465 kept.snapshotBytes = total;
466 kept.snapshotAt = iso();
467 const used = Number(response.headers.get("x-g1t-disk-used"));
468 if (Number.isFinite(used) && used > 0) kept.diskUsedBytes = used;
469 return ok(null);
470 }
471
472 /**
473 * Everything the end of a stretch awake does, once: the meter, the
474 * reservation, the state. From `sleep`, and from `onStop` when the
475 * container went by itself.
476 */
477 private async stopped(exitCode: number, how: "asked" | "exit"): Promise<void> {
478 const kept = await this.ctx.storage.get<Kept>("computer");
479 if (!kept) return;
480 if (kept.meterStarted != null) {
481 const seconds = Math.max(1, Math.ceil((Date.now() - kept.meterStarted) / 1000));
482 const recorded = await billingClient(this.env.BILLING)
483 .recordSandbox({
484 workspace: kept.workspace ?? "",
485 seconds,
486 description: `@${kept.agentHandle ?? "agent"}'s computer, awake`,
487 repo: null,
488 reference: `computer/${kept.agentId}/${kept.meterStarted}`,
489 kind: "agent",
490 selfHosted: false,
491 instance: null,
492 agent: kept.agentHandle,
493 askedBy: kept.askedBy,
494 })
495 .catch((error: unknown) => ({ ok: false as const, error: { message: String(error) } }));
496 if (!recorded.ok) console.log("computer time not recorded", kept.workspace, seconds, recorded.error.message);
497 if (kept.reservation) {
498 gate ??= new ComputeGate(this.env.BILLING);
499 await gate.settle(kept.reservation.id, actualMicros(seconds, kept.reservation.microsPerSecond, 0));
500 }
501 kept.meterStarted = null;
502 kept.reservation = null;
503 }
504 if (how === "exit" && (kept.state === "awake" || kept.state === "waking")) {
505 kept.problem = `The computer stopped by itself (exit ${exitCode}); changes since its last save are gone.`;
506 }
507 if (kept.state !== "asleep") {
508 kept.state = "asleep";
509 kept.since = iso();
510 kept.lastSleptAt = kept.since;
511 }
512 kept.token = null;
513 await this.save(kept);
514 }
515
516 /**
517 * Idle for `sleepAfter`: save and stop, unless a command is running, in
518 * which case look again later. Never `super`, which would stop the
519 * container without saving.
520 */
521 override async onActivityExpired(): Promise<void> {
522 const kept = await this.ctx.storage.get<Kept>("computer");
523 if (!kept || kept.state !== "awake") return;
524 if (this.running > 0) {
525 this.renewActivityTimeout();
526 return;
527 }
528 const slept = await this.sleep(kept.agentId);
529 if (!slept.ok) console.log("computer not put to sleep", kept.agentId, slept.error.message);
530 }
531
532 override async onStop({ exitCode, reason }: StopParams): Promise<void> {
533 const kept = await this.ctx.storage.get<Kept>("computer");
534 if (kept?.state === "asleep" && kept.meterStarted == null) return;
535 console.log("computer stopped", kept?.agentId, "exit", exitCode, reason);
536 await this.stopped(exitCode, "exit");
537 }
538
539 override onError(error: unknown): void {
540 console.error("computer container error", this.ctx.id.toString(), describeError(error));
541 }
542
543 override async alarm(alarmProps?: AlarmInvocationInfo): Promise<void> {
544 try {
545 await super.alarm(alarmProps);
546 } catch (error) {
547 console.error("computer alarm failed", this.ctx.id.toString(), `retry ${alarmProps?.retryCount ?? 0}`, describeError(error));
548 throw error;
549 }
550 }
551
552 // ── Reset and forgetting ───────────────────────────────────────────────
553
554 async reset(agentId: string): Promise<Result<AgentComputerStatus>> {
555 const kept = await this.kept(agentId);
556 if (kept.state !== "asleep") {
557 await this.destroy().catch((error: unknown) => console.log("computer not destroyed for reset", agentId, describeError(error)));
558 await this.stopped(0, "asked");
559 }
560 if (this.env.HOMES) await this.env.HOMES.delete(homeKey(agentId)).catch((error: unknown) => console.log("computer home not deleted", agentId, describeError(error)));
561 await this.ctx.storage.delete("commands");
562 const fresh = (await this.kept(agentId))!;
563 fresh.snapshotAt = null;
564 fresh.snapshotBytes = null;
565 fresh.diskUsedBytes = 0;
566 fresh.problem = null;
567 fresh.failedSnapshots = 0;
568 fresh.state = "asleep";
569 fresh.since = iso();
570 await this.save(fresh);
571 this.audit(fresh, "computer_reset", `Reset @${fresh.agentHandle ?? "agent"}'s computer: its home was wiped.`);
572 return ok(statusOf(fresh, this.attached));
573 }
574
575 /** The agent was archived: its home is kept `COMPUTER_KEEP_DAYS`, then deleted. */
576 async forget(agentId: string): Promise<Result<AgentComputerStatus>> {
577 const kept = await this.kept(agentId);
578 if (kept.state === "awake") await this.sleep(agentId);
579 const fresh = (await this.kept(agentId))!;
580 fresh.deleteAfter = new Date(Date.now() + COMPUTER_KEEP_DAYS * 24 * 60 * 60 * 1000).toISOString();
581 await this.save(fresh);
582 await this.schedule(COMPUTER_KEEP_DAYS * 24 * 60 * 60, "purge");
583 return ok(statusOf(fresh, this.attached));
584 }
585
586 /** The alarm after `forget`: the disk and everything kept here go. */
587 async purge(): Promise<void> {
588 const kept = await this.ctx.storage.get<Kept>("computer");
589 if (!kept?.deleteAfter || Date.parse(kept.deleteAfter) > Date.now()) return;
590 if (kept.state !== "asleep") await this.destroy().catch(() => undefined);
591 if (this.env.HOMES) await this.env.HOMES.delete(homeKey(kept.agentId)).catch(() => undefined);
592 await this.ctx.storage.deleteAll();
593 }
594
595 /** An entry in the workspace's audit log, as the agents service writes them. Never fails the call. */
596 private audit(kept: Kept, action: string, message: string): void {
597 if (!this.env.EVENTS || !kept.workspace) return;
598 const entry = {
599 actorKind: "agent",
600 actor: kept.agentHandle ?? kept.agentId,
601 actorId: kept.agentId,
602 agent: kept.agentHandle,
603 onBehalfOf: kept.askedBy,
604 runId: null,
605 runKind: null,
606 credentialId: null,
607 action,
608 surface: "web",
609 workspace: kept.workspace,
610 repo: null,
611 number: null,
612 gitRef: null,
613 path: `agents/${kept.agentHandle ?? kept.agentId}/computer`,
614 outcome: "allowed",
615 rule: "owner",
616 result: "ok",
617 message,
618 requestId: `req_${crypto.randomUUID()}`,
619 };
620 this.ctx.waitUntil(
621 this.env.EVENTS.fetch("https://service/rpc/audit_record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ entries: [entry] }) })
622 .then((response) => {
623 if (!response.ok) throw new Error(`status ${response.status}`);
624 })
625 .catch((error: unknown) => console.error("computer audit entry not recorded", action, kept.agentId, String(error))),
626 );
627 }
628}
629
630/** A JSON error body's `message`, or the status. */
631async function said(response: Response): Promise<string> {
632 const text = await response.text().catch(() => "");
633 try {
634 return (JSON.parse(text) as { message?: string }).message ?? `The computer answered ${response.status}.`;
635 } catch {
636 return text || `The computer answered ${response.status}.`;
637 }
638}
639