@frockbot/plugin-computer 0.0.0 → 0.1.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/frockbot.json +25 -0
- package/package.json +54 -6
- package/src/agent.test.ts +271 -0
- package/src/agent.ts +1419 -0
- package/src/backend.test.ts +149 -0
- package/src/backend.ts +163 -0
- package/src/bot.test.ts +411 -0
- package/src/bot.ts +831 -0
- package/src/client/ComputerCard.test.ts +96 -0
- package/src/client/ComputerCard.vue +60 -0
- package/src/client/ComputerStrip.test.ts +54 -0
- package/src/client/ComputerStrip.vue +55 -0
- package/src/client/ComputerViewerOverlay.vue +252 -0
- package/src/client/application.test.ts +403 -0
- package/src/client/application.ts +359 -0
- package/src/client/cordis-client-shim.d.ts +16 -0
- package/src/client/dialog-focus.ts +13 -0
- package/src/client/index.ts +28 -0
- package/src/client/state-machine.test.ts +200 -0
- package/src/client/state-machine.ts +172 -0
- package/src/client/styles.css +594 -0
- package/src/client/viewer.ts +58 -0
- package/src/control-record.ts +57 -0
- package/src/doctor.test.ts +247 -0
- package/src/env.d.ts +12 -0
- package/src/index.ts +6 -0
- package/src/manifest.ts +3 -0
- package/src/process-records.test.ts +178 -0
- package/src/process-records.ts +278 -0
- package/src/process-store.ts +96 -0
- package/src/processes.test.ts +388 -0
- package/src/protocol.ts +405 -0
- package/src/roots.ts +6 -0
- package/src/screenshot.test.ts +253 -0
- package/src/shared-provider.test.ts +56 -0
- package/src/shared-provider.ts +121 -0
- package/src/shared.ts +54 -0
- package/src/sync.test.ts +255 -0
- package/src/workspace-fixture.ts +126 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +24 -0
- package/README.md +0 -3
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// The durable record of one background process on a Computer, and its codec.
|
|
2
|
+
//
|
|
3
|
+
// "A mutation or process launch records intent and an effect identifier in the
|
|
4
|
+
// Bot's Durable Object and in the Workspace before it runs, so recovery can
|
|
5
|
+
// read its outcome or classify it as unknown without repeating it." This record
|
|
6
|
+
// is that intent. It is written before `setsid` runs, and every later answer
|
|
7
|
+
// about the process is read out of it plus the Computer, never by launching
|
|
8
|
+
// anything a second time.
|
|
9
|
+
//
|
|
10
|
+
// Versioned, exact-field, and decoded at the seam it crosses, in the shape
|
|
11
|
+
// `@frockbot/plugin-routines`'s records use. There are no migrations: a record
|
|
12
|
+
// the current codec refuses is a visible failure rather than something to
|
|
13
|
+
// reshape.
|
|
14
|
+
|
|
15
|
+
/** The Bot Durable Object key one process record is stored under. */
|
|
16
|
+
export const COMPUTER_PROCESS_PREFIX = "computer-process:";
|
|
17
|
+
|
|
18
|
+
/** Most background process records one Bot may hold. */
|
|
19
|
+
export const COMPUTER_PROCESS_LIMIT_PER_BOT = 100;
|
|
20
|
+
|
|
21
|
+
export const COMPUTER_PROCESS_COMMAND_MAX = 20_000;
|
|
22
|
+
export const COMPUTER_PROCESS_ID_MAX = 128;
|
|
23
|
+
export const COMPUTER_PROCESS_PATH_MAX = 1_024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a process is, as durable state.
|
|
27
|
+
*
|
|
28
|
+
* `unknown` is a first-class status, not an error: the Computer wakes only
|
|
29
|
+
* when a Bot uses it, and "other processes are assumed dead after a cold
|
|
30
|
+
* pause". A process whose Computer was reprovisioned under it, or whose pid is
|
|
31
|
+
* gone with no exit file, is `unknown` — and saying so is the observable
|
|
32
|
+
* failure state the constitution asks for. It is never reported as `running`.
|
|
33
|
+
*/
|
|
34
|
+
export const COMPUTER_PROCESS_STATUSES = [
|
|
35
|
+
"starting",
|
|
36
|
+
"running",
|
|
37
|
+
"exited",
|
|
38
|
+
"unknown",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type ComputerProcessStatusV1 =
|
|
42
|
+
(typeof COMPUTER_PROCESS_STATUSES)[number];
|
|
43
|
+
|
|
44
|
+
export interface ComputerProcessRecordV1 {
|
|
45
|
+
schemaVersion: 1;
|
|
46
|
+
processId: string;
|
|
47
|
+
botId: string;
|
|
48
|
+
sessionId: string;
|
|
49
|
+
turnId: string;
|
|
50
|
+
command: string;
|
|
51
|
+
cwd: string;
|
|
52
|
+
startedAt: string;
|
|
53
|
+
status: ComputerProcessStatusV1;
|
|
54
|
+
/**
|
|
55
|
+
* The Computer's provisioning generation at launch. A later generation means
|
|
56
|
+
* a different Computer under the same name, so the process is gone by
|
|
57
|
+
* constitution and `check` answers `unknown`.
|
|
58
|
+
*/
|
|
59
|
+
generation: number;
|
|
60
|
+
effectId: string;
|
|
61
|
+
pid?: number;
|
|
62
|
+
exitCode?: number;
|
|
63
|
+
logPath: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class ComputerProcessDecodeError extends Error {
|
|
67
|
+
override readonly name = "ComputerProcessDecodeError";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
71
|
+
|
|
72
|
+
export function isComputerProcessIdV1(value: unknown): value is string {
|
|
73
|
+
return typeof value === "string" && IDENTIFIER.test(value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function computerProcessKeyV1(processId: string): string {
|
|
77
|
+
if (!isComputerProcessIdV1(processId)) {
|
|
78
|
+
throw new ComputerProcessDecodeError("Computer process id is invalid");
|
|
79
|
+
}
|
|
80
|
+
return `${COMPUTER_PROCESS_PREFIX}${processId}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
84
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
85
|
+
throw new ComputerProcessDecodeError(`${label} must be an object`);
|
|
86
|
+
}
|
|
87
|
+
return value as Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function exactKeys(
|
|
91
|
+
value: Record<string, unknown>,
|
|
92
|
+
required: readonly string[],
|
|
93
|
+
optional: readonly string[],
|
|
94
|
+
label: string,
|
|
95
|
+
): void {
|
|
96
|
+
const allowed = new Set([...required, ...optional]);
|
|
97
|
+
for (const key of Object.keys(value)) {
|
|
98
|
+
if (!allowed.has(key)) {
|
|
99
|
+
throw new ComputerProcessDecodeError(
|
|
100
|
+
`${label} has unknown field "${key}"`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const key of required) {
|
|
105
|
+
if (!Object.hasOwn(value, key)) {
|
|
106
|
+
throw new ComputerProcessDecodeError(`${label} is missing "${key}"`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function text(value: unknown, maximum: number, label: string): string {
|
|
112
|
+
if (typeof value !== "string") {
|
|
113
|
+
throw new ComputerProcessDecodeError(`${label} must be a string`);
|
|
114
|
+
}
|
|
115
|
+
if (value.length === 0) {
|
|
116
|
+
throw new ComputerProcessDecodeError(`${label} must not be empty`);
|
|
117
|
+
}
|
|
118
|
+
if (value.length > maximum) {
|
|
119
|
+
throw new ComputerProcessDecodeError(
|
|
120
|
+
`${label} must be at most ${maximum} characters`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function timestamp(value: unknown, label: string): string {
|
|
127
|
+
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
|
|
128
|
+
throw new ComputerProcessDecodeError(
|
|
129
|
+
`${label} must be an ISO-8601 timestamp`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function integer(value: unknown, label: string): number {
|
|
136
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
137
|
+
throw new ComputerProcessDecodeError(`${label} must be an integer`);
|
|
138
|
+
}
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function decodeComputerProcessRecordV1(
|
|
143
|
+
value: unknown,
|
|
144
|
+
): ComputerProcessRecordV1 {
|
|
145
|
+
const candidate = record(value, "Computer process record");
|
|
146
|
+
exactKeys(
|
|
147
|
+
candidate,
|
|
148
|
+
[
|
|
149
|
+
"schemaVersion",
|
|
150
|
+
"processId",
|
|
151
|
+
"botId",
|
|
152
|
+
"sessionId",
|
|
153
|
+
"turnId",
|
|
154
|
+
"command",
|
|
155
|
+
"cwd",
|
|
156
|
+
"startedAt",
|
|
157
|
+
"status",
|
|
158
|
+
"generation",
|
|
159
|
+
"effectId",
|
|
160
|
+
"logPath",
|
|
161
|
+
],
|
|
162
|
+
["pid", "exitCode"],
|
|
163
|
+
"Computer process record",
|
|
164
|
+
);
|
|
165
|
+
if (candidate.schemaVersion !== 1) {
|
|
166
|
+
throw new ComputerProcessDecodeError(
|
|
167
|
+
"Computer process record schemaVersion is unsupported",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (!isComputerProcessIdV1(candidate.processId)) {
|
|
171
|
+
throw new ComputerProcessDecodeError(
|
|
172
|
+
"Computer process record processId is invalid",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const status = COMPUTER_PROCESS_STATUSES.find(
|
|
176
|
+
(known) => known === candidate.status,
|
|
177
|
+
);
|
|
178
|
+
if (!status) {
|
|
179
|
+
throw new ComputerProcessDecodeError(
|
|
180
|
+
"Computer process record status is invalid",
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const generation = integer(
|
|
184
|
+
candidate.generation,
|
|
185
|
+
"Computer process record generation",
|
|
186
|
+
);
|
|
187
|
+
if (generation < 0) {
|
|
188
|
+
throw new ComputerProcessDecodeError(
|
|
189
|
+
"Computer process record generation must not be negative",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
schemaVersion: 1,
|
|
194
|
+
processId: candidate.processId,
|
|
195
|
+
botId: text(candidate.botId, 200, "Computer process record botId"),
|
|
196
|
+
sessionId: text(
|
|
197
|
+
candidate.sessionId,
|
|
198
|
+
256,
|
|
199
|
+
"Computer process record sessionId",
|
|
200
|
+
),
|
|
201
|
+
turnId: text(candidate.turnId, 256, "Computer process record turnId"),
|
|
202
|
+
command: text(
|
|
203
|
+
candidate.command,
|
|
204
|
+
COMPUTER_PROCESS_COMMAND_MAX,
|
|
205
|
+
"Computer process record command",
|
|
206
|
+
),
|
|
207
|
+
cwd: text(
|
|
208
|
+
candidate.cwd,
|
|
209
|
+
COMPUTER_PROCESS_PATH_MAX,
|
|
210
|
+
"Computer process record cwd",
|
|
211
|
+
),
|
|
212
|
+
startedAt: timestamp(
|
|
213
|
+
candidate.startedAt,
|
|
214
|
+
"Computer process record startedAt",
|
|
215
|
+
),
|
|
216
|
+
status,
|
|
217
|
+
generation,
|
|
218
|
+
effectId: text(candidate.effectId, 256, "Computer process record effectId"),
|
|
219
|
+
logPath: text(
|
|
220
|
+
candidate.logPath,
|
|
221
|
+
COMPUTER_PROCESS_PATH_MAX,
|
|
222
|
+
"Computer process record logPath",
|
|
223
|
+
),
|
|
224
|
+
...(candidate.pid === undefined
|
|
225
|
+
? {}
|
|
226
|
+
: { pid: integer(candidate.pid, "Computer process record pid") }),
|
|
227
|
+
...(candidate.exitCode === undefined
|
|
228
|
+
? {}
|
|
229
|
+
: {
|
|
230
|
+
exitCode: integer(
|
|
231
|
+
candidate.exitCode,
|
|
232
|
+
"Computer process record exitCode",
|
|
233
|
+
),
|
|
234
|
+
}),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The status a process holds, given what the Computer answered and the
|
|
240
|
+
* generation it answered under.
|
|
241
|
+
*
|
|
242
|
+
* The whole reconciliation rule in one place, so no caller can decide it
|
|
243
|
+
* differently: a Computer that has been reprovisioned since the launch cannot
|
|
244
|
+
* be holding the process, whatever its pid table says, and a pid that is gone
|
|
245
|
+
* with no recorded exit is `unknown` rather than finished.
|
|
246
|
+
*/
|
|
247
|
+
export function computerProcessStatusV1(input: {
|
|
248
|
+
recorded: ComputerProcessRecordV1;
|
|
249
|
+
currentGeneration: number;
|
|
250
|
+
observed: {
|
|
251
|
+
/** True when the Computer still holds a live process for the pid. */
|
|
252
|
+
alive: boolean;
|
|
253
|
+
/** The exit code the Computer recorded, when it recorded one. */
|
|
254
|
+
exitCode?: number;
|
|
255
|
+
};
|
|
256
|
+
}): { status: ComputerProcessStatusV1; exitCode?: number } {
|
|
257
|
+
const { recorded, currentGeneration, observed } = input;
|
|
258
|
+
if (recorded.status === "exited") {
|
|
259
|
+
return {
|
|
260
|
+
status: "exited",
|
|
261
|
+
...(recorded.exitCode === undefined
|
|
262
|
+
? {}
|
|
263
|
+
: { exitCode: recorded.exitCode }),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (currentGeneration !== recorded.generation) {
|
|
267
|
+
// A rebuilt Computer is a different Computer. An exit file written by the
|
|
268
|
+
// process before the rebuild is still evidence; a live pid is not, because
|
|
269
|
+
// the pid belongs to whatever is running there now.
|
|
270
|
+
return observed.exitCode === undefined
|
|
271
|
+
? { status: "unknown" }
|
|
272
|
+
: { status: "exited", exitCode: observed.exitCode };
|
|
273
|
+
}
|
|
274
|
+
if (observed.exitCode !== undefined) {
|
|
275
|
+
return { status: "exited", exitCode: observed.exitCode };
|
|
276
|
+
}
|
|
277
|
+
return observed.alive ? { status: "running" } : { status: "unknown" };
|
|
278
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The Bot Durable Object's background-process records.
|
|
2
|
+
//
|
|
3
|
+
// "The Bot's Durable Object is the authority for everything Bot-scoped … the
|
|
4
|
+
// append-only event log, the resumable execution cursor, idempotency records".
|
|
5
|
+
// A background process is Bot-scoped durable state of exactly that kind: it
|
|
6
|
+
// outlives its Turn, and after a Durable Object eviction the record is the only
|
|
7
|
+
// thing that knows the process was ever launched.
|
|
8
|
+
//
|
|
9
|
+
// A deep, small module: `record`, `read`, `update`, `list`. Every write goes
|
|
10
|
+
// through a decoder, so a value that reaches storage is a value the codec
|
|
11
|
+
// accepts, and a stored record the codec later refuses is a visible failure
|
|
12
|
+
// rather than a silently reshaped one.
|
|
13
|
+
import {
|
|
14
|
+
COMPUTER_PROCESS_LIMIT_PER_BOT,
|
|
15
|
+
COMPUTER_PROCESS_PREFIX,
|
|
16
|
+
computerProcessKeyV1,
|
|
17
|
+
decodeComputerProcessRecordV1,
|
|
18
|
+
ComputerProcessDecodeError,
|
|
19
|
+
type ComputerProcessRecordV1,
|
|
20
|
+
} from "./process-records.js";
|
|
21
|
+
|
|
22
|
+
/** The Durable Object storage seam. `DurableObjectStorage` satisfies it. */
|
|
23
|
+
export interface ComputerProcessStorageV1 {
|
|
24
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
25
|
+
put(key: string, value: unknown): Promise<void>;
|
|
26
|
+
delete(key: string): Promise<boolean>;
|
|
27
|
+
list<T>(options: { prefix: string; limit?: number }): Promise<Map<string, T>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ComputerProcessLimitError extends Error {
|
|
31
|
+
override readonly name = "ComputerProcessLimitError";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class ComputerProcessStore {
|
|
35
|
+
constructor(private readonly storage: ComputerProcessStorageV1) {}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Writes the intent to launch. It happens before anything runs on the
|
|
39
|
+
* Computer, so an interrupted launch leaves a record to reconcile rather
|
|
40
|
+
* than a process nothing remembers.
|
|
41
|
+
*/
|
|
42
|
+
async record(intent: ComputerProcessRecordV1): Promise<void> {
|
|
43
|
+
const decoded = decodeComputerProcessRecordV1(intent);
|
|
44
|
+
const held = await this.storage.list<unknown>({
|
|
45
|
+
prefix: COMPUTER_PROCESS_PREFIX,
|
|
46
|
+
limit: COMPUTER_PROCESS_LIMIT_PER_BOT + 1,
|
|
47
|
+
});
|
|
48
|
+
if (
|
|
49
|
+
held.size >= COMPUTER_PROCESS_LIMIT_PER_BOT &&
|
|
50
|
+
!held.has(computerProcessKeyV1(decoded.processId))
|
|
51
|
+
) {
|
|
52
|
+
throw new ComputerProcessLimitError(
|
|
53
|
+
`this Bot already holds ${COMPUTER_PROCESS_LIMIT_PER_BOT} background process records; end or forget one first`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
await this.storage.put(computerProcessKeyV1(decoded.processId), decoded);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async read(processId: string): Promise<ComputerProcessRecordV1 | undefined> {
|
|
60
|
+
const held = await this.storage.get<unknown>(
|
|
61
|
+
computerProcessKeyV1(processId),
|
|
62
|
+
);
|
|
63
|
+
if (held === undefined) return undefined;
|
|
64
|
+
return decodeComputerProcessRecordV1(held);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Replaces one record. The caller has already decided the new shape. */
|
|
68
|
+
async update(next: ComputerProcessRecordV1): Promise<void> {
|
|
69
|
+
const decoded = decodeComputerProcessRecordV1(next);
|
|
70
|
+
await this.storage.put(computerProcessKeyV1(decoded.processId), decoded);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every record this Bot holds, newest first. A stored value the codec
|
|
75
|
+
* refuses is dropped from the listing and left in storage: a listing is a
|
|
76
|
+
* projection, and losing a row is better than failing every read because one
|
|
77
|
+
* record is malformed.
|
|
78
|
+
*/
|
|
79
|
+
async list(): Promise<ComputerProcessRecordV1[]> {
|
|
80
|
+
const held = await this.storage.list<unknown>({
|
|
81
|
+
prefix: COMPUTER_PROCESS_PREFIX,
|
|
82
|
+
limit: COMPUTER_PROCESS_LIMIT_PER_BOT,
|
|
83
|
+
});
|
|
84
|
+
const records: ComputerProcessRecordV1[] = [];
|
|
85
|
+
for (const value of held.values()) {
|
|
86
|
+
try {
|
|
87
|
+
records.push(decodeComputerProcessRecordV1(value));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (!(error instanceof ComputerProcessDecodeError)) throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return records.sort((left, right) =>
|
|
93
|
+
right.startedAt.localeCompare(left.startedAt),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|