Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
dc5d678
feat(computer): add durable lifecycle state reader
duc15052006-dotcom Sep 19, 2026
ef7c787
feat(computer): audit sleep and wake lifecycle events
duc15052006-dotcom Sep 19, 2026
26edc49
feat(computer): expose explicit fleet lifecycle states
duc15052006-dotcom Sep 19, 2026
64c9812
feat(computer): persist automatic sleep provenance
duc15052006-dotcom Sep 19, 2026
2bbd286
feat(computer): wire lifecycle audit into idle culler
duc15052006-dotcom Sep 19, 2026
986c766
feat(computer): derive running idle sleep and wake states
duc15052006-dotcom Sep 19, 2026
7188147
feat(computer): wire durable lifecycle reader into fleet gateway
duc15052006-dotcom Sep 19, 2026
f7ba4cd
feat(computer): type explicit fleet lifecycle states
duc15052006-dotcom Sep 19, 2026
d3ff0cb
feat(computer): show Running Idle Sleep and Wake in manager
duc15052006-dotcom Sep 19, 2026
4b508b1
test(computer): prove idle culler persists Sleep provenance
duc15052006-dotcom Sep 19, 2026
0cb7b96
test(computer): wire lifecycle audit into all culler cases
duc15052006-dotcom Sep 19, 2026
aa1bc41
test(computer): pin Running Idle Sleeping Stopped classification
duc15052006-dotcom Sep 19, 2026
d9fd38b
test(computer): distinguish Wake from manual Start
duc15052006-dotcom Sep 19, 2026
6255445
test(release): pin Computer lifecycle provenance and Wake UX
duc15052006-dotcom Sep 19, 2026
e1b0157
style: format lifecycle release preflight
duc15052006-dotcom Sep 19, 2026
e8d5b6b
style: format lifecycle reader types
duc15052006-dotcom Sep 19, 2026
e1802c6
style: format lifecycle schema type
duc15052006-dotcom Sep 19, 2026
7db7911
fix(computer): do not count Sleep bookkeeping as activity
duc15052006-dotcom Sep 19, 2026
b488e34
test(computer): update fleet contract with lifecycle state
duc15052006-dotcom Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/src/lib/computers/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ export type QuarantineList = {
downloads: QuarantineEntry[];
};

export type ComputerLifecycle = "running" | "idle" | "sleeping" | "stopped";

export type ComputerProfile = {
botId: string;
running: boolean;
/** Optional during rolling upgrades; derive from running/metrics when an older server omits it. */
lifecycle?: ComputerLifecycle;
startedAt: string | null;
/** Absent when the provider does not report egress at all, which is not the same as none. */
egress?: string | null;
Expand Down
44 changes: 36 additions & 8 deletions app/src/routes/_authed/admin/computers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,7 @@ function ComputersPage() {
{nameFor(computer.botId)}
</ItemTitle>
<ItemDescription>
{computer.running
? computer.metrics?.browserRunning === false
? "Computer awake · Browser sleeping after idle"
: computer.metrics?.browserRunning === true
? `Browser running since ${new Date(computer.startedAt ?? "").toLocaleTimeString()}`
: "Computer running · Browser state unavailable"
: "Computer stopped. Its profile and workspace remain saved."}
{computerLifecycleDescription(computer)}
{" · "}
{computer.egress === undefined
? "Egress not reported"
Expand Down Expand Up @@ -274,7 +268,13 @@ function ComputersPage() {
size="sm"
variant="outline"
>
{busy === computer.botId ? "Starting…" : "Start"}
{busy === computer.botId
? computer.lifecycle === "sleeping"
? "Waking…"
: "Starting…"
: computer.lifecycle === "sleeping"
? "Wake"
: "Start"}
</Button>
)}
<Button
Expand Down Expand Up @@ -607,6 +607,34 @@ function summaryFor(
return parts.length > 0 ? parts.join(" · ") : "No folders approved.";
}

function computerLifecycleDescription(computer: {
running: boolean;
lifecycle?: "running" | "idle" | "sleeping" | "stopped";
startedAt: string | null;
metrics?: { browserRunning?: boolean };
}): string {
const lifecycle =
computer.lifecycle ??
(computer.running
? computer.metrics?.browserRunning === false
? "idle"
: "running"
: "stopped");

switch (lifecycle) {
case "idle":
return "Idle · Computer awake, browser sleeping until the next task";
case "sleeping":
return "Sleeping after idle · profile and workspace saved · next task wakes it";
case "stopped":
return "Stopped manually · profile and workspace remain saved";
case "running":
return computer.startedAt
? `Running since ${new Date(computer.startedAt).toLocaleTimeString()}`
: "Running";
}
}

function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"] as const;
Expand Down
33 changes: 32 additions & 1 deletion scripts/release-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,9 @@ function checkComputerSandboxBoundary(): void {
for (const evidence of [
"KILL ALL COMPUTERS",
"ComputerScreenDialog",
"Browser sleeping",
"Idle · Computer awake",
"Sleeping after idle",
'"Waking…"',
"Reset",
]) {
if (!computersPage.includes(evidence)) {
Expand All @@ -323,6 +325,35 @@ function checkComputerSandboxBoundary(): void {
fail("computer: admin Kill All Computers route is missing");
}

const lifecycle = read("server/src/computer/lifecycle.ts");
const culler = read("server/src/work/culler.ts");
const gateway = read("server/src/computer/gateway.ts");
for (const evidence of [
'"running"',
'"idle"',
'"sleeping"',
'"stopped"',
'latestEvent === "computer.slept"',
]) {
if (!lifecycle.includes(evidence)) {
fail(`computer: explicit lifecycle state is missing ${evidence}`);
}
}
for (const evidence of [
'eventType: "computer.slept"',
'initiator: { kind: "deployment" }',
'reason: "idle_timeout"',
]) {
if (!culler.includes(evidence)) {
fail(`computer: durable idle Sleep provenance is missing ${evidence}`);
}
}
if (!gateway.includes('woke ? "computer.woke" : "computer.started"')) {
fail(
"computer: waking from automatic Sleep is not distinguished from Start",
);
}

const workspace = read("agent-computer/src/workspace.ts");
for (const evidence of [
"totalBytes: 4 * 1024 * 1024 * 1024",
Expand Down
2 changes: 2 additions & 0 deletions server/scripts/cull-idle-computers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* reported and left for the next sweep, because a computer still running costs money rather than
* losing anything, and a failing CronJob that pages somebody at 3am should mean something worse.
*/
import { createAuditStore } from "../src/audit";
import { createComputerProvider } from "../src/computer/provider";
import { loadConfig } from "../src/config";
import { createDatabase } from "../src/db/client";
Expand Down Expand Up @@ -45,6 +46,7 @@ try {
database,
queue,
provider,
auditStore: createAuditStore(database),
idleAfterMs: config.computer.idleAfterMs,
owner,
};
Expand Down
2 changes: 2 additions & 0 deletions server/src/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,9 @@ export const auditEventTypes = [
// both the recovery path and the most consequential button on the admin page, so who pressed it and
// when is exactly the sort of thing an investigator needs and nothing else records.
"computer.started",
"computer.woke",
"computer.restarted",
"computer.slept",
"computer.stopped",
"computer.reset",
// Untrusted downloads stay quarantined through scanning and explicit human approval. These rows
Expand Down
69 changes: 55 additions & 14 deletions server/src/computer/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export {
WorkspaceRequestError,
} from "./client";

import type { ComputerLifecycleReader } from "./lifecycle";
import { computerLifecycleState } from "./lifecycle";
import type { PageFrameStore } from "./page-frames";
import {
type ActionPolicy,
Expand Down Expand Up @@ -126,6 +128,8 @@ export type ComputerGatewayOptions = {
* this deployment makes in as many words.
*/
pageFrames?: PageFrameStore;
/** Durable provenance that distinguishes automatic Sleep from an explicit Stop. */
lifecycleReader?: ComputerLifecycleReader;
};

export interface ComputerGateway {
Expand Down Expand Up @@ -231,6 +235,7 @@ export interface ComputerGateway {
computers: {
botId: string;
running: boolean;
lifecycle: "running" | "idle" | "sleeping" | "stopped";
startedAt: string | null;
egress?: string | null;
metrics?: ComputerResourceMetrics;
Expand Down Expand Up @@ -837,15 +842,36 @@ export function createComputerGateway(
};

const metrics = await Promise.all(computers.map(metricsFor));
let lifecycleEvents = new Map();
if (options.lifecycleReader) {
try {
lifecycleEvents = await options.lifecycleReader.latest(
computers
.filter((computer) => computer.status !== "running")
.map((computer) => computer.botId),
);
} catch {
// Lifecycle provenance is presentation metadata. A temporary audit-read failure must not
// hide the fleet; a stopped Computer falls back to "stopped" until the next refresh.
}
}
return {
isolation: provider.isolation,
computers: computers.map((computer, index) => ({
botId: computer.botId,
running: computer.status === "running",
startedAt: computer.startedAt ?? null,
egress: computer.egress,
...(metrics[index] ? { metrics: metrics[index] } : {}),
})),
computers: computers.map((computer, index) => {
const running = computer.status === "running";
return {
botId: computer.botId,
running,
lifecycle: computerLifecycleState({
running,
browserRunning: metrics[index]?.browserRunning,
latestEvent: lifecycleEvents.get(computer.botId),
}),
startedAt: computer.startedAt ?? null,
egress: computer.egress,
...(metrics[index] ? { metrics: metrics[index] } : {}),
};
}),
};
},

Expand All @@ -862,18 +888,32 @@ export function createComputerGateway(
botId,
state: "unreachable" as const,
}));
const priorLifecycle =
before.state === "ready" || !options.lifecycleReader
? undefined
: await options.lifecycleReader
.latest([botId])
.then((events) => events.get(botId))
.catch(() => undefined);
const url = await locate(botId);
const started = before.state !== "ready";
// Refs belong to the browser run that produced them. Waking a stopped computer may replace
// that run, so never let an old accessibility ref survive into the new process.
if (started) await snapshots.clear(botId);
await writeControlEvent(auditStore, "computer.started", {
botId,
actor,
reason: started
? "the computer was started or resumed"
: "the computer was already running",
});
const woke = started && priorLifecycle === "computer.slept";
await writeControlEvent(
auditStore,
woke ? "computer.woke" : "computer.started",
{
botId,
actor,
reason: woke
? "the computer woke from automatic idle sleep"
: started
? "the computer was started or resumed"
: "the computer was already running",
},
);
return { started, url };
},

Expand Down Expand Up @@ -1449,6 +1489,7 @@ async function writeControlEvent(
| "computer.secret_requested"
| "computer.secret_supplied"
| "computer.started"
| "computer.woke"
| "computer.restarted"
| "computer.stopped"
| "computer.reset"
Expand Down
89 changes: 89 additions & 0 deletions server/src/computer/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Database } from "../db/client";
import { auditEvents } from "../db/schema";
import type { ComputerLifecycleState } from "./schema";

export const COMPUTER_LIFECYCLE_EVENTS = [
"computer.started",
"computer.woke",
"computer.restarted",
"computer.slept",
"computer.stopped",
"computer.reset",
] as const;

export type ComputerLifecycleEvent = (typeof COMPUTER_LIFECYCLE_EVENTS)[number];

export type ComputerLifecycleReader = {
latest(botIds: string[]): Promise<Map<string, ComputerLifecycleEvent>>;
};

/**
* One lifecycle label for the fleet surface.
*
* A running container with no resident browser is idle: its durable profile remains mounted and the
* next browser action can relaunch Chromium without recreating the Computer. A stopped container is
* called sleeping only when the durable audit trail says the idle culler put it there; an explicit
* Stop remains stopped, so the UI never pretends a person's action was automatic power saving.
*/
export function computerLifecycleState(input: {
running: boolean;
browserRunning?: boolean;
latestEvent?: ComputerLifecycleEvent;
}): ComputerLifecycleState {
if (input.running) {
return input.browserRunning === false ? "idle" : "running";
}
return input.latestEvent === "computer.slept" ? "sleeping" : "stopped";
}

/**
* Read the newest lifecycle event for every requested Bot in one database query.
*
* Fleet pages can contain many stopped profiles. Querying the audit reader once per row turns one
* refresh into an N+1 burst; reading the bounded lifecycle event family together keeps the refresh
* cost proportional to the actual number of lifecycle changes instead.
*/
export function createComputerLifecycleReader(
database: Database,
): ComputerLifecycleReader {
return {
latest: async (botIds) => {
const unique = [...new Set(botIds.filter(Boolean))];
if (unique.length === 0) return new Map();

const rows = await database
.select({
targetId: auditEvents.targetId,
eventType: auditEvents.eventType,
})
.from(auditEvents)
.where(
and(
eq(auditEvents.targetType, "computer"),
inArray(auditEvents.targetId, unique),
inArray(
auditEvents.eventType,
COMPUTER_LIFECYCLE_EVENTS as unknown as string[],
),
),
)
.orderBy(desc(auditEvents.createdAt), desc(auditEvents.id));

const latest = new Map<string, ComputerLifecycleEvent>();
for (const row of rows) {
if (
!row.targetId ||
latest.has(row.targetId) ||
!COMPUTER_LIFECYCLE_EVENTS.includes(
row.eventType as ComputerLifecycleEvent,
)
) {
continue;
}
latest.set(row.targetId, row.eventType as ComputerLifecycleEvent);
}
return latest;
},
};
}
11 changes: 11 additions & 0 deletions server/src/computer/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export type SecretRequest = { label: string; ref: string; snapshotId: number };
export type ComputerProfile = {
botId: string;
running: boolean;
lifecycle: ComputerLifecycleState;
startedAt: string | null;
/**
* The host its traffic leaves through, or null for direct.
Expand Down Expand Up @@ -341,6 +342,16 @@ export type HumanInputResult = {
url: string;
};

/** Fleet lifecycle shown to a person without changing the provider's lower-level health states. */
export const COMPUTER_LIFECYCLE_STATES = [
"running",
"idle",
"sleeping",
"stopped",
] as const;

export type ComputerLifecycleState = (typeof COMPUTER_LIFECYCLE_STATES)[number];

/** Lifecycle states a Bot's computer can be in, as the UI must render them. */
export const COMPUTER_STATES = [
"absent",
Expand Down
Loading
Loading