@fungi.computer/computer 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +85 -0
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/container/computer-init.mjs +119 -0
- package/container/computer-start.sh +24 -0
- package/container/package.json +14 -0
- package/container/pnpm-lock.yaml +378 -0
- package/container/pnpm-workspace.yaml +5 -0
- package/container/resource-bounds.mjs +54 -0
- package/container/runtime-privileges.cc +37 -0
- package/container/runtime-privileges.mjs +11 -0
- package/container/terminal-assets/README.md +35 -0
- package/container/terminal-assets/bashrc +49 -0
- package/container/terminal-assets/git-prompt.sh +586 -0
- package/container/terminal-protocol.mjs +58 -0
- package/container/terminal-runtime.mjs +10 -0
- package/container/terminal-server.mjs +243 -0
- package/dist/apply-patch/LICENSE.codex +201 -0
- package/dist/apply-patch/build.mjs +98 -0
- package/dist/apply-patch/cache.mjs +168 -0
- package/dist/apply-patch/compile.mjs +199 -0
- package/dist/apply-patch/docker/darwin.Dockerfile +15 -0
- package/dist/apply-patch/docker/linux-arm64.Dockerfile +38 -0
- package/dist/apply-patch/docker/linux-x64.Dockerfile +23 -0
- package/dist/apply-patch/main.rs +320 -0
- package/dist/apply-patch/notices.mjs +44 -0
- package/dist/apply-patch/pin.mjs +8 -0
- package/dist/apply-patch/toolchain.mjs +39 -0
- package/dist/computer-backends.d.ts +94 -0
- package/dist/computer-backends.js +103 -0
- package/dist/computerd.cjs +18010 -0
- package/dist/container-policy.d.ts +7 -0
- package/dist/container-policy.js +24 -0
- package/dist/container-system-trust.d.ts +8 -0
- package/dist/container-system-trust.js +66 -0
- package/dist/egress-policy.d.ts +89 -0
- package/dist/egress-policy.js +240 -0
- package/dist/execution-result.d.ts +3 -0
- package/dist/execution-result.js +28 -0
- package/dist/files-patch.d.ts +20 -0
- package/dist/files-patch.js +108 -0
- package/dist/files.d.ts +75 -0
- package/dist/files.js +136 -0
- package/dist/fleet/admission.d.ts +14 -0
- package/dist/fleet/admission.js +10 -0
- package/dist/fleet/backing.d.ts +52 -0
- package/dist/fleet/backing.js +242 -0
- package/dist/fleet/computers.d.ts +62 -0
- package/dist/fleet/computers.js +35 -0
- package/dist/fleet/index.d.ts +11 -0
- package/dist/fleet/index.js +11 -0
- package/dist/fleet/inventory-wire.d.ts +29 -0
- package/dist/fleet/inventory-wire.js +15 -0
- package/dist/fleet/inventory.d.ts +24 -0
- package/dist/fleet/inventory.js +152 -0
- package/dist/fleet/lifecycle.d.ts +99 -0
- package/dist/fleet/lifecycle.js +423 -0
- package/dist/fleet/runtime-usage.d.ts +50 -0
- package/dist/fleet/runtime-usage.js +145 -0
- package/dist/fleet/slots.d.ts +30 -0
- package/dist/fleet/slots.js +46 -0
- package/dist/fleet/wire.d.ts +228 -0
- package/dist/fleet/wire.js +127 -0
- package/dist/host-config.d.ts +12 -0
- package/dist/host-config.js +10 -0
- package/dist/index.d.ts +282 -0
- package/dist/index.js +1237 -0
- package/dist/light-shell.d.ts +44 -0
- package/dist/light-shell.js +376 -0
- package/dist/local-backend.d.ts +52 -0
- package/dist/local-backend.js +143 -0
- package/dist/metered-container-host.d.ts +26 -0
- package/dist/metered-container-host.js +48 -0
- package/dist/on-box-vm.d.ts +119 -0
- package/dist/on-box-vm.js +294 -0
- package/dist/operation-contract.d.ts +456 -0
- package/dist/operation-contract.js +452 -0
- package/dist/operation-record.d.ts +50 -0
- package/dist/operation-record.js +162 -0
- package/dist/preview.d.ts +55 -0
- package/dist/preview.js +151 -0
- package/dist/process-inventory.d.ts +80 -0
- package/dist/process-inventory.js +427 -0
- package/dist/pull-pending.d.ts +26 -0
- package/dist/pull-pending.js +35 -0
- package/dist/team-candidate-builder.mjs +28170 -0
- package/dist/transport-backend.d.ts +3 -0
- package/dist/transport-backend.js +99 -0
- package/dist/workspace-stream.d.ts +2 -0
- package/dist/workspace-stream.js +31 -0
- package/package.json +126 -3
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { words } from "@fungi.computer/cairn";
|
|
2
|
+
import { defineIdFamily } from "@fungi.computer/cairn/id";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { ComputerInventoryError, computerPageSchema, } from "./inventory-wire.js";
|
|
5
|
+
const computerIds = defineIdFamily({
|
|
6
|
+
family: "computer",
|
|
7
|
+
version: 1,
|
|
8
|
+
kinds: { computer: { prefix: "computer" } },
|
|
9
|
+
});
|
|
10
|
+
const rowSchema = z.strictObject({
|
|
11
|
+
computer_id: z.string().min(1),
|
|
12
|
+
name: z.string().min(1).max(256),
|
|
13
|
+
actor_kind: z.string().min(1),
|
|
14
|
+
actor_id: z.string().min(1).max(256),
|
|
15
|
+
active: z.number().int().min(0).max(1),
|
|
16
|
+
created_at: z.number().int().nonnegative(),
|
|
17
|
+
updated_at: z.number().int().nonnegative(),
|
|
18
|
+
});
|
|
19
|
+
const receiptSchema = z.strictObject({
|
|
20
|
+
requested_name: z.string().min(1).max(256).nullable(),
|
|
21
|
+
request_id: z.string().min(1).max(256),
|
|
22
|
+
actor_kind: z.string().min(1),
|
|
23
|
+
actor_id: z.string().min(1).max(256),
|
|
24
|
+
name: z.string().min(1).max(256),
|
|
25
|
+
computer_id: z.string().min(1),
|
|
26
|
+
});
|
|
27
|
+
const selectRow = "SELECT computer_id,name,actor_kind,actor_id,active,created_at,updated_at FROM hub_team_computers";
|
|
28
|
+
function actorColumns(actor) {
|
|
29
|
+
return { actorId: actor.id, actorKind: actor.kind };
|
|
30
|
+
}
|
|
31
|
+
function sameActor(actorKind, actorId, actor) {
|
|
32
|
+
return actor.kind === actorKind && actor.id === actorId;
|
|
33
|
+
}
|
|
34
|
+
/** Durable Computer inventory and replay receipts, without host admission policy. */
|
|
35
|
+
export function createFleetInventory(options) {
|
|
36
|
+
const { sql } = options;
|
|
37
|
+
const ensure = () => {
|
|
38
|
+
sql.exec("CREATE TABLE IF NOT EXISTS hub_team_computers (computer_id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, actor_kind TEXT NOT NULL, actor_id TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL) STRICT");
|
|
39
|
+
sql.exec("CREATE TABLE IF NOT EXISTS hub_team_computer_create_receipts (request_id TEXT PRIMARY KEY, actor_kind TEXT NOT NULL, actor_id TEXT NOT NULL, requested_name TEXT, name TEXT NOT NULL, computer_id TEXT NOT NULL REFERENCES hub_team_computers(computer_id)) STRICT");
|
|
40
|
+
};
|
|
41
|
+
const decode = (row) => {
|
|
42
|
+
const id = computerIds.computer.parse(row.computer_id);
|
|
43
|
+
if (!id.ok)
|
|
44
|
+
throw new ComputerInventoryError("partial_init");
|
|
45
|
+
return {
|
|
46
|
+
computerId: id.value,
|
|
47
|
+
name: row.name,
|
|
48
|
+
createdAt: row.created_at,
|
|
49
|
+
updatedAt: row.updated_at,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
const nameIsTaken = (name) => sql
|
|
53
|
+
.exec("SELECT computer_id FROM hub_team_computers WHERE name=?", name)
|
|
54
|
+
.toArray().length > 0;
|
|
55
|
+
const generatedName = () => {
|
|
56
|
+
// Cairn's default two-word names are candidates, not registered unique IDs.
|
|
57
|
+
// Add entropy on collision instead of rejecting an otherwise valid create.
|
|
58
|
+
for (const count of [2, 3, 4]) {
|
|
59
|
+
const candidate = words({ count });
|
|
60
|
+
if (!nameIsTaken(candidate))
|
|
61
|
+
return candidate;
|
|
62
|
+
}
|
|
63
|
+
throw new ComputerInventoryError("name_taken");
|
|
64
|
+
};
|
|
65
|
+
const read = (computerId) => {
|
|
66
|
+
const raw = sql
|
|
67
|
+
.exec(`${selectRow} WHERE computer_id=?`, computerId)
|
|
68
|
+
.toArray()[0];
|
|
69
|
+
if (raw === undefined)
|
|
70
|
+
return null;
|
|
71
|
+
const row = rowSchema.safeParse(raw);
|
|
72
|
+
if (!row.success)
|
|
73
|
+
throw new ComputerInventoryError("partial_init");
|
|
74
|
+
return row.data.active === 0 ? null : decode(row.data);
|
|
75
|
+
};
|
|
76
|
+
const count = () => z
|
|
77
|
+
.strictObject({ count: z.number().int().nonnegative() })
|
|
78
|
+
.parse(sql
|
|
79
|
+
.exec("SELECT COUNT(*) AS count FROM hub_team_computers")
|
|
80
|
+
.toArray()[0]).count;
|
|
81
|
+
const list = (input) => {
|
|
82
|
+
const { limit, offset } = computerPageSchema.parse(input);
|
|
83
|
+
const rows = sql
|
|
84
|
+
.exec(`${selectRow} WHERE active=1 ORDER BY created_at DESC,computer_id DESC LIMIT ? OFFSET ?`, limit + 1, offset)
|
|
85
|
+
.toArray()
|
|
86
|
+
.map((raw) => {
|
|
87
|
+
const row = rowSchema.safeParse(raw);
|
|
88
|
+
if (!row.success)
|
|
89
|
+
throw new ComputerInventoryError("partial_init");
|
|
90
|
+
return decode(row.data);
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
computers: rows.slice(0, limit),
|
|
94
|
+
nextOffset: rows.length > limit ? offset + limit : null,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
/** Every active Computer in this inventory. */
|
|
98
|
+
const ids = () => {
|
|
99
|
+
const found = [];
|
|
100
|
+
let offset = 0;
|
|
101
|
+
while (offset !== null) {
|
|
102
|
+
const page = list({ limit: 100, offset });
|
|
103
|
+
found.push(...page.computers.map(({ computerId }) => computerId));
|
|
104
|
+
offset = page.nextOffset;
|
|
105
|
+
}
|
|
106
|
+
return found;
|
|
107
|
+
};
|
|
108
|
+
/** Validate an existing receipt before the host decides whether a new create is funded. */
|
|
109
|
+
const replay = (actor, request) => {
|
|
110
|
+
const prior = sql
|
|
111
|
+
.exec("SELECT request_id,actor_kind,actor_id,requested_name,name,computer_id FROM hub_team_computer_create_receipts WHERE request_id=?", request.requestId)
|
|
112
|
+
.toArray()[0];
|
|
113
|
+
if (prior !== undefined) {
|
|
114
|
+
const receipt = receiptSchema.safeParse(prior);
|
|
115
|
+
if (!receipt.success)
|
|
116
|
+
throw new ComputerInventoryError("partial_init");
|
|
117
|
+
if (!sameActor(receipt.data.actor_kind, receipt.data.actor_id, actor) ||
|
|
118
|
+
receipt.data.requested_name !== (request.name ?? null))
|
|
119
|
+
throw new ComputerInventoryError("request_conflict");
|
|
120
|
+
const stored = rowSchema.safeParse(sql
|
|
121
|
+
.exec(`${selectRow} WHERE computer_id=?`, receipt.data.computer_id)
|
|
122
|
+
.toArray()[0]);
|
|
123
|
+
if (!stored.success ||
|
|
124
|
+
stored.data.active === 0 ||
|
|
125
|
+
stored.data.name !== receipt.data.name ||
|
|
126
|
+
stored.data.actor_kind !== receipt.data.actor_kind ||
|
|
127
|
+
stored.data.actor_id !== receipt.data.actor_id)
|
|
128
|
+
throw new ComputerInventoryError("partial_init");
|
|
129
|
+
return decode(stored.data);
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
};
|
|
133
|
+
/** The host has admitted creation; identity and its receipt commit together. */
|
|
134
|
+
const create = (actor, request) => options.transactionSync(() => {
|
|
135
|
+
const prior = replay(actor, request);
|
|
136
|
+
if (prior !== null)
|
|
137
|
+
return prior;
|
|
138
|
+
const name = request.name ?? generatedName();
|
|
139
|
+
if (request.name !== undefined && nameIsTaken(name))
|
|
140
|
+
throw new ComputerInventoryError("name_taken");
|
|
141
|
+
const computerId = computerIds.computer.generate();
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const columns = actorColumns(actor);
|
|
144
|
+
sql.exec("INSERT INTO hub_team_computers (computer_id,name,actor_kind,actor_id,active,created_at,updated_at) VALUES (?,?,?,?,?,?,?)", computerId, name, columns.actorKind, columns.actorId, 1, now, now);
|
|
145
|
+
sql.exec("INSERT INTO hub_team_computer_create_receipts (request_id,actor_kind,actor_id,requested_name,name,computer_id) VALUES (?,?,?,?,?,?)", request.requestId, columns.actorKind, columns.actorId, request.name ?? null, name, computerId);
|
|
146
|
+
const created = read(computerId);
|
|
147
|
+
if (created === null)
|
|
148
|
+
throw new ComputerInventoryError("partial_init");
|
|
149
|
+
return created;
|
|
150
|
+
});
|
|
151
|
+
return { ensure, replay, create, read, list, ids, count };
|
|
152
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
|
|
2
|
+
import type { ComputerDO as NativeComputerDO, OnBoxVmContainerHost } from "../index.js";
|
|
3
|
+
import { type ContainerRuntimeStart, type HostedBacking, type AttachedBackend, type AttachedBackendView } from "../computer-backends.js";
|
|
4
|
+
import { ComputerRuntimeUsage } from "./runtime-usage.js";
|
|
5
|
+
import type { ComputerGrant } from "./wire.js";
|
|
6
|
+
/** Runtime observations consumed by the host; never host callbacks. */
|
|
7
|
+
export type FleetLifecycleFact = Readonly<{
|
|
8
|
+
kind: "reservation.release" | "reservation.monitor";
|
|
9
|
+
}> | Readonly<{
|
|
10
|
+
kind: "diagnostic";
|
|
11
|
+
event: string;
|
|
12
|
+
code: string;
|
|
13
|
+
cause?: unknown;
|
|
14
|
+
}>;
|
|
15
|
+
/** Native runtime and storage capabilities, without host policy or settlement. */
|
|
16
|
+
export type FleetLifecyclePorts = Readonly<{
|
|
17
|
+
storage: WatchdogSqliteOwner & Pick<DurableObjectStorage, "getAlarm" | "setAlarm">;
|
|
18
|
+
alarm: Pick<DurableObjectState, "waitUntil">;
|
|
19
|
+
computerId: string | undefined;
|
|
20
|
+
runtimeWatchMs: string | undefined;
|
|
21
|
+
backend: Readonly<{
|
|
22
|
+
state: Pick<DurableObjectState, "id" | "waitUntil"> & {
|
|
23
|
+
container?: Pick<NonNullable<DurableObjectState["container"]>, "running" | "monitor" | "destroy">;
|
|
24
|
+
};
|
|
25
|
+
onBoxVmHost: (hostId: string) => Pick<OnBoxVmContainerHost, "state" | "inventory" | "destroy">;
|
|
26
|
+
hostedContainerHost: (backing: HostedBacking) => Pick<ReturnType<NativeComputerDO["getWorkspaceContainer"]>, "restart">;
|
|
27
|
+
readyWorkspace: () => Promise<{
|
|
28
|
+
push: (id: string) => Promise<number>;
|
|
29
|
+
pull: (id: string) => Promise<object>;
|
|
30
|
+
}>;
|
|
31
|
+
workspace: () => {
|
|
32
|
+
push: (id: string) => Promise<number>;
|
|
33
|
+
};
|
|
34
|
+
syncRecycledWorkspace: (backing: HostedBacking) => Promise<void>;
|
|
35
|
+
attachedBackends: () => readonly AttachedBackend[];
|
|
36
|
+
describeBackends: () => AttachedBackendView[];
|
|
37
|
+
requireHostedRoute: () => {
|
|
38
|
+
backing: HostedBacking;
|
|
39
|
+
id: string;
|
|
40
|
+
};
|
|
41
|
+
containerRunning: (backing: HostedBacking) => Promise<boolean>;
|
|
42
|
+
vmQueuePosition: (hostId: string) => Promise<number | null>;
|
|
43
|
+
}>;
|
|
44
|
+
}>;
|
|
45
|
+
/** A grant refused before native execution. The host maps it to its public error. */
|
|
46
|
+
export declare class FleetGrantRefused extends Error {
|
|
47
|
+
constructor();
|
|
48
|
+
}
|
|
49
|
+
/** Runtime activity, interval observations and recycling under host-issued grants. */
|
|
50
|
+
export declare class FleetLifecycle {
|
|
51
|
+
#private;
|
|
52
|
+
private readonly ports;
|
|
53
|
+
readonly usage: ComputerRuntimeUsage;
|
|
54
|
+
constructor(ports: FleetLifecyclePorts);
|
|
55
|
+
get recycleRuntimeId(): string | undefined;
|
|
56
|
+
takeFacts(): FleetLifecycleFact[];
|
|
57
|
+
private diagnostic;
|
|
58
|
+
/** Keep pending starts visible while the host obtains admission and capacity. */
|
|
59
|
+
beginHostedStart(): () => void;
|
|
60
|
+
/** Read liveness without waking a sleeping backend. Queue state wins over booting. */
|
|
61
|
+
readComputerState(): Promise<{
|
|
62
|
+
state: "queued";
|
|
63
|
+
position: number;
|
|
64
|
+
} | {
|
|
65
|
+
state: "running" | "starting";
|
|
66
|
+
position?: never;
|
|
67
|
+
} | {
|
|
68
|
+
state: "running" | "sleeping";
|
|
69
|
+
position?: never;
|
|
70
|
+
}>;
|
|
71
|
+
/** The Computer page's read: each attached backend and its live status. */
|
|
72
|
+
readAttachedBackends(): Promise<(Omit<AttachedBackendView, "status"> & {
|
|
73
|
+
status: AttachedBackendView["status"] | "queued";
|
|
74
|
+
queuePosition: number | null;
|
|
75
|
+
})[]>;
|
|
76
|
+
/** Native execution remains the callback; policy has already become a grant. */
|
|
77
|
+
run<T>(grant: ComputerGrant, backing: HostedBacking, run: () => Promise<T>): Promise<T>;
|
|
78
|
+
/** Transport traffic advances activity; an idle open connection does not. */
|
|
79
|
+
trackResponse(backing: HostedBacking, response: Response): Response;
|
|
80
|
+
containerRuntimeStarted(start: ContainerRuntimeStart): void;
|
|
81
|
+
armRuntimeWatch(due: number): Promise<void>;
|
|
82
|
+
syncRecycledWorkspace(backing: HostedBacking): Promise<void>;
|
|
83
|
+
private finishVmRecycle;
|
|
84
|
+
private recycleQuietVms;
|
|
85
|
+
private observeRuntime;
|
|
86
|
+
awaitRecycle(): Promise<void>;
|
|
87
|
+
/** Reconcile durable observations before the host delivers usage facts. */
|
|
88
|
+
observe(): Promise<HostedBacking[]>;
|
|
89
|
+
/** The host supplies current permission; this owner chooses quiet runtime candidates. */
|
|
90
|
+
recycle(grant: ComputerGrant | null): Promise<void>;
|
|
91
|
+
watch(delivered: boolean, running: number): Promise<void>;
|
|
92
|
+
private recycleWatchDelay;
|
|
93
|
+
private observeOpenRuntimes;
|
|
94
|
+
/** Stop requested backends; failures remain host-readable observations. */
|
|
95
|
+
stop(runningBackings: readonly HostedBacking[]): Promise<void>;
|
|
96
|
+
/** Stop all known runtimes and report any still running. */
|
|
97
|
+
stopAll(reason: string): Promise<HostedBacking[]>;
|
|
98
|
+
private destroyRuntime;
|
|
99
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import * as Data from "effect/Data";
|
|
2
|
+
import { computerContainerSleepAfterMs } from "../container-policy.js";
|
|
3
|
+
import { attachedBackendId, } from "../computer-backends.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { ComputerRuntimeUsage } from "./runtime-usage.js";
|
|
6
|
+
import { checkComputerGrant } from "./admission.js";
|
|
7
|
+
const vmRecycleAfterMs = 23 * 60 * 60 * 1_000;
|
|
8
|
+
const vmRecycleQuietMs = 30_000;
|
|
9
|
+
const runtimeWatchSchema = z.coerce
|
|
10
|
+
.number()
|
|
11
|
+
.int()
|
|
12
|
+
.min(1_000)
|
|
13
|
+
.max(3_600_000)
|
|
14
|
+
.catch(60_000);
|
|
15
|
+
function runtimeWatchDelay(delivered, running, watchMs) {
|
|
16
|
+
if (running > 0)
|
|
17
|
+
return delivered ? watchMs : 1_000;
|
|
18
|
+
return delivered ? null : 30_000;
|
|
19
|
+
}
|
|
20
|
+
class ComputerVmRecycleError extends Data.TaggedError("ComputerVmRecycleError") {
|
|
21
|
+
}
|
|
22
|
+
/** A grant refused before native execution. The host maps it to its public error. */
|
|
23
|
+
export class FleetGrantRefused extends Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super("Computer grant refused");
|
|
26
|
+
this.name = "FleetGrantRefused";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Runtime activity, interval observations and recycling under host-issued grants. */
|
|
30
|
+
export class FleetLifecycle {
|
|
31
|
+
ports;
|
|
32
|
+
usage;
|
|
33
|
+
#watchMs;
|
|
34
|
+
#hostedOperations = 0;
|
|
35
|
+
#startingHosted = 0;
|
|
36
|
+
#lastHostedActivity = Date.now();
|
|
37
|
+
#recycling;
|
|
38
|
+
#recycleRuntimeId;
|
|
39
|
+
#facts = [];
|
|
40
|
+
constructor(ports) {
|
|
41
|
+
this.ports = ports;
|
|
42
|
+
this.#watchMs = runtimeWatchSchema.parse(ports.runtimeWatchMs ?? 60_000);
|
|
43
|
+
this.usage = new ComputerRuntimeUsage(ports.storage.sql, this.#watchMs);
|
|
44
|
+
}
|
|
45
|
+
get recycleRuntimeId() {
|
|
46
|
+
return this.#recycleRuntimeId;
|
|
47
|
+
}
|
|
48
|
+
takeFacts() {
|
|
49
|
+
const facts = this.#facts;
|
|
50
|
+
this.#facts = [];
|
|
51
|
+
return facts;
|
|
52
|
+
}
|
|
53
|
+
diagnostic(event, code, cause) {
|
|
54
|
+
this.#facts.push({ kind: "diagnostic", event, code, cause });
|
|
55
|
+
}
|
|
56
|
+
/** Keep pending starts visible while the host obtains admission and capacity. */
|
|
57
|
+
beginHostedStart() {
|
|
58
|
+
this.#startingHosted++;
|
|
59
|
+
return () => {
|
|
60
|
+
this.#startingHosted--;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Read liveness without waking a sleeping backend. Queue state wins over booting. */
|
|
64
|
+
async readComputerState() {
|
|
65
|
+
const backends = await this.readAttachedBackends();
|
|
66
|
+
const position = backends.find((backend) => backend.queuePosition !== null)?.queuePosition;
|
|
67
|
+
if (position != null)
|
|
68
|
+
return { state: "queued", position };
|
|
69
|
+
if (this.#startingHosted > 0)
|
|
70
|
+
return { state: "starting" };
|
|
71
|
+
for (const backing of this.ports.backend.attachedBackends()) {
|
|
72
|
+
if (backing.kind === "on-box-vm") {
|
|
73
|
+
const runtime = await this.ports.backend
|
|
74
|
+
.onBoxVmHost(backing.hostId)
|
|
75
|
+
.state();
|
|
76
|
+
if (runtime.running)
|
|
77
|
+
return {
|
|
78
|
+
state: runtime.runtimeId === null
|
|
79
|
+
? "starting"
|
|
80
|
+
: "running",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
else if (backing.kind === "platform-container" &&
|
|
84
|
+
(await this.ports.backend.containerRunning(backing))) {
|
|
85
|
+
return { state: "running" };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
state: backends.some((backend) => backend.status === "online")
|
|
90
|
+
? "running"
|
|
91
|
+
: "sleeping",
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** The Computer page's read: each attached backend and its live status. */
|
|
95
|
+
async readAttachedBackends() {
|
|
96
|
+
return await Promise.all(this.ports.backend.describeBackends().map(async (backend) => {
|
|
97
|
+
const queuePosition = backend.kind === "on-box-vm" && backend.hostId !== undefined
|
|
98
|
+
? await this.ports.backend.vmQueuePosition(backend.hostId)
|
|
99
|
+
: null;
|
|
100
|
+
return {
|
|
101
|
+
...backend,
|
|
102
|
+
status: queuePosition === null ? backend.status : "queued",
|
|
103
|
+
queuePosition,
|
|
104
|
+
};
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
/** Native execution remains the callback; policy has already become a grant. */
|
|
108
|
+
async run(grant, backing, run) {
|
|
109
|
+
await this.awaitRecycle();
|
|
110
|
+
if (checkComputerGrant(grant, {
|
|
111
|
+
computerId: this.ports.computerId ?? "",
|
|
112
|
+
action: "start",
|
|
113
|
+
now: Date.now(),
|
|
114
|
+
}).kind !== "admitted")
|
|
115
|
+
throw new FleetGrantRefused();
|
|
116
|
+
this.#hostedOperations++;
|
|
117
|
+
try {
|
|
118
|
+
if (backing.kind === "platform-container")
|
|
119
|
+
this.usage.touchActivity(backing, Date.now());
|
|
120
|
+
return await run();
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
this.#hostedOperations--;
|
|
124
|
+
this.#lastHostedActivity = Date.now();
|
|
125
|
+
if (backing.kind === "platform-container")
|
|
126
|
+
this.usage.touchActivity(backing, this.#lastHostedActivity);
|
|
127
|
+
if (backing.kind === "on-box-vm") {
|
|
128
|
+
if (!this.usage.hasOpen(backing))
|
|
129
|
+
this.#facts.push({ kind: "reservation.release" });
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.#facts.push({
|
|
133
|
+
kind: this.ports.backend.state.container?.running
|
|
134
|
+
? "reservation.monitor"
|
|
135
|
+
: "reservation.release",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Transport traffic advances activity; an idle open connection does not. */
|
|
141
|
+
trackResponse(backing, response) {
|
|
142
|
+
const socket = response.webSocket ?? null;
|
|
143
|
+
if (socket === null && response.body === null)
|
|
144
|
+
return response;
|
|
145
|
+
let lastRecordedAt = Date.now();
|
|
146
|
+
const recordTraffic = () => {
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
if (now - lastRecordedAt < 30_000)
|
|
149
|
+
return;
|
|
150
|
+
lastRecordedAt = now;
|
|
151
|
+
this.#lastHostedActivity = now;
|
|
152
|
+
if (backing.kind === "platform-container")
|
|
153
|
+
this.usage.touchActivity(backing, now);
|
|
154
|
+
};
|
|
155
|
+
if (socket !== null) {
|
|
156
|
+
const pair = new WebSocketPair();
|
|
157
|
+
const client = pair[0];
|
|
158
|
+
const server = pair[1];
|
|
159
|
+
const close = (event) => {
|
|
160
|
+
const code = event.code === 1000 || (event.code >= 3000 && event.code <= 4999)
|
|
161
|
+
? event.code
|
|
162
|
+
: 1000;
|
|
163
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
164
|
+
socket.close(code, event.reason);
|
|
165
|
+
if (server.readyState === WebSocket.OPEN)
|
|
166
|
+
server.close(code, event.reason);
|
|
167
|
+
};
|
|
168
|
+
const fail = () => {
|
|
169
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
170
|
+
socket.close(1000, "Terminal transport failed");
|
|
171
|
+
if (server.readyState === WebSocket.OPEN)
|
|
172
|
+
server.close(1000, "Terminal transport failed");
|
|
173
|
+
};
|
|
174
|
+
socket.addEventListener("message", (event) => {
|
|
175
|
+
recordTraffic();
|
|
176
|
+
server.send(event.data);
|
|
177
|
+
});
|
|
178
|
+
server.addEventListener("message", (event) => {
|
|
179
|
+
recordTraffic();
|
|
180
|
+
socket.send(event.data);
|
|
181
|
+
});
|
|
182
|
+
socket.addEventListener("close", close, { once: true });
|
|
183
|
+
server.addEventListener("close", close, { once: true });
|
|
184
|
+
socket.addEventListener("error", fail, { once: true });
|
|
185
|
+
server.addEventListener("error", fail, { once: true });
|
|
186
|
+
// Workers delivers binary frames as Blob by default; send accepts bytes.
|
|
187
|
+
socket.binaryType = "arraybuffer";
|
|
188
|
+
server.binaryType = "arraybuffer";
|
|
189
|
+
socket.accept();
|
|
190
|
+
server.accept();
|
|
191
|
+
return new Response(null, {
|
|
192
|
+
status: 101,
|
|
193
|
+
webSocket: client,
|
|
194
|
+
headers: response.headers,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const reader = response.body?.getReader();
|
|
198
|
+
if (reader === undefined) {
|
|
199
|
+
return response;
|
|
200
|
+
}
|
|
201
|
+
const body = new ReadableStream({
|
|
202
|
+
async pull(controller) {
|
|
203
|
+
try {
|
|
204
|
+
const result = await reader.read();
|
|
205
|
+
if (result.done) {
|
|
206
|
+
controller.close();
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const chunk = z.instanceof(Uint8Array).parse(result.value);
|
|
210
|
+
if (chunk.byteLength > 0)
|
|
211
|
+
recordTraffic();
|
|
212
|
+
controller.enqueue(chunk);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (cause) {
|
|
216
|
+
controller.error(cause);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
async cancel(reason) {
|
|
220
|
+
await reader.cancel(reason);
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
return new Response(body, {
|
|
224
|
+
status: response.status,
|
|
225
|
+
statusText: response.statusText,
|
|
226
|
+
headers: response.headers,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
containerRuntimeStarted(start) {
|
|
230
|
+
this.usage.started(start);
|
|
231
|
+
this.ports.alarm.waitUntil(this.armRuntimeWatch(Date.now() + this.#watchMs));
|
|
232
|
+
}
|
|
233
|
+
async armRuntimeWatch(due) {
|
|
234
|
+
const alarm = await this.ports.storage.getAlarm();
|
|
235
|
+
if (alarm === null || alarm > due)
|
|
236
|
+
await this.ports.storage.setAlarm(due);
|
|
237
|
+
}
|
|
238
|
+
async syncRecycledWorkspace(backing) {
|
|
239
|
+
const workspace = await this.ports.backend.readyWorkspace();
|
|
240
|
+
const id = attachedBackendId(backing);
|
|
241
|
+
await workspace.push(id);
|
|
242
|
+
await workspace.pull(id);
|
|
243
|
+
}
|
|
244
|
+
async finishVmRecycle() {
|
|
245
|
+
for (const row of this.usage.pendingRecycles()) {
|
|
246
|
+
const backing = row.backing;
|
|
247
|
+
const current = await this.ports.backend
|
|
248
|
+
.onBoxVmHost(backing.hostId)
|
|
249
|
+
.state();
|
|
250
|
+
if (!(current.running && current.runtimeId === row.runtimeId)) {
|
|
251
|
+
this.usage.observe(row.runtimeId, {
|
|
252
|
+
running: false,
|
|
253
|
+
endedAt: row.requestedAt,
|
|
254
|
+
});
|
|
255
|
+
if (current.running && current.runtimeId !== null)
|
|
256
|
+
this.containerRuntimeStarted({
|
|
257
|
+
runtimeId: current.runtimeId,
|
|
258
|
+
backing,
|
|
259
|
+
startedAt: Date.now(),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (!current.running &&
|
|
263
|
+
this.#hostedOperations === 0 &&
|
|
264
|
+
!this.usage.hasAnyOpen())
|
|
265
|
+
this.#facts.push({ kind: "reservation.release" });
|
|
266
|
+
this.usage.finishRecycle(row.runtimeId);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async recycleQuietVms(grant) {
|
|
270
|
+
if (this.#hostedOperations > 0 ||
|
|
271
|
+
Date.now() - this.#lastHostedActivity < vmRecycleQuietMs)
|
|
272
|
+
return;
|
|
273
|
+
const candidates = this.usage.recycleCandidates(Date.now() - vmRecycleAfterMs);
|
|
274
|
+
for (const row of candidates) {
|
|
275
|
+
const backing = row.backing;
|
|
276
|
+
if (backing.kind !== "on-box-vm" ||
|
|
277
|
+
grant === null ||
|
|
278
|
+
checkComputerGrant(grant, {
|
|
279
|
+
computerId: this.ports.computerId ?? "",
|
|
280
|
+
action: "start",
|
|
281
|
+
now: Date.now(),
|
|
282
|
+
}).kind !== "admitted")
|
|
283
|
+
continue;
|
|
284
|
+
const inventory = await this.ports.backend
|
|
285
|
+
.onBoxVmHost(backing.hostId)
|
|
286
|
+
.inventory();
|
|
287
|
+
const vm = inventory.find((entry) => entry.id === this.ports.backend.state.id.toString());
|
|
288
|
+
if (vm === undefined ||
|
|
289
|
+
!vm.running ||
|
|
290
|
+
vm.runtimeId !== row.runtimeId ||
|
|
291
|
+
Date.now() - vm.lastActive < vmRecycleQuietMs)
|
|
292
|
+
continue;
|
|
293
|
+
this.#recycleRuntimeId = row.runtimeId;
|
|
294
|
+
try {
|
|
295
|
+
await this.ports.backend.syncRecycledWorkspace(backing);
|
|
296
|
+
const current = await this.ports.backend
|
|
297
|
+
.onBoxVmHost(backing.hostId)
|
|
298
|
+
.state();
|
|
299
|
+
if (!current.running || current.runtimeId !== row.runtimeId)
|
|
300
|
+
continue;
|
|
301
|
+
this.usage.requestRecycle(row.runtimeId, backing, Date.now());
|
|
302
|
+
await this.armRuntimeWatch(Date.now() + vmRecycleQuietMs);
|
|
303
|
+
const started = await this.ports.backend
|
|
304
|
+
.hostedContainerHost(backing)
|
|
305
|
+
.restart({}, false);
|
|
306
|
+
this.#recycleRuntimeId = started.runtimeId;
|
|
307
|
+
await this.finishVmRecycle();
|
|
308
|
+
await this.ports.backend.syncRecycledWorkspace(backing);
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
this.#recycleRuntimeId = undefined;
|
|
312
|
+
await this.finishVmRecycle();
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async observeRuntime(runtimeId, backing, newest) {
|
|
317
|
+
const now = Date.now();
|
|
318
|
+
if (backing.kind === "platform-container") {
|
|
319
|
+
const container = this.ports.backend.state.container;
|
|
320
|
+
if (newest && container?.running === true) {
|
|
321
|
+
const deadline = this.usage.idleDeadline(runtimeId, computerContainerSleepAfterMs, now);
|
|
322
|
+
if (deadline !== null &&
|
|
323
|
+
this.#hostedOperations === 0 &&
|
|
324
|
+
now >= deadline) {
|
|
325
|
+
try {
|
|
326
|
+
await container.destroy(new Error("Computer idle timeout"));
|
|
327
|
+
}
|
|
328
|
+
catch (cause) {
|
|
329
|
+
this.diagnostic("computer_idle_stop_failed", "idle_stop", cause);
|
|
330
|
+
return { running: true, endedAt: now };
|
|
331
|
+
}
|
|
332
|
+
return { running: false, endedAt: deadline };
|
|
333
|
+
}
|
|
334
|
+
return { running: true, endedAt: now };
|
|
335
|
+
}
|
|
336
|
+
return { running: false, endedAt: now };
|
|
337
|
+
}
|
|
338
|
+
const state = await this.ports.backend.onBoxVmHost(backing.hostId).state();
|
|
339
|
+
return {
|
|
340
|
+
running: state.running && state.runtimeId === runtimeId,
|
|
341
|
+
endedAt: state.exit?.exitedAt ?? now,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
async awaitRecycle() {
|
|
345
|
+
const error = await this.#recycling;
|
|
346
|
+
if (error !== undefined)
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
/** Reconcile durable observations before the host delivers usage facts. */
|
|
350
|
+
async observe() {
|
|
351
|
+
await this.#recycling;
|
|
352
|
+
this.usage.ensure();
|
|
353
|
+
await this.finishVmRecycle();
|
|
354
|
+
return this.observeOpenRuntimes();
|
|
355
|
+
}
|
|
356
|
+
/** The host supplies current permission; this owner chooses quiet runtime candidates. */
|
|
357
|
+
async recycle(grant) {
|
|
358
|
+
if (this.#recycling !== undefined)
|
|
359
|
+
return;
|
|
360
|
+
const recycling = this.recycleQuietVms(grant).then(() => undefined, (cause) => {
|
|
361
|
+
const error = new ComputerVmRecycleError({ cause });
|
|
362
|
+
this.diagnostic("computer_vm_recycle_failed", "vm_recycle", error);
|
|
363
|
+
return error;
|
|
364
|
+
});
|
|
365
|
+
this.#recycling = recycling;
|
|
366
|
+
try {
|
|
367
|
+
await recycling;
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
this.#recycling = undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
async watch(delivered, running) {
|
|
374
|
+
const delay = runtimeWatchDelay(delivered, running, this.#watchMs);
|
|
375
|
+
if (delay !== null)
|
|
376
|
+
await this.armRuntimeWatch(Date.now() + this.recycleWatchDelay(delay));
|
|
377
|
+
}
|
|
378
|
+
recycleWatchDelay(delay) {
|
|
379
|
+
const oldest = this.usage.oldestVmStart();
|
|
380
|
+
if (oldest === null)
|
|
381
|
+
return delay;
|
|
382
|
+
return Math.min(delay, Math.max(vmRecycleQuietMs, oldest + vmRecycleAfterMs - Date.now()));
|
|
383
|
+
}
|
|
384
|
+
async observeOpenRuntimes() {
|
|
385
|
+
const open = this.usage.open();
|
|
386
|
+
const runningBackings = [];
|
|
387
|
+
for (const [index, row] of open.entries()) {
|
|
388
|
+
const observed = await this.observeRuntime(row.runtimeId, row.backing, index === 0);
|
|
389
|
+
if (observed.running)
|
|
390
|
+
runningBackings.push(row.backing);
|
|
391
|
+
this.usage.observe(row.runtimeId, observed);
|
|
392
|
+
}
|
|
393
|
+
if (open.length > 0 &&
|
|
394
|
+
this.#hostedOperations === 0 &&
|
|
395
|
+
runningBackings.length === 0)
|
|
396
|
+
this.#facts.push({ kind: "reservation.release" });
|
|
397
|
+
return runningBackings;
|
|
398
|
+
}
|
|
399
|
+
/** Stop requested backends; failures remain host-readable observations. */
|
|
400
|
+
async stop(runningBackings) {
|
|
401
|
+
const stopped = await Promise.allSettled(runningBackings.map((backing) => this.destroyRuntime(backing)));
|
|
402
|
+
if (stopped.some((result) => result.status === "rejected"))
|
|
403
|
+
this.diagnostic("computer_usage_stop_failed", "usage_stop");
|
|
404
|
+
}
|
|
405
|
+
/** Stop all known runtimes and report any still running. */
|
|
406
|
+
async stopAll(reason) {
|
|
407
|
+
this.usage.ensure();
|
|
408
|
+
const backings = this.usage.open().map(({ backing }) => backing);
|
|
409
|
+
await Promise.all(backings.map((backing) => this.destroyRuntime(backing)));
|
|
410
|
+
if (this.ports.backend.state.container?.running === true &&
|
|
411
|
+
backings.length === 0)
|
|
412
|
+
await this.ports.backend.state.container.destroy(new Error(reason));
|
|
413
|
+
return this.observeOpenRuntimes();
|
|
414
|
+
}
|
|
415
|
+
async destroyRuntime(backing) {
|
|
416
|
+
if (backing.kind === "on-box-vm") {
|
|
417
|
+
await this.ports.backend.onBoxVmHost(backing.hostId).destroy();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (this.ports.backend.state.container !== undefined)
|
|
421
|
+
await this.ports.backend.state.container.destroy(new Error("Computer usage delivery unavailable"));
|
|
422
|
+
}
|
|
423
|
+
}
|