@runuai/host 0.9.150 → 0.9.151

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.
Files changed (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1830 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -0,0 +1,546 @@
1
+ /**
2
+ * ADR-154 §13 on the runner: the activation authority. A runner moves from
3
+ * its own credentials (`legacy`) to cloud ones once, and never back.
4
+ *
5
+ * 1. *Prepare*, changing nothing that cannot be undone. The cloud agrees
6
+ * (`credentials.activate`, phase `prepare`, which the cloud records: from
7
+ * here it honors this runner's commit whatever its switch says later).
8
+ * The runner admits no new start on its own credentials and waits for the
9
+ * starts under way, and plans the retirement of every task runtime
10
+ * (finding every blocker known in advance:
11
+ * lib/legacy-runtime-retirement.ts). A refusal or a blocker admits starts
12
+ * again, on v0, with every runtime and store as it was; the next attempt
13
+ * prepares again.
14
+ * 2. *Local commit*, from which it only goes forward. The durable record
15
+ * `local`, written under the managed-operation lock once the runner's
16
+ * next boot is proven a release that reads it and standalone recovery
17
+ * reads it too (managed-runtime.ts commitCredentialsSwitchLocally): from
18
+ * it, no release built before the cutover runs here again, neither
19
+ * through an update or rollback nor through standalone recovery.
20
+ * Then leaving its own credentials (its refreshers, its v0 lease and every
21
+ * credential write admitted from the bridge stop and drain), the
22
+ * retirement of every task runtime (each task stops and its container
23
+ * goes with its worktree kept), and the credentials floor's own file. It
24
+ * is `pending` from the record on: it runs no task, and a restart resumes
25
+ * here.
26
+ * 3. *Cloud commit*: the cloud records the runner's v1 floor; record
27
+ * `cloud`.
28
+ * 4. *Purge*, then record `active`: the runner advertises v1, reports the
29
+ * purge (main.ts sends it on every connection and tick, since the cloud
30
+ * acknowledges none and keeps the first), and runs its tasks on cloud
31
+ * credentials.
32
+ *
33
+ * Every retired task is owed a stopped report at the exact run epoch it was
34
+ * stopped at, for the cloud may still show it running: the report is
35
+ * best-effort (no acknowledgement; the cloud drops one it fails to write),
36
+ * so the record keeps each owed report through every step, `active`
37
+ * included, and the runner sends them again on every attempt and tick until
38
+ * each is moot (a newer run of the task, or its row gone). The cloud ignores
39
+ * a report about a task that is not running at that epoch, so sending one
40
+ * again is harmless.
41
+ *
42
+ * Leaving its own credentials is one-way in a process: if that fails (a
43
+ * worker's stop threw), the process still retires every task runtime, so no
44
+ * engine goes on running on them, and then asks its supervisor for a
45
+ * restart; it comes back pending, with nothing of its own credentials
46
+ * started, and resumes the switch.
47
+ *
48
+ * Nothing irreversible runs on a record not known to be durable: written
49
+ * whole, renamed into place and its directory synced, in this process. One
50
+ * renamed into place whose directory could not then be synced is the record
51
+ * (it may well be on disk: the runner's own credentials do not resume), but
52
+ * a crash could still lose it, and the runner would boot back onto its own
53
+ * credentials after retiring its tasks. So the switch waits, pending, until
54
+ * the directory syncs. A record found at boot is synced before anything
55
+ * runs on it too: an earlier process may have left one it could not sync.
56
+ * What is synced is proven to be the record this process holds, exactly:
57
+ * one removed, replaced or unreadable since is written again from it.
58
+ *
59
+ * The runner says it is active (`onActive`, and `mode()`, which every v1
60
+ * surface follows: its advertisement, its lease frames, the purge report,
61
+ * task starts) only once its `active` record is durable and the recovery
62
+ * sweep of what the last process left (ADR-154 §5, §6) is done, so that no
63
+ * grant is asked for before it: at boot the container runtime's own pass
64
+ * sweeps (and should it give up, the authority, on every attempt), at the
65
+ * transition the authority does. Should saying so fail, the
66
+ * runner is pending again, withdraws what it advertised, and says so later.
67
+ *
68
+ * The record holds no secret: `{"schemaVersion":1,"step":"local",
69
+ * "stopped":[{"taskId":"…","runEpoch":3}]}`. One the runner cannot read
70
+ * counts as `local`, never as `legacy`: a damaged record may stand for a
71
+ * switch under way. That is why the local step leaves, plans the retirement
72
+ * and claims the floor itself (each idempotent, and a damaged record must
73
+ * not skip them); the reports a damaged record lost are owed again by that
74
+ * retirement, which finds the same runtimes gone.
75
+ */
76
+
77
+ import {
78
+ closeSync,
79
+ constants,
80
+ fstatSync,
81
+ fsyncSync,
82
+ lstatSync,
83
+ openSync,
84
+ readFileSync,
85
+ renameSync,
86
+ writeFileSync,
87
+ } from "node:fs";
88
+ import { dirname } from "node:path";
89
+
90
+ import type { CredentialsActivationPhase } from "../src/cloud-credentials";
91
+ import type { CredentialsMode } from "./cloud-credential-host";
92
+ import type { RetirementPlan } from "./legacy-runtime-retirement";
93
+ import { isSafeHostTaskId } from "./task-identity";
94
+
95
+ export type ActivationStep = "local" | "cloud" | "active";
96
+
97
+ /** A retired task's stopped report: its exact run epoch, or null for a task
98
+ * with no assignment (it predates run epochs). */
99
+ export interface StoppedReport {
100
+ taskId: string;
101
+ runEpoch: number | null;
102
+ }
103
+
104
+ export interface ActivationRecord {
105
+ step: ActivationStep;
106
+ /** Reports owed: the cloud may not have taken them yet. */
107
+ stopped: StoppedReport[];
108
+ /** The record on disk could not be read, and this one stands for it. */
109
+ damaged?: true;
110
+ }
111
+
112
+ export type ActivationAnswer = { ok: true } | { ok: false; error: string };
113
+
114
+ const RECORD_SCHEMA_VERSION = 1;
115
+ const STEPS: readonly ActivationStep[] = ["local", "cloud", "active"];
116
+
117
+ export interface ActivationAuthorityDeps {
118
+ /** The durable record (non-secret), beside the runner's database. */
119
+ recordPath: string;
120
+ /** The credentials floor's file exists: the switch began, even with no
121
+ * record to say so (managed-runtime.ts credentialsCapabilityFloorExists). */
122
+ floorClaimed: () => boolean;
123
+ /** Whether this runner moves to cloud credentials in this release. */
124
+ eligible: () => boolean;
125
+ /** One phase asked of the cloud. Rejects when it could not be asked or
126
+ * answered (no bridge, a timeout): the next attempt asks again. */
127
+ ask: (phase: CredentialsActivationPhase) => Promise<ActivationAnswer>;
128
+ /** Admit no new start on the runner's own credentials (cloud-credential-host). */
129
+ pauseLegacyStarts: () => { drained: Promise<void>; resume(): void };
130
+ /** The retirement's plan: changes nothing, rejects with every blocker. */
131
+ planRetirement: () => Promise<RetirementPlan>;
132
+ /** Under the managed-operation lock, prove the next boot reads the record
133
+ * and fence standalone recovery, then call `record` to write it
134
+ * (managed-runtime.ts commitCredentialsSwitchLocally). */
135
+ commitLocally: (record: () => void) => Promise<void>;
136
+ /** Claim the credentials floor: no older release runs here again. Idempotent. */
137
+ claimFloor: () => Promise<void> | void;
138
+ /** Leave the runner's own credentials (cloud-credential-host). */
139
+ leaveLegacy: () => Promise<void>;
140
+ /** Carry the plan out (idempotent). `stopped` names each task whose
141
+ * runtime is gone, at its exact run epoch; `problem` says what could not
142
+ * be proven removed. */
143
+ retire: (plan: RetirementPlan) => Promise<{ stopped: StoppedReport[]; problem: string | null }>;
144
+ /** Send each owed report again; returns those still owed (the moot ones
145
+ * dropped: a newer run of the task, or its row gone). */
146
+ reportStopped: (owed: readonly StoppedReport[]) => StoppedReport[];
147
+ /** Delete every runner-local credential store; rejects unless all went. */
148
+ purge: () => Promise<void>;
149
+ /** The recovery sweep has succeeded in this process
150
+ * (cloud-credential-host.ts cloudCredentialsSwept). */
151
+ swept: () => boolean;
152
+ /** Run the recovery sweep, or join the one under way; rejects when it
153
+ * fails (recoverCloudCredentials). */
154
+ sweep: () => Promise<void>;
155
+ /** The runner's tasks now run on cloud credentials: at the transition, or
156
+ * at a boot that found the record `active` (never both in one process).
157
+ * `mode()` is `active` while it runs. May throw: the authority is then
158
+ * pending again, calls `onActiveWithdrawn`, and calls this again later. */
159
+ onActive: (how: "transition" | "boot") => void;
160
+ /** Saying the runner is active failed and it is pending again: withdraw
161
+ * what was advertised. */
162
+ onActiveWithdrawn: () => void;
163
+ /** Ask the runner's supervisor for a restart. */
164
+ restart: (why: string) => void;
165
+ log: (line: string) => void;
166
+ }
167
+
168
+ export class CredentialsActivationAuthority {
169
+ readonly #deps: ActivationAuthorityDeps;
170
+ #record: ActivationRecord | null;
171
+ /** `#record` is known to be durable (see the header). */
172
+ #durable = false;
173
+ /** `onActive` has run, and returned, in this process. */
174
+ #announced = false;
175
+ /** `onActive` is running. */
176
+ #announcing = false;
177
+ /** The container runtime's own recovery gave up: the sweep is this
178
+ * authority's to retry (`sweepItself`). */
179
+ #sweepsItself = false;
180
+ /** The record was `active` when this process started. */
181
+ readonly #activeAtBoot: boolean;
182
+ #advancing: Promise<void> | null = null;
183
+ #lastNote: string | null = null;
184
+ /** Leaving the runner's own credentials failed in this process. */
185
+ #stuck = false;
186
+
187
+ constructor(deps: ActivationAuthorityDeps) {
188
+ this.#deps = deps;
189
+ this.#record = readActivationRecord(deps.recordPath);
190
+ if (this.#record === null && deps.floorClaimed()) {
191
+ // Only the switch claims the floor, after its record: a floor with no
192
+ // record means the record was lost, never a runner still on v0.
193
+ this.#record = { step: "local", stopped: [], damaged: true };
194
+ deps.log(
195
+ `[credentials] the credentials floor exists but ${deps.recordPath} does not: the runner treats it as the local ` +
196
+ "commit, runs no task, and retires every task runtime again before it completes the switch",
197
+ );
198
+ } else if (this.#record?.damaged) {
199
+ deps.log(
200
+ `[credentials] the activation record ${deps.recordPath} could not be read: the runner treats it as the local ` +
201
+ "commit, runs no task, and claims the floor and retires every task runtime again before it completes the switch",
202
+ );
203
+ }
204
+ this.#activeAtBoot = this.#record?.step === "active";
205
+ }
206
+
207
+ /** The step the durable record names; null on the runner's own credentials. */
208
+ step(): ActivationStep | null {
209
+ return this.#record?.step ?? null;
210
+ }
211
+
212
+ mode(): CredentialsMode {
213
+ if (this.#record === null) return "legacy";
214
+ return this.#record.step === "active" && (this.#announced || this.#announcing) ? "active" : "pending";
215
+ }
216
+
217
+ /** At boot, once the composer and the bridge's handlers exist: a runner
218
+ * already active says so to whoever follows it once it may (`advance`
219
+ * again when the boot recovery sweep is done). */
220
+ booted(): void {
221
+ if (this.#record?.step === "active") void this.advance();
222
+ }
223
+
224
+ /** The container runtime's recovery gave up (its bounded retries spent)
225
+ * before the boot sweep succeeded: from now on every attempt runs the
226
+ * sweep itself, so a runner already active is not left pending for good. */
227
+ sweepItself(): void {
228
+ this.#sweepsItself = true;
229
+ void this.advance();
230
+ }
231
+
232
+ /**
233
+ * The owed stopped reports again, then as far as the runner can get now:
234
+ * from `legacy` only when eligible and the cloud agrees; from `pending`
235
+ * always. Single-flight; never rejects (each failure is logged, and the
236
+ * next call tries again).
237
+ */
238
+ advance(): Promise<void> {
239
+ if (this.#advancing === null) {
240
+ this.#advancing = this.#advanceOnce()
241
+ .catch((error: unknown) => this.#note(`activation stopped: ${message(error)}; it tries again later`))
242
+ .finally(() => {
243
+ this.#advancing = null;
244
+ });
245
+ }
246
+ return this.#advancing;
247
+ }
248
+
249
+ async #advanceOnce(): Promise<void> {
250
+ this.#reportOwed();
251
+ if (this.#stuck) return;
252
+ if (this.#record?.step === "active") {
253
+ await this.#activate();
254
+ return;
255
+ }
256
+ let plan: RetirementPlan | null = null;
257
+ if (this.#record === null) {
258
+ if (!this.#deps.eligible()) return;
259
+ const prepared = await this.#deps.ask("prepare");
260
+ if (!prepared.ok) {
261
+ this.#note(`the cloud did not agree to cloud credentials yet: ${prepared.error}`);
262
+ return;
263
+ }
264
+ const paused = this.#deps.pauseLegacyStarts();
265
+ try {
266
+ await paused.drained;
267
+ plan = await this.#deps.planRetirement();
268
+ // From the record on, the switch only goes forward.
269
+ await this.#deps.commitLocally(() => this.#write({ step: "local", stopped: [] }));
270
+ } catch (error) {
271
+ // A failure after the record was written (the lock's release, say)
272
+ // changes nothing about it: the switch goes on. Whether it was
273
+ // written is read back, never assumed from the error: a rename that
274
+ // failed ambiguously may have landed, and a record on disk, readable
275
+ // or not, is the local commit.
276
+ this.#record ??= readActivationRecord(this.#deps.recordPath);
277
+ if (this.#record === null) {
278
+ paused.resume();
279
+ this.#note(`the runner stays on its own credentials, nothing changed: ${message(error)}`);
280
+ return;
281
+ }
282
+ }
283
+ this.#deps.log("[credentials] switching to cloud credentials: leaving the runner's own");
284
+ }
285
+ if (!this.#synced()) return;
286
+ if (this.#record?.step === "local") {
287
+ // A process that started pending has nothing of its own credentials
288
+ // running; leaving them is then immediate.
289
+ try {
290
+ await this.#deps.leaveLegacy();
291
+ } catch (error) {
292
+ this.#stuck = true;
293
+ this.#deps.log(
294
+ `[credentials] the runner's own credentials did not stop (${message(error)}): it retires every task runtime ` +
295
+ "now, then restarts to resume the switch",
296
+ );
297
+ try {
298
+ await this.#retire(plan ?? (await this.#deps.planRetirement()));
299
+ } finally {
300
+ this.#deps.restart("the runner's own credentials did not stop while it switched to cloud credentials");
301
+ }
302
+ return;
303
+ }
304
+ plan ??= await this.#deps.planRetirement();
305
+ await this.#retire(plan);
306
+ await this.#deps.claimFloor();
307
+ const committed = await this.#deps.ask("commit");
308
+ if (!committed.ok) {
309
+ this.#note(`the cloud did not record this runner's switch yet: ${committed.error}; it runs no task meanwhile`);
310
+ return;
311
+ }
312
+ this.#write({ ...this.#record, step: "cloud" });
313
+ }
314
+ if (this.#record?.step === "cloud") {
315
+ await this.#deps.purge();
316
+ this.#write({ ...this.#record, step: "active" });
317
+ this.#deps.log("[credentials] local credential stores purged");
318
+ await this.#activate();
319
+ }
320
+ }
321
+
322
+ /** Whether the record is durable, proving it if that is not known yet:
323
+ * the record this process holds, exactly, synced at its path. One removed,
324
+ * replaced or unreadable since is written again from it (no process
325
+ * holds one behind the one on disk). Noted (once) while it cannot be. */
326
+ #synced(): boolean {
327
+ if (this.#durable) return true;
328
+ const record = this.#record;
329
+ if (record === null) return false;
330
+ try {
331
+ if (sameRecord(readActivationRecord(this.#deps.recordPath), record)) {
332
+ syncRecordExactly(this.#deps.recordPath, record);
333
+ this.#durable = true;
334
+ } else {
335
+ this.#write(record);
336
+ }
337
+ } catch (error) {
338
+ this.#note(
339
+ `the activation record is not known to be on disk yet (${message(error)}): the switch waits, running no task, ` +
340
+ "until it is",
341
+ );
342
+ return false;
343
+ }
344
+ return this.#durable;
345
+ }
346
+
347
+ /** Says `active` once the runner may: its `active` record durable, and the
348
+ * recovery sweep done (see the header). */
349
+ async #activate(): Promise<void> {
350
+ if (this.#announced || !this.#synced()) return;
351
+ if (!this.#deps.swept()) {
352
+ // At boot the container runtime's pass sweeps, before it reattaches
353
+ // anything; this joins in only once that pass gave up.
354
+ if (this.#activeAtBoot && !this.#sweepsItself) {
355
+ this.#note("the runner says it is active once the boot recovery sweep is done; it runs no task until then");
356
+ return;
357
+ }
358
+ try {
359
+ await this.#deps.sweep();
360
+ } catch (error) {
361
+ this.#note(
362
+ `the recovery sweep failed (${message(error)}): the runner stays pending, running no task, and sweeps again later`,
363
+ );
364
+ return;
365
+ }
366
+ }
367
+ this.#announce();
368
+ }
369
+
370
+ /** `active`, said once per process: at boot for a runner that started
371
+ * active, else the transition. A failure leaves it pending, withdrawn. */
372
+ #announce(): void {
373
+ if (this.#announced) return;
374
+ this.#announcing = true;
375
+ let failure: unknown = null;
376
+ try {
377
+ this.#deps.onActive(this.#activeAtBoot ? "boot" : "transition");
378
+ } catch (error) {
379
+ failure = error ?? new Error("onActive threw");
380
+ } finally {
381
+ this.#announcing = false;
382
+ }
383
+ if (failure === null) {
384
+ this.#announced = true;
385
+ this.#deps.log("[credentials] tasks run on cloud credentials");
386
+ return;
387
+ }
388
+ this.#note(`saying the runner is active failed (${message(failure)}): it is pending again, and tries again later`);
389
+ try {
390
+ this.#deps.onActiveWithdrawn();
391
+ } catch (error) {
392
+ this.#note(`withdrawing what was advertised failed: ${message(error)}`);
393
+ }
394
+ }
395
+
396
+ /** The retirement, its reports owed durably before they are sent, and
397
+ * sent; rejects when anything could not be proven removed. */
398
+ async #retire(plan: RetirementPlan): Promise<void> {
399
+ const { stopped, problem } = await this.#deps.retire(plan);
400
+ const owed = new Map((this.#record?.stopped ?? []).map((report) => [report.taskId, report]));
401
+ for (const report of stopped) owed.set(report.taskId, report);
402
+ this.#write({ step: this.#record?.step ?? "local", stopped: [...owed.values()] });
403
+ this.#reportOwed();
404
+ if (problem !== null) throw new Error(problem);
405
+ }
406
+
407
+ #reportOwed(): void {
408
+ const record = this.#record;
409
+ if (record === null || record.stopped.length === 0) return;
410
+ const owed = this.#deps.reportStopped(record.stopped);
411
+ if (owed.length !== record.stopped.length) this.#write({ ...record, stopped: owed });
412
+ }
413
+
414
+ /** From its rename on it is the record, even if syncing its directory
415
+ * then fails, so a failure past the rename is never taken for no record
416
+ * at all; but it is known to be durable only once that sync is done, and
417
+ * nothing irreversible runs on it before (`#synced`). */
418
+ #write(record: ActivationRecord): void {
419
+ const written: ActivationRecord = { step: record.step, stopped: record.stopped };
420
+ writeActivationRecord(this.#deps.recordPath, written, () => {
421
+ this.#record = written;
422
+ this.#durable = false;
423
+ this.#lastNote = null;
424
+ });
425
+ syncRecordExactly(this.#deps.recordPath, written);
426
+ this.#durable = true;
427
+ }
428
+
429
+ /** The same note twice in a row is logged once: a refusal is asked about
430
+ * again every few minutes. */
431
+ #note(line: string): void {
432
+ if (line === this.#lastNote) return;
433
+ this.#lastNote = line;
434
+ this.#deps.log(`[credentials] ${line}`);
435
+ }
436
+ }
437
+
438
+ /** The record; null when there is none. One the runner cannot read is
439
+ * `local` with nothing owed (see the header). */
440
+ export function readActivationRecord(path: string): ActivationRecord | null {
441
+ let raw: string;
442
+ try {
443
+ raw = readFileSync(path, "utf8");
444
+ } catch (error) {
445
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
446
+ return { step: "local", stopped: [], damaged: true };
447
+ }
448
+ return parseActivationRecord(raw);
449
+ }
450
+
451
+ /** A record's text as the runner reads it: unreadable is `local`, damaged. */
452
+ function parseActivationRecord(raw: string): ActivationRecord {
453
+ try {
454
+ const parsed = JSON.parse(raw) as { schemaVersion?: unknown; step?: unknown; stopped?: unknown };
455
+ const step = parsed.step as ActivationStep;
456
+ if (parsed.schemaVersion === RECORD_SCHEMA_VERSION && STEPS.includes(step)) {
457
+ const stopped = Array.isArray(parsed.stopped) && parsed.stopped.every(isStoppedReport) ? parsed.stopped : null;
458
+ if (stopped !== null) return { step, stopped };
459
+ }
460
+ } catch {
461
+ // Falls through: unreadable.
462
+ }
463
+ return { step: "local", stopped: [], damaged: true };
464
+ }
465
+
466
+ function isStoppedReport(value: unknown): value is StoppedReport {
467
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
468
+ const report = value as Record<string, unknown>;
469
+ return (
470
+ Object.keys(report).length === 2 &&
471
+ isSafeHostTaskId(report.taskId) &&
472
+ (report.runEpoch === null || (Number.isSafeInteger(report.runEpoch) && (report.runEpoch as number) >= 1))
473
+ );
474
+ }
475
+
476
+ /** Written whole or not at all, and durable before it counts. `renamed`
477
+ * runs the moment the new record is in place, before its directory is
478
+ * made durable. */
479
+ export function writeActivationRecord(path: string, record: ActivationRecord, renamed?: () => void): void {
480
+ const body = { schemaVersion: RECORD_SCHEMA_VERSION, step: record.step, stopped: record.stopped };
481
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
482
+ writeFileSync(tmp, `${JSON.stringify(body)}\n`, { mode: 0o600 });
483
+ const file = openSync(tmp, "r");
484
+ try {
485
+ fsyncSync(file);
486
+ } finally {
487
+ closeSync(file);
488
+ }
489
+ renameSync(tmp, path);
490
+ renamed?.();
491
+ syncDirectoryOf(path);
492
+ }
493
+
494
+ /** Both say the same record; a damaged one is none. */
495
+ function sameRecord(a: ActivationRecord | null, b: ActivationRecord): boolean {
496
+ return (
497
+ a !== null &&
498
+ !a.damaged &&
499
+ !b.damaged &&
500
+ a.step === b.step &&
501
+ JSON.stringify(a.stopped) === JSON.stringify(b.stopped)
502
+ );
503
+ }
504
+
505
+ /** Proves `path` names `expected` on disk: the regular file it names (no
506
+ * link followed) says exactly that, its bytes are synced, its directory
507
+ * is synced, and it is still that file at that name afterwards (what the
508
+ * directory sync made durable is it, not a removal or a replacement). */
509
+ function syncRecordExactly(path: string, expected: ActivationRecord): void {
510
+ const file = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
511
+ try {
512
+ const opened = fstatSync(file);
513
+ if (!opened.isFile()) throw new Error(`${path} is not a regular file`);
514
+ const onDisk = parseActivationRecord(readFileSync(file, "utf8"));
515
+ if (!sameRecord(onDisk, expected)) throw new Error(`${path} is not the record this runner holds`);
516
+ fsyncSync(file);
517
+ syncDirectoryOf(path);
518
+ const named = lstatSync(path);
519
+ if (named.dev !== opened.dev || named.ino !== opened.ino) {
520
+ throw new Error(`${path} was replaced while it was synced`);
521
+ }
522
+ } finally {
523
+ closeSync(file);
524
+ }
525
+ }
526
+
527
+ /** Makes the entries of `path`'s directory durable: a rename into it. */
528
+ function syncDirectoryOf(path: string): void {
529
+ const dir = openSync(dirname(path), "r");
530
+ try {
531
+ fsyncSync(dir);
532
+ } finally {
533
+ closeSync(dir);
534
+ }
535
+ }
536
+
537
+ /** An error as the runner's log says it. A purge's names everything it could
538
+ * not remove and what to do about each: all of them are listed, one to a
539
+ * line, so that the operator can resolve them in one go. */
540
+ function message(error: unknown): string {
541
+ if (error instanceof AggregateError) {
542
+ const each = error.errors.map((inner: unknown) => `\n - ${message(inner)}`).join("");
543
+ return `${error.message}:${each}`.slice(0, 8_000);
544
+ }
545
+ return (error instanceof Error ? error.message : String(error)).slice(0, 600);
546
+ }