@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/src/device.ts ADDED
@@ -0,0 +1,707 @@
1
+ // The device agent's loop, with nothing native in it.
2
+ //
3
+ // This is the real agent — the one the Electron shell runs — and it is written
4
+ // here rather than in `src/desktop.ts` for one reason: everything that decides
5
+ // *what happens* must run in CI. So the loop holds no `child_process`, no
6
+ // `node:fs`, no `electron` and no ambient clock. It is handed four seams:
7
+ //
8
+ // fetch how a request leaves the laptop
9
+ // secrets where the machine token rests between runs (the OS keychain)
10
+ // runner what actually executes an op (the only untested surface)
11
+ // clock `now` and `sleep`, so backoff is asserted rather than waited on
12
+ //
13
+ // `MachineAgentDriverV1` in `./testing.ts` is the *stub* agent: it speaks the
14
+ // same wire but scripts its answers. This is the shipped one. They are checked
15
+ // against each other in `apps/cloudflare/test/machines-desktop.workerd.ts`,
16
+ // which is what "byte-identical behaviour to the stub" means in the plan.
17
+ //
18
+ // Two behaviours are worth naming because they are policy, not plumbing:
19
+ //
20
+ // 1. **A 401 un-enrols.** Revocation bumps `keyVersion`, so a revoked token
21
+ // fails every route forever. An agent that kept retrying it would poll a
22
+ // door that will never open again and would keep a dead secret on disk.
23
+ // The stored token is cleared and the loop stops.
24
+ // 2. **A command is claimed before it is run and answered after.** A claim
25
+ // that loses the race answers `already-claimed` and the agent does not
26
+ // run it: first claim wins is the protocol's guarantee against a duplicate
27
+ // delivery running twice, and the agent is the half that honours it.
28
+
29
+ import {
30
+ MACHINE_LIMITS_V1,
31
+ MachineDecodeError,
32
+ decodeMachineClaimReceiptV1,
33
+ decodeMachineEnrollmentReceiptV1,
34
+ decodeMachineIdV1,
35
+ decodeMachinePollResultV1,
36
+ decodeMachineResultReceiptV1,
37
+ machineRoutePathV1,
38
+ type MachineCapabilityV1,
39
+ type MachineCommandResultV1,
40
+ type MachineCommandV1,
41
+ type MachinePlatformV1,
42
+ } from "@frockbot/machine-protocol";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // What the agent remembers between runs
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * The whole of the agent's durable state: which machine it is and the token
50
+ * that proves it. It is written to the OS secure store and nowhere else — not
51
+ * to a log, not to a preference file, and never back over the wire.
52
+ */
53
+ export interface MachineEnrollmentStateV1 {
54
+ schemaVersion: 1;
55
+ machineId: string;
56
+ token: string;
57
+ /** The origin this token was minted by. A token is not portable. */
58
+ origin: string;
59
+ label: string;
60
+ enrolledAt: string;
61
+ }
62
+
63
+ function text(value: unknown, max: number, label: string): string {
64
+ if (typeof value !== "string" || value.length === 0) {
65
+ throw new MachineDecodeError(`${label} must be a non-empty string`);
66
+ }
67
+ if (value.length > max) {
68
+ throw new MachineDecodeError(
69
+ `${label} exceeds ${max} characters`,
70
+ "limit-exceeded",
71
+ );
72
+ }
73
+ return value;
74
+ }
75
+
76
+ function origin(value: unknown, label: string): string {
77
+ const raw = text(value, 2_048, label);
78
+ let url: URL;
79
+ try {
80
+ url = new URL(raw);
81
+ } catch {
82
+ throw new MachineDecodeError(`${label} must be a URL`);
83
+ }
84
+ if (
85
+ url.origin !== raw ||
86
+ (url.protocol !== "https:" && url.protocol !== "http:")
87
+ ) {
88
+ throw new MachineDecodeError(`${label} must be an http(s) origin`);
89
+ }
90
+ return url.origin;
91
+ }
92
+
93
+ export function decodeMachineEnrollmentStateV1(
94
+ input: unknown,
95
+ label = "machine enrollment state",
96
+ ): MachineEnrollmentStateV1 {
97
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
98
+ throw new MachineDecodeError(`${label} must be an object`);
99
+ }
100
+ const value = input as Record<string, unknown>;
101
+ const allowed = [
102
+ "schemaVersion",
103
+ "machineId",
104
+ "token",
105
+ "origin",
106
+ "label",
107
+ "enrolledAt",
108
+ ];
109
+ for (const key of Reflect.ownKeys(value)) {
110
+ if (typeof key !== "string" || !allowed.includes(key)) {
111
+ throw new MachineDecodeError(`${label} has an unexpected key`);
112
+ }
113
+ }
114
+ if (value.schemaVersion !== 1) {
115
+ throw new MachineDecodeError(`${label} schemaVersion must be 1`);
116
+ }
117
+ const enrolledAt = text(value.enrolledAt, 64, `${label} enrolledAt`);
118
+ if (Number.isNaN(Date.parse(enrolledAt))) {
119
+ throw new MachineDecodeError(`${label} enrolledAt must be a timestamp`);
120
+ }
121
+ return {
122
+ schemaVersion: 1,
123
+ machineId: decodeMachineIdV1(value.machineId, `${label} machineId`),
124
+ token: text(value.token, 2_048, `${label} token`),
125
+ origin: origin(value.origin, `${label} origin`),
126
+ label: text(value.label, MACHINE_LIMITS_V1.label, `${label} label`),
127
+ enrolledAt,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * The OS secure store, as one string.
133
+ *
134
+ * A seam and not `safeStorage` directly, because the loop above must run in
135
+ * CI and because a keychain that is unavailable (a headless Linux session, a
136
+ * locked login keychain) is a *state the agent has to survive*, not a crash.
137
+ */
138
+ export interface MachineSecretStoreV1 {
139
+ read(): Promise<string | undefined>;
140
+ write(value: string): Promise<void>;
141
+ clear(): Promise<void>;
142
+ }
143
+
144
+ /** A secret store that forgets on exit. Used by tests and by nothing else. */
145
+ export function createMemoryMachineSecretStoreV1(
146
+ initial?: string,
147
+ ): MachineSecretStoreV1 {
148
+ let held = initial;
149
+ return {
150
+ read: () => Promise.resolve(held),
151
+ write: (value) => {
152
+ held = value;
153
+ return Promise.resolve();
154
+ },
155
+ clear: () => {
156
+ held = undefined;
157
+ return Promise.resolve();
158
+ },
159
+ };
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // What runs an op
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /** A result as the machine reports it: the wire DTO minus what the caller adds. */
167
+ export type MachineCommandReportV1 = Omit<
168
+ MachineCommandResultV1,
169
+ "schemaVersion" | "commandId"
170
+ >;
171
+
172
+ /**
173
+ * The one seam that touches the laptop. `src/desktop.ts` implements it with
174
+ * `child_process` and `node:fs`; `src/device-runner.ts` holds every decision
175
+ * either of them would otherwise make.
176
+ */
177
+ export interface MachineCommandRunnerV1 {
178
+ run(
179
+ command: MachineCommandV1,
180
+ signal: AbortSignal,
181
+ ): Promise<MachineCommandReportV1>;
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Backoff
186
+ // ---------------------------------------------------------------------------
187
+
188
+ /** The first retry delay, and the ceiling every later one is clamped to. */
189
+ export const MACHINE_AGENT_BACKOFF_V1 = {
190
+ baseMs: 1_000,
191
+ maxMs: 60_000,
192
+ /** ± this fraction of the delay, so a fleet does not retry in lockstep. */
193
+ jitter: 0.2,
194
+ } as const;
195
+
196
+ /**
197
+ * How long the loop rests when this laptop is not paired.
198
+ *
199
+ * Not a backoff — nothing failed. An unpaired agent has no request to make, so
200
+ * without this the loop would spin on an early return; with it, pairing is
201
+ * picked up within a few seconds without the shell having to restart anything.
202
+ */
203
+ export const MACHINE_AGENT_IDLE_MS_V1 = 5_000;
204
+
205
+ /**
206
+ * How long to wait before the next poll after `failures` consecutive failures.
207
+ *
208
+ * Pure, and jittered from an injected `random`, so a test asserts the exact
209
+ * number rather than a range. `failures` of 0 is "the last poll worked": the
210
+ * agent does not sleep at all, because the long poll is its own pacing.
211
+ */
212
+ export function machinePollBackoffV1(
213
+ failures: number,
214
+ random: () => number = Math.random,
215
+ bounds: {
216
+ baseMs: number;
217
+ maxMs: number;
218
+ jitter: number;
219
+ } = MACHINE_AGENT_BACKOFF_V1,
220
+ ): number {
221
+ if (failures <= 0) return 0;
222
+ const exponential = bounds.baseMs * 2 ** Math.min(failures - 1, 16);
223
+ const clamped = Math.min(exponential, bounds.maxMs);
224
+ const spread = clamped * bounds.jitter;
225
+ // random() in [0,1) maps to [-spread, +spread).
226
+ return Math.max(0, Math.round(clamped + (random() * 2 - 1) * spread));
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // The agent
231
+ // ---------------------------------------------------------------------------
232
+
233
+ export interface MachineDeviceAgentStatusV1 {
234
+ schemaVersion: 1;
235
+ enrolled: boolean;
236
+ running: boolean;
237
+ machineId?: string;
238
+ label?: string;
239
+ origin?: string;
240
+ /** When the last poll was answered, successfully or not. */
241
+ lastPollAt?: string;
242
+ /** Why the last cycle failed, if it did. Never carries a token. */
243
+ lastError?: string;
244
+ /** Consecutive failures, which is what the backoff is a function of. */
245
+ failures: number;
246
+ }
247
+
248
+ /**
249
+ * The status as it crosses the IPC seam into the renderer.
250
+ *
251
+ * The settings section reads this from the Electron preload bridge, which is a
252
+ * different runtime, so it is decoded like every other cross-runtime value.
253
+ * Note what is *not* on it: no token, no machine token digest, no origin
254
+ * secret — a status a renderer can read is a status that carries nothing that
255
+ * proves anything.
256
+ */
257
+ export function decodeMachineDeviceAgentStatusV1(
258
+ input: unknown,
259
+ label = "machine agent status",
260
+ ): MachineDeviceAgentStatusV1 {
261
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
262
+ throw new MachineDecodeError(`${label} must be an object`);
263
+ }
264
+ const value = input as Record<string, unknown>;
265
+ const allowed = [
266
+ "schemaVersion",
267
+ "enrolled",
268
+ "running",
269
+ "machineId",
270
+ "label",
271
+ "origin",
272
+ "lastPollAt",
273
+ "lastError",
274
+ "failures",
275
+ ];
276
+ for (const key of Reflect.ownKeys(value)) {
277
+ if (typeof key !== "string" || !allowed.includes(key)) {
278
+ throw new MachineDecodeError(`${label} has an unexpected key`);
279
+ }
280
+ }
281
+ if (value.schemaVersion !== 1) {
282
+ throw new MachineDecodeError(`${label} schemaVersion must be 1`);
283
+ }
284
+ if (
285
+ typeof value.enrolled !== "boolean" ||
286
+ typeof value.running !== "boolean"
287
+ ) {
288
+ throw new MachineDecodeError(
289
+ `${label} enrolled and running must be booleans`,
290
+ );
291
+ }
292
+ if (
293
+ !Number.isSafeInteger(value.failures) ||
294
+ (value.failures as number) < 0 ||
295
+ (value.failures as number) > 1_000_000
296
+ ) {
297
+ throw new MachineDecodeError(`${label} failures must be a counter`);
298
+ }
299
+ const optional = (
300
+ key: "machineId" | "label" | "origin" | "lastPollAt" | "lastError",
301
+ ): Record<string, string> | Record<string, never> =>
302
+ value[key] === undefined
303
+ ? {}
304
+ : { [key]: text(value[key], 2_048, `${label} ${key}`) };
305
+ return {
306
+ schemaVersion: 1,
307
+ enrolled: value.enrolled,
308
+ running: value.running,
309
+ ...optional("machineId"),
310
+ ...optional("label"),
311
+ ...optional("origin"),
312
+ ...optional("lastPollAt"),
313
+ ...optional("lastError"),
314
+ failures: value.failures as number,
315
+ };
316
+ }
317
+
318
+ export interface MachineDeviceAgentOptionsV1 {
319
+ /** The deployment the machine dials. */
320
+ origin: string;
321
+ /** Injected: the platform's `fetch`. */
322
+ fetch(input: string, init?: RequestInit): Promise<Response>;
323
+ secrets: MachineSecretStoreV1;
324
+ runner: MachineCommandRunnerV1;
325
+ /** The machine's own name for itself — a hostname. */
326
+ label: string;
327
+ platform: MachinePlatformV1;
328
+ agentVersion: string;
329
+ capabilities: MachineCapabilityV1[];
330
+ now?(): number;
331
+ sleep?(ms: number, signal: AbortSignal): Promise<void>;
332
+ random?(): number;
333
+ /** Called whenever the status changes, so a UI can render it. */
334
+ onStatus?(status: MachineDeviceAgentStatusV1): void;
335
+ }
336
+
337
+ export class MachineDeviceAgentError extends Error {
338
+ override readonly name = "MachineDeviceAgentError";
339
+ readonly status: number;
340
+ constructor(status: number, message: string) {
341
+ super(message);
342
+ this.status = status;
343
+ }
344
+ }
345
+
346
+ /** One poll-claim-run-report cycle's outcome, for tests and for the status. */
347
+ export interface MachineDeviceAgentCycleV1 {
348
+ /** False when there is no stored enrollment: nothing was attempted. */
349
+ paired: boolean;
350
+ delivered: number;
351
+ claimed: number;
352
+ alreadyClaimed: number;
353
+ reported: number;
354
+ /** Set when the cycle failed; the loop backs off on it. */
355
+ error?: string;
356
+ /** True when a 401 un-enrolled this agent. */
357
+ unenrolled?: boolean;
358
+ }
359
+
360
+ function message(error: unknown): string {
361
+ return (error instanceof Error ? error.message : String(error)).slice(0, 500);
362
+ }
363
+
364
+ export class MachineDeviceAgentV1 {
365
+ private state: MachineEnrollmentStateV1 | undefined;
366
+ private loaded = false;
367
+ private running = false;
368
+ private failures = 0;
369
+ private lastPollAt: string | undefined;
370
+ private lastError: string | undefined;
371
+ private loop: Promise<void> | undefined;
372
+ private controller: AbortController | undefined;
373
+
374
+ constructor(private readonly options: MachineDeviceAgentOptionsV1) {}
375
+
376
+ private now(): number {
377
+ return this.options.now?.() ?? Date.now();
378
+ }
379
+
380
+ private random(): number {
381
+ return this.options.random?.() ?? Math.random();
382
+ }
383
+
384
+ private sleep(ms: number, signal: AbortSignal): Promise<void> {
385
+ if (this.options.sleep) return this.options.sleep(ms, signal);
386
+ return new Promise<void>((resolve) => {
387
+ if (ms <= 0 || signal.aborted) {
388
+ resolve();
389
+ return;
390
+ }
391
+ const timer = setTimeout(finish, ms);
392
+ function finish(): void {
393
+ clearTimeout(timer);
394
+ signal.removeEventListener("abort", finish);
395
+ resolve();
396
+ }
397
+ signal.addEventListener("abort", finish, { once: true });
398
+ });
399
+ }
400
+
401
+ /**
402
+ * The stored enrollment, read once and then held.
403
+ *
404
+ * A store that throws — a locked keychain — is not fatal: the agent reports
405
+ * it as an error and stays un-enrolled, because pretending to be enrolled
406
+ * with no token would just 401 in a loop.
407
+ */
408
+ private async enrollment(): Promise<MachineEnrollmentStateV1 | undefined> {
409
+ if (this.loaded) return this.state;
410
+ this.loaded = true;
411
+ let raw: string | undefined;
412
+ try {
413
+ raw = await this.options.secrets.read();
414
+ } catch (error) {
415
+ this.lastError = `could not read the machine token: ${message(error)}`;
416
+ return undefined;
417
+ }
418
+ if (raw === undefined) return undefined;
419
+ try {
420
+ const decoded = decodeMachineEnrollmentStateV1(JSON.parse(raw));
421
+ // A token minted by another deployment is not this one's; forget it
422
+ // rather than presenting it somewhere it can only fail.
423
+ if (decoded.origin !== this.options.origin) {
424
+ await this.forget();
425
+ return undefined;
426
+ }
427
+ this.state = decoded;
428
+ } catch (error) {
429
+ this.lastError = `stored machine enrollment is unreadable: ${message(error)}`;
430
+ await this.forget();
431
+ }
432
+ return this.state;
433
+ }
434
+
435
+ private async forget(): Promise<void> {
436
+ this.state = undefined;
437
+ try {
438
+ await this.options.secrets.clear();
439
+ } catch {
440
+ // A store that cannot be cleared is still forgotten in memory; the next
441
+ // load discards what it finds because it will not verify either.
442
+ }
443
+ }
444
+
445
+ /** Whether a token is on this laptop. Reads the store on first call. */
446
+ async paired(): Promise<boolean> {
447
+ return (await this.enrollment()) !== undefined;
448
+ }
449
+
450
+ status(): MachineDeviceAgentStatusV1 {
451
+ return {
452
+ schemaVersion: 1,
453
+ enrolled: this.state !== undefined,
454
+ running: this.running,
455
+ ...(this.state === undefined
456
+ ? {}
457
+ : {
458
+ machineId: this.state.machineId,
459
+ label: this.state.label,
460
+ origin: this.state.origin,
461
+ }),
462
+ ...(this.lastPollAt === undefined ? {} : { lastPollAt: this.lastPollAt }),
463
+ ...(this.lastError === undefined ? {} : { lastError: this.lastError }),
464
+ failures: this.failures,
465
+ };
466
+ }
467
+
468
+ private announce(): void {
469
+ this.options.onStatus?.(this.status());
470
+ }
471
+
472
+ private async call(
473
+ path: string,
474
+ init: RequestInit & { token?: string } = {},
475
+ ): Promise<unknown> {
476
+ const headers = new Headers(init.headers);
477
+ if (init.token) headers.set("authorization", `Bearer ${init.token}`);
478
+ if (init.body !== undefined) {
479
+ headers.set("content-type", "application/json");
480
+ }
481
+ const response = await this.options.fetch(`${this.options.origin}${path}`, {
482
+ ...init,
483
+ headers,
484
+ });
485
+ const body = await response.text();
486
+ if (!response.ok) {
487
+ throw new MachineDeviceAgentError(
488
+ response.status,
489
+ `machine request failed with ${response.status}: ${body.slice(0, 200)}`,
490
+ );
491
+ }
492
+ return body.length === 0 ? undefined : (JSON.parse(body) as unknown);
493
+ }
494
+
495
+ /**
496
+ * Present a pairing code and become a registered machine.
497
+ *
498
+ * The code is the only secret that crosses from the browser to the laptop,
499
+ * it is one-time, and it is never stored: what is stored is the token the
500
+ * enrollment answered with.
501
+ */
502
+ async pair(code: string): Promise<MachineDeviceAgentStatusV1> {
503
+ const presented = text(
504
+ code.trim(),
505
+ MACHINE_LIMITS_V1.pairingCode,
506
+ "pairing code",
507
+ );
508
+ const receipt = decodeMachineEnrollmentReceiptV1(
509
+ await this.call(machineRoutePathV1("enroll"), {
510
+ method: "POST",
511
+ token: presented,
512
+ body: JSON.stringify({
513
+ schemaVersion: 1,
514
+ code: presented,
515
+ label: this.options.label,
516
+ platform: this.options.platform,
517
+ agentVersion: this.options.agentVersion,
518
+ capabilities: this.options.capabilities,
519
+ }),
520
+ }),
521
+ );
522
+ const state: MachineEnrollmentStateV1 = {
523
+ schemaVersion: 1,
524
+ machineId: receipt.machineId,
525
+ token: receipt.token,
526
+ origin: this.options.origin,
527
+ label: this.options.label,
528
+ enrolledAt: new Date(this.now()).toISOString(),
529
+ };
530
+ await this.options.secrets.write(JSON.stringify(state));
531
+ this.state = state;
532
+ this.loaded = true;
533
+ this.failures = 0;
534
+ this.lastError = undefined;
535
+ this.announce();
536
+ return this.status();
537
+ }
538
+
539
+ /** Forget the token on this laptop. The registry row is the browser's to revoke. */
540
+ async unpair(): Promise<MachineDeviceAgentStatusV1> {
541
+ await this.stop();
542
+ await this.forget();
543
+ this.loaded = true;
544
+ this.failures = 0;
545
+ this.lastError = undefined;
546
+ this.announce();
547
+ return this.status();
548
+ }
549
+
550
+ /**
551
+ * One cycle: poll, then claim, run and answer everything the poll returned.
552
+ *
553
+ * Never throws. Every failure is a value, because the loop's job is to keep
554
+ * polling and a thrown error in a background loop is a silently dead agent.
555
+ */
556
+ async runOnce(
557
+ waitSeconds: number = MACHINE_LIMITS_V1.pollMaxWaitSeconds,
558
+ signal: AbortSignal = new AbortController().signal,
559
+ ): Promise<MachineDeviceAgentCycleV1> {
560
+ const cycle: MachineDeviceAgentCycleV1 = {
561
+ paired: true,
562
+ delivered: 0,
563
+ claimed: 0,
564
+ alreadyClaimed: 0,
565
+ reported: 0,
566
+ };
567
+ const state = await this.enrollment();
568
+ if (!state) {
569
+ cycle.paired = false;
570
+ cycle.error = this.lastError ?? "this machine is not paired";
571
+ return cycle;
572
+ }
573
+ try {
574
+ const answered = decodeMachinePollResultV1(
575
+ await this.call(
576
+ machineRoutePathV1("poll", {
577
+ machineId: state.machineId,
578
+ waitSeconds: Math.min(
579
+ waitSeconds,
580
+ MACHINE_LIMITS_V1.pollMaxWaitSeconds,
581
+ ),
582
+ }),
583
+ { token: state.token, signal },
584
+ ),
585
+ );
586
+ this.lastPollAt = new Date(this.now()).toISOString();
587
+ cycle.delivered = answered.commands.length;
588
+ for (const command of answered.commands) {
589
+ if (signal.aborted) break;
590
+ const receipt = decodeMachineClaimReceiptV1(
591
+ await this.call(
592
+ machineRoutePathV1("claim", {
593
+ machineId: state.machineId,
594
+ commandId: command.commandId,
595
+ }),
596
+ { method: "POST", token: state.token, body: JSON.stringify({}) },
597
+ ),
598
+ );
599
+ if (receipt.status !== "claimed") {
600
+ // Somebody else holds the lease. Running it anyway is the one thing
601
+ // "recovery never silently duplicates" forbids.
602
+ cycle.alreadyClaimed += 1;
603
+ continue;
604
+ }
605
+ cycle.claimed += 1;
606
+ const report = await this.execute(command, signal);
607
+ decodeMachineResultReceiptV1(
608
+ await this.call(
609
+ machineRoutePathV1("result", {
610
+ machineId: state.machineId,
611
+ commandId: command.commandId,
612
+ }),
613
+ {
614
+ method: "POST",
615
+ token: state.token,
616
+ body: JSON.stringify({
617
+ schemaVersion: 1,
618
+ commandId: command.commandId,
619
+ ...report,
620
+ }),
621
+ },
622
+ ),
623
+ );
624
+ cycle.reported += 1;
625
+ }
626
+ this.failures = 0;
627
+ this.lastError = undefined;
628
+ } catch (error) {
629
+ cycle.error = message(error);
630
+ this.lastError = cycle.error;
631
+ this.failures += 1;
632
+ if (
633
+ error instanceof MachineDeviceAgentError &&
634
+ (error.status === 401 || error.status === 403)
635
+ ) {
636
+ // Revoked, or signed with a rotated secret. The token is dead for
637
+ // good, so it is cleared rather than retried forever.
638
+ await this.forget();
639
+ this.running = false;
640
+ cycle.unenrolled = true;
641
+ this.lastError = "this machine was revoked; pair it again to reconnect";
642
+ }
643
+ }
644
+ this.announce();
645
+ return cycle;
646
+ }
647
+
648
+ /**
649
+ * The runner, wrapped so a thrown handler is still an answer.
650
+ *
651
+ * A command that was claimed and never answered leaves the backend guessing
652
+ * until the lease lapses. An `error` outcome the Bot can read is strictly
653
+ * better than silence, so nothing the runner does escapes this method.
654
+ */
655
+ private async execute(
656
+ command: MachineCommandV1,
657
+ signal: AbortSignal,
658
+ ): Promise<MachineCommandReportV1> {
659
+ try {
660
+ return await this.options.runner.run(command, signal);
661
+ } catch (error) {
662
+ return {
663
+ finishedAt: new Date(this.now()).toISOString(),
664
+ outcome: "error",
665
+ truncated: false,
666
+ message: message(error),
667
+ };
668
+ }
669
+ }
670
+
671
+ /** Start the loop. Idempotent: a second call is not a second loop. */
672
+ start(): void {
673
+ if (this.running) return;
674
+ this.running = true;
675
+ const controller = new AbortController();
676
+ this.controller = controller;
677
+ this.loop = this.pump(controller.signal).finally(() => {
678
+ this.running = false;
679
+ this.controller = undefined;
680
+ this.loop = undefined;
681
+ });
682
+ this.announce();
683
+ }
684
+
685
+ async stop(): Promise<void> {
686
+ this.controller?.abort();
687
+ this.running = false;
688
+ const loop = this.loop;
689
+ if (loop) await loop;
690
+ this.announce();
691
+ }
692
+
693
+ private async pump(signal: AbortSignal): Promise<void> {
694
+ while (!signal.aborted) {
695
+ const cycle = await this.runOnce(
696
+ MACHINE_LIMITS_V1.pollMaxWaitSeconds,
697
+ signal,
698
+ );
699
+ if (cycle.unenrolled) return;
700
+ if (signal.aborted) return;
701
+ const delay = cycle.paired
702
+ ? machinePollBackoffV1(this.failures, () => this.random())
703
+ : MACHINE_AGENT_IDLE_MS_V1;
704
+ if (delay > 0) await this.sleep(delay, signal);
705
+ }
706
+ }
707
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ declare module "*.vue" {
2
+ import type { DefineComponent } from "vue";
3
+
4
+ const component: DefineComponent;
5
+ export default component;
6
+ }