@frockbot/plugin-user-machine 0.0.0 → 0.1.0
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 +66 -0
- package/package.json +54 -6
- package/src/agent.test.ts +509 -0
- package/src/agent.ts +733 -0
- package/src/approval.test.ts +323 -0
- package/src/approval.ts +148 -0
- package/src/backend.test.ts +370 -0
- package/src/backend.ts +370 -0
- package/src/client/MachineSection.vue +97 -0
- package/src/client/MachineSurface.vue +308 -0
- package/src/client/index.test.ts +244 -0
- package/src/client/index.ts +180 -0
- package/src/client/state.ts +49 -0
- package/src/delivery.ts +145 -0
- package/src/desktop.test.ts +233 -0
- package/src/desktop.ts +238 -0
- package/src/device-runner.test.ts +326 -0
- package/src/device-runner.ts +205 -0
- package/src/device.test.ts +418 -0
- package/src/device.ts +707 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +13 -0
- package/src/intent.ts +325 -0
- package/src/manifest.ts +3 -0
- package/src/pairing.test.ts +85 -0
- package/src/pairing.ts +182 -0
- package/src/storage-keys.ts +102 -0
- package/src/store.test.ts +507 -0
- package/src/store.ts +638 -0
- package/src/target.ts +86 -0
- package/src/testing.ts +352 -0
- package/src/user.test.ts +182 -0
- package/src/user.ts +442 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/store.ts
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
// The registry, the queue, the lease and the results — as durable state.
|
|
2
|
+
//
|
|
3
|
+
// Every function here takes a storage seam and a clock and returns a decision.
|
|
4
|
+
// Nothing here reads a secret, mints a token, or answers an HTTP request: the
|
|
5
|
+
// gateway Contribution does the first two at the edge and the User backend
|
|
6
|
+
// Contribution owns the third, so the rules a machine's authority actually
|
|
7
|
+
// turns on can be exercised without either.
|
|
8
|
+
//
|
|
9
|
+
// Four invariants this module exists to hold:
|
|
10
|
+
//
|
|
11
|
+
// 1. **Presence is arithmetic.** `connected` is never stored. A laptop that
|
|
12
|
+
// stops polling goes offline on its own, and an evicted Durable Object has
|
|
13
|
+
// nothing to reconcile when it wakes.
|
|
14
|
+
// 2. **A claim is first-write-wins.** A duplicate delivery — a poll answered
|
|
15
|
+
// twice, an agent that retried — cannot run a command twice, because the
|
|
16
|
+
// second claim answers `already-claimed` and the agent stops.
|
|
17
|
+
// 3. **A lease expiry re-queues once, then terminates.** A machine that
|
|
18
|
+
// claimed a command and vanished gets the command offered again exactly
|
|
19
|
+
// once; the second expiry marks it `unknown`, which is the audit
|
|
20
|
+
// vocabulary's word for "nobody can say whether this ran".
|
|
21
|
+
// 4. **A result is idempotent on `commandId`.** `commandId` *is* the Bot
|
|
22
|
+
// Durable Object's `effectId`, so a replayed result answers `replayed` and
|
|
23
|
+
// changes nothing.
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
MACHINE_LIMITS_V1,
|
|
27
|
+
MachineDecodeError,
|
|
28
|
+
checkMachineQuotaV1,
|
|
29
|
+
decodeMachineCommandResultV1,
|
|
30
|
+
decodeMachineCommandV1,
|
|
31
|
+
decodeMachineRecordV1,
|
|
32
|
+
machineConnectedV1,
|
|
33
|
+
machineListEntryV1,
|
|
34
|
+
machineMessagesPermissionsFromResultV1,
|
|
35
|
+
machineQuotaRefusalV1,
|
|
36
|
+
machineOpCapabilityV1,
|
|
37
|
+
type MachineCapabilityV1,
|
|
38
|
+
type MachineClaimReceiptV1,
|
|
39
|
+
type MachineCommandResultV1,
|
|
40
|
+
type MachineCommandV1,
|
|
41
|
+
type MachineEnrollmentV1,
|
|
42
|
+
type MachineListViewV1,
|
|
43
|
+
type MachineRecordV1,
|
|
44
|
+
type MachineResultReceiptV1,
|
|
45
|
+
} from "@frockbot/machine-protocol";
|
|
46
|
+
import {
|
|
47
|
+
MACHINE_PREFIX,
|
|
48
|
+
machineKeyV1,
|
|
49
|
+
machinePairingKeyV1,
|
|
50
|
+
machineQueueKeyV1,
|
|
51
|
+
machineQueuePrefixV1,
|
|
52
|
+
machineRequeueKeyV1,
|
|
53
|
+
machineResultKeyV1,
|
|
54
|
+
machineUsageKeyV1,
|
|
55
|
+
nextMachineQueueSequenceV1,
|
|
56
|
+
} from "./storage-keys.js";
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// The storage seam
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
export interface MachineStorageWritesV1 {
|
|
63
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
64
|
+
list<T>(options: { prefix: string; limit?: number }): Promise<Map<string, T>>;
|
|
65
|
+
put(key: string, value: unknown): Promise<void>;
|
|
66
|
+
delete(key: string): Promise<boolean>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MachineStorageV1 extends MachineStorageWritesV1 {
|
|
70
|
+
transaction<T>(
|
|
71
|
+
closure: (transaction: MachineStorageWritesV1) => Promise<T>,
|
|
72
|
+
): Promise<T>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A refusal with the status the route should answer. */
|
|
76
|
+
export class MachineRegistryError extends Error {
|
|
77
|
+
override readonly name = "MachineRegistryError";
|
|
78
|
+
readonly status: number;
|
|
79
|
+
constructor(status: number, message: string) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.status = status;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Pairing
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One unspent pairing offer.
|
|
91
|
+
*
|
|
92
|
+
* The code itself is absent by construction: only `SHA-256(code)` is kept, so
|
|
93
|
+
* a dump of this object's storage hands nobody a machine.
|
|
94
|
+
*/
|
|
95
|
+
export interface MachinePairingRecordV1 {
|
|
96
|
+
schemaVersion: 1;
|
|
97
|
+
machineId: string;
|
|
98
|
+
userId: string;
|
|
99
|
+
label?: string;
|
|
100
|
+
codeDigest: string;
|
|
101
|
+
createdAt: string;
|
|
102
|
+
expiresAt: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function iso(now: number | Date): string {
|
|
106
|
+
return new Date(now).toISOString();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function writeMachinePairingV1(
|
|
110
|
+
storage: MachineStorageV1,
|
|
111
|
+
input: {
|
|
112
|
+
userId: string;
|
|
113
|
+
machineId: string;
|
|
114
|
+
label?: string;
|
|
115
|
+
codeDigest: string;
|
|
116
|
+
now: number | Date;
|
|
117
|
+
ttlMs?: number;
|
|
118
|
+
},
|
|
119
|
+
): Promise<MachinePairingRecordV1> {
|
|
120
|
+
const ttl = input.ttlMs ?? MACHINE_LIMITS_V1.pairingTtlMs;
|
|
121
|
+
const record: MachinePairingRecordV1 = {
|
|
122
|
+
schemaVersion: 1,
|
|
123
|
+
machineId: input.machineId,
|
|
124
|
+
userId: input.userId,
|
|
125
|
+
...(input.label === undefined ? {} : { label: input.label }),
|
|
126
|
+
codeDigest: input.codeDigest,
|
|
127
|
+
createdAt: iso(input.now),
|
|
128
|
+
expiresAt: iso(new Date(input.now).getTime() + ttl),
|
|
129
|
+
};
|
|
130
|
+
await storage.put(machinePairingKeyV1(input.machineId), record);
|
|
131
|
+
return record;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function readMachinePairingV1(
|
|
135
|
+
storage: MachineStorageWritesV1,
|
|
136
|
+
machineId: string,
|
|
137
|
+
): Promise<MachinePairingRecordV1 | undefined> {
|
|
138
|
+
return storage.get<MachinePairingRecordV1>(machinePairingKeyV1(machineId));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// The registry
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
export async function listMachineRecordsV1(
|
|
146
|
+
storage: MachineStorageWritesV1,
|
|
147
|
+
): Promise<MachineRecordV1[]> {
|
|
148
|
+
const stored = await storage.list<unknown>({
|
|
149
|
+
prefix: MACHINE_PREFIX,
|
|
150
|
+
limit: MACHINE_LIMITS_V1.maxMachinesPerUser * 4,
|
|
151
|
+
});
|
|
152
|
+
const records: MachineRecordV1[] = [];
|
|
153
|
+
for (const [key, value] of stored) {
|
|
154
|
+
// `machine-queue:` and friends share no prefix with `machine:`… except
|
|
155
|
+
// that `machine:` is a prefix of nothing else only because every other key
|
|
156
|
+
// uses a hyphen. Decoding is the check that says so out loud.
|
|
157
|
+
if (!key.startsWith(MACHINE_PREFIX)) continue;
|
|
158
|
+
try {
|
|
159
|
+
records.push(decodeMachineRecordV1(value, "stored machine record"));
|
|
160
|
+
} catch {
|
|
161
|
+
// A record this build cannot decode is a row the registry does not
|
|
162
|
+
// show, never a reason the whole registry fails to answer.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return records.sort((left, right) =>
|
|
166
|
+
left.registeredAt.localeCompare(right.registeredAt),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function readMachineRecordV1(
|
|
171
|
+
storage: MachineStorageWritesV1,
|
|
172
|
+
machineId: string,
|
|
173
|
+
): Promise<MachineRecordV1 | undefined> {
|
|
174
|
+
const stored = await storage.get<unknown>(machineKeyV1(machineId));
|
|
175
|
+
if (stored === undefined) return undefined;
|
|
176
|
+
return decodeMachineRecordV1(stored, "stored machine record");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function machineListViewV1(
|
|
180
|
+
records: readonly MachineRecordV1[],
|
|
181
|
+
now: number | Date,
|
|
182
|
+
): MachineListViewV1 {
|
|
183
|
+
return {
|
|
184
|
+
schemaVersion: 1,
|
|
185
|
+
machines: records.map((record) => machineListEntryV1(record, now)),
|
|
186
|
+
serverTime: iso(now),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Enrollment: the pairing offer is spent and the registry row is written, in
|
|
192
|
+
* one transaction.
|
|
193
|
+
*
|
|
194
|
+
* "Admit input durably before acknowledging" — the machine is registered
|
|
195
|
+
* before the token it will present is handed back, so an agent that reads the
|
|
196
|
+
* response is holding a key to a record that already exists.
|
|
197
|
+
*/
|
|
198
|
+
export async function enrollMachineV1(
|
|
199
|
+
storage: MachineStorageV1,
|
|
200
|
+
input: {
|
|
201
|
+
userId: string;
|
|
202
|
+
machineId: string;
|
|
203
|
+
enrollment: MachineEnrollmentV1;
|
|
204
|
+
codeDigest: string;
|
|
205
|
+
tokenDigest: string;
|
|
206
|
+
now: number | Date;
|
|
207
|
+
},
|
|
208
|
+
): Promise<MachineRecordV1> {
|
|
209
|
+
return storage.transaction(async (transaction) => {
|
|
210
|
+
const pairing = await readMachinePairingV1(transaction, input.machineId);
|
|
211
|
+
// Missing, spent, expired, for another User, or for another code: one
|
|
212
|
+
// answer, because telling them apart tells a prober which it was.
|
|
213
|
+
if (
|
|
214
|
+
!pairing ||
|
|
215
|
+
pairing.userId !== input.userId ||
|
|
216
|
+
pairing.codeDigest !== input.codeDigest ||
|
|
217
|
+
Date.parse(pairing.expiresAt) <= new Date(input.now).getTime()
|
|
218
|
+
) {
|
|
219
|
+
throw new MachineRegistryError(
|
|
220
|
+
401,
|
|
221
|
+
"machine pairing code is invalid or has expired",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const registered = await listMachineRecordsV1(transaction);
|
|
225
|
+
const quota = checkMachineQuotaV1({
|
|
226
|
+
kind: "register",
|
|
227
|
+
registeredMachines: registered.filter(
|
|
228
|
+
(record) => record.revokedAt === undefined,
|
|
229
|
+
).length,
|
|
230
|
+
});
|
|
231
|
+
if (quota.status === "refused") {
|
|
232
|
+
throw new MachineRegistryError(429, machineQuotaRefusalV1(quota));
|
|
233
|
+
}
|
|
234
|
+
const record: MachineRecordV1 = decodeMachineRecordV1(
|
|
235
|
+
{
|
|
236
|
+
schemaVersion: 1,
|
|
237
|
+
machineId: input.machineId,
|
|
238
|
+
userId: input.userId,
|
|
239
|
+
label: input.enrollment.label,
|
|
240
|
+
platform: input.enrollment.platform,
|
|
241
|
+
agentVersion: input.enrollment.agentVersion,
|
|
242
|
+
capabilities: input.enrollment.capabilities,
|
|
243
|
+
registeredAt: iso(input.now),
|
|
244
|
+
lastSeenAt: iso(input.now),
|
|
245
|
+
keyVersion: 1,
|
|
246
|
+
tokenDigest: input.tokenDigest,
|
|
247
|
+
},
|
|
248
|
+
"machine record",
|
|
249
|
+
);
|
|
250
|
+
await transaction.put(machineKeyV1(record.machineId), record);
|
|
251
|
+
// One-time: the offer is gone whether or not the agent ever polls.
|
|
252
|
+
await transaction.delete(machinePairingKeyV1(input.machineId));
|
|
253
|
+
return record;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Every issued token dies, the queue is purged, and the row stays as evidence. */
|
|
258
|
+
export async function revokeMachineV1(
|
|
259
|
+
storage: MachineStorageV1,
|
|
260
|
+
machineId: string,
|
|
261
|
+
now: number | Date,
|
|
262
|
+
): Promise<MachineRecordV1> {
|
|
263
|
+
return storage.transaction(async (transaction) => {
|
|
264
|
+
const record = await readMachineRecordV1(transaction, machineId);
|
|
265
|
+
if (!record) {
|
|
266
|
+
throw new MachineRegistryError(404, "machine was not found");
|
|
267
|
+
}
|
|
268
|
+
const revoked: MachineRecordV1 = {
|
|
269
|
+
...record,
|
|
270
|
+
keyVersion: record.keyVersion + 1,
|
|
271
|
+
revokedAt: record.revokedAt ?? iso(now),
|
|
272
|
+
};
|
|
273
|
+
await transaction.put(machineKeyV1(machineId), revoked);
|
|
274
|
+
const queued = await transaction.list<unknown>({
|
|
275
|
+
prefix: machineQueuePrefixV1(machineId),
|
|
276
|
+
});
|
|
277
|
+
for (const key of queued.keys()) await transaction.delete(key);
|
|
278
|
+
return revoked;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Every poll refreshes presence. This is the whole of how `connected` is fed. */
|
|
283
|
+
export async function touchMachineV1(
|
|
284
|
+
storage: MachineStorageV1,
|
|
285
|
+
machineId: string,
|
|
286
|
+
now: number | Date,
|
|
287
|
+
): Promise<MachineRecordV1> {
|
|
288
|
+
const record = await readMachineRecordV1(storage, machineId);
|
|
289
|
+
if (!record) throw new MachineRegistryError(404, "machine was not found");
|
|
290
|
+
const touched: MachineRecordV1 = { ...record, lastSeenAt: iso(now) };
|
|
291
|
+
await storage.put(machineKeyV1(machineId), touched);
|
|
292
|
+
return touched;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// The queue
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
interface StoredCommandV1 {
|
|
300
|
+
key: string;
|
|
301
|
+
command: MachineCommandV1;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function readQueueV1(
|
|
305
|
+
storage: MachineStorageWritesV1,
|
|
306
|
+
machineId: string,
|
|
307
|
+
): Promise<StoredCommandV1[]> {
|
|
308
|
+
const stored = await storage.list<unknown>({
|
|
309
|
+
prefix: machineQueuePrefixV1(machineId),
|
|
310
|
+
limit: MACHINE_LIMITS_V1.maxQueue * 4,
|
|
311
|
+
});
|
|
312
|
+
const commands: StoredCommandV1[] = [];
|
|
313
|
+
for (const [key, value] of stored) {
|
|
314
|
+
try {
|
|
315
|
+
commands.push({
|
|
316
|
+
key,
|
|
317
|
+
command: decodeMachineCommandV1(value, "stored machine command"),
|
|
318
|
+
});
|
|
319
|
+
} catch {
|
|
320
|
+
// Same rule as the registry: an undecodable row is not delivered and is
|
|
321
|
+
// not a reason a poll fails.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return commands;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export type MachineDispatchOutcomeV1 =
|
|
328
|
+
| { status: "queued"; command: MachineCommandV1 }
|
|
329
|
+
| { status: "duplicate"; command: MachineCommandV1 }
|
|
330
|
+
| { status: "refused"; reason: string };
|
|
331
|
+
|
|
332
|
+
async function readUsageV1(
|
|
333
|
+
storage: MachineStorageWritesV1,
|
|
334
|
+
now: number | Date,
|
|
335
|
+
): Promise<number> {
|
|
336
|
+
const stored = await storage.get<{ schemaVersion: 1; count: number }>(
|
|
337
|
+
machineUsageKeyV1(now),
|
|
338
|
+
);
|
|
339
|
+
return Number.isSafeInteger(stored?.count) ? stored!.count : 0;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Put one approved command on a machine's queue.
|
|
344
|
+
*
|
|
345
|
+
* Idempotent on `commandId`: a re-dispatch of the same `effectId` — a retried
|
|
346
|
+
* settlement, a replayed decision — finds the command already queued and
|
|
347
|
+
* answers `duplicate` rather than queueing a second copy of somebody's laptop
|
|
348
|
+
* running `rm`.
|
|
349
|
+
*/
|
|
350
|
+
export async function dispatchMachineCommandV1(
|
|
351
|
+
storage: MachineStorageV1,
|
|
352
|
+
input: unknown,
|
|
353
|
+
now: number | Date,
|
|
354
|
+
): Promise<MachineDispatchOutcomeV1> {
|
|
355
|
+
const command = decodeMachineCommandV1(input, "machine command");
|
|
356
|
+
if (command.status !== "queued") {
|
|
357
|
+
throw new MachineDecodeError("a dispatched machine command must be queued");
|
|
358
|
+
}
|
|
359
|
+
return storage.transaction(async (transaction) => {
|
|
360
|
+
const record = await readMachineRecordV1(transaction, command.machineId);
|
|
361
|
+
if (!record || record.revokedAt !== undefined) {
|
|
362
|
+
return {
|
|
363
|
+
status: "refused",
|
|
364
|
+
reason: "Refused: this machine is not registered.",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const needed: MachineCapabilityV1 = machineOpCapabilityV1(command.op);
|
|
368
|
+
if (!record.capabilities.includes(needed)) {
|
|
369
|
+
return {
|
|
370
|
+
status: "refused",
|
|
371
|
+
reason: `Refused: this machine does not report the ${needed} capability.`,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const queue = await readQueueV1(transaction, command.machineId);
|
|
375
|
+
const existing = queue.find(
|
|
376
|
+
(entry) => entry.command.commandId === command.commandId,
|
|
377
|
+
);
|
|
378
|
+
if (existing) return { status: "duplicate", command: existing.command };
|
|
379
|
+
// A command that already has a result is terminal; a replayed dispatch of
|
|
380
|
+
// it is a duplicate, not a second run.
|
|
381
|
+
const settled = await transaction.get<unknown>(
|
|
382
|
+
machineResultKeyV1(command.commandId),
|
|
383
|
+
);
|
|
384
|
+
if (settled !== undefined) return { status: "duplicate", command };
|
|
385
|
+
const commandsToday = await readUsageV1(transaction, now);
|
|
386
|
+
const quota = checkMachineQuotaV1({
|
|
387
|
+
kind: "dispatch",
|
|
388
|
+
queuedCommands: queue.length,
|
|
389
|
+
commandsToday,
|
|
390
|
+
});
|
|
391
|
+
if (quota.status === "refused") {
|
|
392
|
+
return { status: "refused", reason: machineQuotaRefusalV1(quota) };
|
|
393
|
+
}
|
|
394
|
+
const seq = nextMachineQueueSequenceV1([
|
|
395
|
+
...queue.map((entry) => entry.key),
|
|
396
|
+
]);
|
|
397
|
+
await transaction.put(machineQueueKeyV1(command.machineId, seq), command);
|
|
398
|
+
await transaction.put(machineUsageKeyV1(now), {
|
|
399
|
+
schemaVersion: 1,
|
|
400
|
+
count: commandsToday + 1,
|
|
401
|
+
});
|
|
402
|
+
return { status: "queued", command };
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Expire the leases a vanished agent left behind.
|
|
408
|
+
*
|
|
409
|
+
* Run before every poll and every claim, so the sweep needs no alarm of its
|
|
410
|
+
* own: the only party who can be harmed by a stuck lease is the machine whose
|
|
411
|
+
* queue it is on, and that machine is the one asking.
|
|
412
|
+
*/
|
|
413
|
+
export async function sweepMachineLeasesV1(
|
|
414
|
+
storage: MachineStorageV1,
|
|
415
|
+
machineId: string,
|
|
416
|
+
now: number | Date,
|
|
417
|
+
): Promise<{ requeued: string[]; terminated: string[] }> {
|
|
418
|
+
const at = new Date(now).getTime();
|
|
419
|
+
return storage.transaction(async (transaction) => {
|
|
420
|
+
const queue = await readQueueV1(transaction, machineId);
|
|
421
|
+
const requeued: string[] = [];
|
|
422
|
+
const terminated: string[] = [];
|
|
423
|
+
for (const entry of queue) {
|
|
424
|
+
const command = entry.command;
|
|
425
|
+
if (command.status !== "claimed") continue;
|
|
426
|
+
if (
|
|
427
|
+
command.leaseExpiresAt === undefined ||
|
|
428
|
+
Date.parse(command.leaseExpiresAt) > at
|
|
429
|
+
) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
// One re-queue, then `unknown`: recovery never silently duplicates, and
|
|
433
|
+
// it never loops. The count is the backend's own bookkeeping, held
|
|
434
|
+
// beside the command rather than on it — see `MACHINE_REQUEUE_PREFIX`.
|
|
435
|
+
const attempts =
|
|
436
|
+
(
|
|
437
|
+
await transaction.get<{ schemaVersion: 1; count: number }>(
|
|
438
|
+
machineRequeueKeyV1(command.commandId),
|
|
439
|
+
)
|
|
440
|
+
)?.count ?? 0;
|
|
441
|
+
if (attempts < 1) {
|
|
442
|
+
const { leaseExpiresAt: _expired, ...rest } = command;
|
|
443
|
+
await transaction.put(entry.key, {
|
|
444
|
+
...rest,
|
|
445
|
+
status: "queued",
|
|
446
|
+
} satisfies MachineCommandV1);
|
|
447
|
+
await transaction.put(machineRequeueKeyV1(command.commandId), {
|
|
448
|
+
schemaVersion: 1,
|
|
449
|
+
count: attempts + 1,
|
|
450
|
+
});
|
|
451
|
+
requeued.push(command.commandId);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const { leaseExpiresAt: _lease, ...rest } = command;
|
|
455
|
+
await transaction.put(entry.key, {
|
|
456
|
+
...rest,
|
|
457
|
+
status: "unknown",
|
|
458
|
+
} satisfies MachineCommandV1);
|
|
459
|
+
// The terminal fact is durable, and it is the *audit* answer: nobody can
|
|
460
|
+
// say whether this ran on the User's laptop.
|
|
461
|
+
await transaction.put(machineResultKeyV1(command.commandId), {
|
|
462
|
+
schemaVersion: 1,
|
|
463
|
+
commandId: command.commandId,
|
|
464
|
+
finishedAt: iso(now),
|
|
465
|
+
outcome: "error",
|
|
466
|
+
truncated: false,
|
|
467
|
+
message:
|
|
468
|
+
"the machine claimed this command and never reported a result; its outcome is unknown",
|
|
469
|
+
} satisfies MachineCommandResultV1);
|
|
470
|
+
await transaction.delete(machineRequeueKeyV1(command.commandId));
|
|
471
|
+
terminated.push(command.commandId);
|
|
472
|
+
}
|
|
473
|
+
return { requeued, terminated };
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** What a poll answers with: every command still waiting for this machine. */
|
|
478
|
+
export async function pendingMachineCommandsV1(
|
|
479
|
+
storage: MachineStorageWritesV1,
|
|
480
|
+
machineId: string,
|
|
481
|
+
): Promise<MachineCommandV1[]> {
|
|
482
|
+
const queue = await readQueueV1(storage, machineId);
|
|
483
|
+
return queue
|
|
484
|
+
.filter((entry) => entry.command.status === "queued")
|
|
485
|
+
.map((entry) => entry.command);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* A claim, first-write-wins.
|
|
490
|
+
*
|
|
491
|
+
* The second claim of a command answers `already-claimed` with the lease the
|
|
492
|
+
* first claim took, which is the whole reason a duplicate delivery can never
|
|
493
|
+
* run twice on somebody's laptop.
|
|
494
|
+
*/
|
|
495
|
+
export async function claimMachineCommandV1(
|
|
496
|
+
storage: MachineStorageV1,
|
|
497
|
+
machineId: string,
|
|
498
|
+
commandId: string,
|
|
499
|
+
now: number | Date,
|
|
500
|
+
): Promise<MachineClaimReceiptV1> {
|
|
501
|
+
return storage.transaction(async (transaction) => {
|
|
502
|
+
const queue = await readQueueV1(transaction, machineId);
|
|
503
|
+
const entry = queue.find(
|
|
504
|
+
(candidate) => candidate.command.commandId === commandId,
|
|
505
|
+
);
|
|
506
|
+
if (!entry) {
|
|
507
|
+
throw new MachineRegistryError(404, "machine command was not found");
|
|
508
|
+
}
|
|
509
|
+
const command = entry.command;
|
|
510
|
+
if (command.status !== "queued") {
|
|
511
|
+
return {
|
|
512
|
+
schemaVersion: 1,
|
|
513
|
+
status: "already-claimed",
|
|
514
|
+
commandId,
|
|
515
|
+
leaseExpiresAt: command.leaseExpiresAt ?? iso(new Date(now).getTime()),
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const leaseExpiresAt = iso(
|
|
519
|
+
new Date(now).getTime() + MACHINE_LIMITS_V1.leaseMs,
|
|
520
|
+
);
|
|
521
|
+
await transaction.put(entry.key, {
|
|
522
|
+
...command,
|
|
523
|
+
status: "claimed",
|
|
524
|
+
claimedAt: command.claimedAt ?? iso(now),
|
|
525
|
+
leaseExpiresAt,
|
|
526
|
+
} satisfies MachineCommandV1);
|
|
527
|
+
return { schemaVersion: 1, status: "claimed", commandId, leaseExpiresAt };
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* One result, recorded once.
|
|
533
|
+
*
|
|
534
|
+
* The command leaves the queue and the result becomes the durable answer. A
|
|
535
|
+
* second POST of the same `commandId` answers `replayed` and changes nothing —
|
|
536
|
+
* which is what makes the agent's own retry safe.
|
|
537
|
+
*/
|
|
538
|
+
export async function recordMachineResultV1(
|
|
539
|
+
storage: MachineStorageV1,
|
|
540
|
+
machineId: string,
|
|
541
|
+
input: unknown,
|
|
542
|
+
now: number | Date,
|
|
543
|
+
): Promise<{
|
|
544
|
+
receipt: MachineResultReceiptV1;
|
|
545
|
+
result: MachineCommandResultV1;
|
|
546
|
+
/**
|
|
547
|
+
* The command the result answers, present only on the write that recorded
|
|
548
|
+
* it. The queue entry is deleted in the same transaction, so this is the one
|
|
549
|
+
* moment the Bot that asked is still nameable — and a replay must not deliver
|
|
550
|
+
* a second time, which is exactly why it is absent on one.
|
|
551
|
+
*/
|
|
552
|
+
command?: MachineCommandV1;
|
|
553
|
+
}> {
|
|
554
|
+
const result = decodeMachineCommandResultV1(input, "machine command result");
|
|
555
|
+
return storage.transaction(async (transaction) => {
|
|
556
|
+
const existing = await transaction.get<unknown>(
|
|
557
|
+
machineResultKeyV1(result.commandId),
|
|
558
|
+
);
|
|
559
|
+
if (existing !== undefined) {
|
|
560
|
+
return {
|
|
561
|
+
receipt: {
|
|
562
|
+
schemaVersion: 1,
|
|
563
|
+
status: "replayed",
|
|
564
|
+
commandId: result.commandId,
|
|
565
|
+
},
|
|
566
|
+
result: decodeMachineCommandResultV1(existing, "stored machine result"),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const queue = await readQueueV1(transaction, machineId);
|
|
570
|
+
const entry = queue.find(
|
|
571
|
+
(candidate) => candidate.command.commandId === result.commandId,
|
|
572
|
+
);
|
|
573
|
+
if (!entry) {
|
|
574
|
+
throw new MachineRegistryError(404, "machine command was not found");
|
|
575
|
+
}
|
|
576
|
+
await transaction.put(machineResultKeyV1(result.commandId), result);
|
|
577
|
+
await transaction.delete(entry.key);
|
|
578
|
+
await transaction.delete(machineRequeueKeyV1(result.commandId));
|
|
579
|
+
// Row 57g's third gate is a fact the *machine* reports, and this is the one
|
|
580
|
+
// moment it arrives: a permission check that answered `ok` updates the
|
|
581
|
+
// registry row, and every other result leaves it exactly as it was. The
|
|
582
|
+
// rule is the protocol's and pure, so the registry cannot start believing
|
|
583
|
+
// something the agent did not say.
|
|
584
|
+
const reported = machineMessagesPermissionsFromResultV1(
|
|
585
|
+
entry.command.op,
|
|
586
|
+
result,
|
|
587
|
+
);
|
|
588
|
+
if (reported) {
|
|
589
|
+
const record = await readMachineRecordV1(transaction, machineId);
|
|
590
|
+
if (record) {
|
|
591
|
+
await transaction.put(machineKeyV1(machineId), {
|
|
592
|
+
...record,
|
|
593
|
+
messagesPermissions: reported,
|
|
594
|
+
} satisfies MachineRecordV1);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return {
|
|
598
|
+
receipt: {
|
|
599
|
+
schemaVersion: 1,
|
|
600
|
+
status: "recorded",
|
|
601
|
+
commandId: result.commandId,
|
|
602
|
+
},
|
|
603
|
+
result,
|
|
604
|
+
command: entry.command,
|
|
605
|
+
};
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The two counters a dispatch quota is arithmetic over, for one machine.
|
|
611
|
+
*
|
|
612
|
+
* Read by the tool *before* it asks a person anything: a card the User approves
|
|
613
|
+
* and the queue then refuses is a question that wasted their attention, so the
|
|
614
|
+
* refusal happens where the Bot can still say something useful about it.
|
|
615
|
+
*/
|
|
616
|
+
export async function machineQuotaSnapshotV1(
|
|
617
|
+
storage: MachineStorageWritesV1,
|
|
618
|
+
machineId: string,
|
|
619
|
+
now: number | Date,
|
|
620
|
+
): Promise<{ queuedCommands: number; commandsToday: number }> {
|
|
621
|
+
const queue = await readQueueV1(storage, machineId);
|
|
622
|
+
return {
|
|
623
|
+
queuedCommands: queue.length,
|
|
624
|
+
commandsToday: await readUsageV1(storage, now),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export async function readMachineResultV1(
|
|
629
|
+
storage: MachineStorageWritesV1,
|
|
630
|
+
commandId: string,
|
|
631
|
+
): Promise<MachineCommandResultV1 | undefined> {
|
|
632
|
+
const stored = await storage.get<unknown>(machineResultKeyV1(commandId));
|
|
633
|
+
if (stored === undefined) return undefined;
|
|
634
|
+
return decodeMachineCommandResultV1(stored, "stored machine result");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Re-exported so a caller reads presence from one place. */
|
|
638
|
+
export { machineConnectedV1 };
|