@runuai/host 0.9.149 → 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 +1847 -236
  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 +1062 -140
  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
package/lib/codex-auth.ts CHANGED
@@ -59,6 +59,7 @@ import { dirname, join } from "node:path";
59
59
 
60
60
  import { isNull } from "drizzle-orm";
61
61
 
62
+ import { onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
62
63
  import { getDb, schema } from "./db";
63
64
  import { dockerCli } from "./docker-exec";
64
65
  import { brokeredFilesFor } from "./token-broker";
@@ -237,7 +238,9 @@ export async function injectCodexIntoContainer(
237
238
  const exists = deps.fileExists ?? existsSync;
238
239
  if (!exists(join(ownerCodexDir(), "auth.json"))) return true; // nothing to inject
239
240
  try {
240
- await copyCodexInto(container, deps);
241
+ // ADR-154: no default slot once the runner begins leaving its own
242
+ // credentials; leaving waits for a copy under way.
243
+ await whileOnOwnCredentials(() => copyCodexInto(container, deps));
241
244
  return true;
242
245
  } catch (err) {
243
246
  console.error(
@@ -273,6 +276,9 @@ export async function injectCodexIntoMachine(
273
276
  },
274
277
  deps: CodexDeps = {},
275
278
  ): Promise<boolean> {
279
+ // ADR-154: no default slot once the runner begins leaving its own
280
+ // credentials (a start on them holds its permit through this).
281
+ if (!onOwnCredentials()) return true;
276
282
  const exists = deps.fileExists ?? existsSync;
277
283
  const dir = ownerCodexDir();
278
284
  if (!exists(join(dir, "auth.json"))) return true; // nothing to inject
@@ -317,6 +323,13 @@ export async function injectCodexIntoMachine(
317
323
  * failures are surfaced and don't stop the sweep.
318
324
  */
319
325
  export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<void> {
326
+ // ADR-154: no default slot once the runner begins leaving its own
327
+ // credentials. Leaving waits for a sweep under way, and no copy starts
328
+ // after it began.
329
+ await whileOnOwnCredentials(() => reinjectOwnCodex(deps));
330
+ }
331
+
332
+ async function reinjectOwnCodex(deps: CodexDeps): Promise<void> {
320
333
  const exists = deps.fileExists ?? existsSync;
321
334
  if (!exists(join(ownerCodexDir(), "auth.json"))) return;
322
335
  const active = (deps.activeTaskContainers ?? defaultActiveTaskContainers)();
@@ -338,6 +351,7 @@ export async function reinjectCodexRunningTasks(deps: CodexDeps = {}): Promise<v
338
351
  if (targets.length === 0) return;
339
352
  console.log(`[codex] refreshing auth in ${targets.length} running task(s)`);
340
353
  for (const container of targets) {
354
+ if (!onOwnCredentials()) return;
341
355
  try {
342
356
  await copyCodexInto(container, deps, LIVE_CODEX_ITEMS);
343
357
  } catch (err) {
@@ -394,6 +408,11 @@ export function stopWatchingCodexAuth(): void {
394
408
  * Same sweep shape as the Codex reinject; only auth.json travels.
395
409
  */
396
410
  export async function reinjectGrokRunningTasks(deps: CodexDeps = {}): Promise<void> {
411
+ // ADR-154: as for Codex, only while the runner is on its own credentials.
412
+ await whileOnOwnCredentials(() => reinjectOwnGrok(deps));
413
+ }
414
+
415
+ async function reinjectOwnGrok(deps: CodexDeps): Promise<void> {
397
416
  const exists = deps.fileExists ?? existsSync;
398
417
  const src = join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".grok", "auth.json");
399
418
  if (!exists(src)) return;
@@ -417,6 +436,7 @@ export async function reinjectGrokRunningTasks(deps: CodexDeps = {}): Promise<vo
417
436
  try {
418
437
  writeFileSync(tmp, brokered, { mode: 0o600 });
419
438
  for (const container of active.filter((c) => running.has(c))) {
439
+ if (!onOwnCredentials()) break;
420
440
  const steps: Array<[string[], string]> = [
421
441
  [["exec", "-u", "root", ...runtimeAuthorityDockerExecArgs(), container, "/bin/mkdir", "-p", "/home/node/.grok"], "mkdir"],
422
442
  [["cp", tmp, `${container}:/home/node/.grok/auth.json`], "docker cp auth.json (brokered)"],
@@ -466,6 +466,82 @@ export async function claimContainerRuntimeForTask(
466
466
  );
467
467
  }
468
468
 
469
+ let recheckRequester: ((reason: string) => void) | null = null;
470
+
471
+ /** Who rechecks the runtime when work outside its activation asks: the
472
+ * operator's recheck (main), which fences new work, re-probes, and runs
473
+ * activation again, image maintenance (whose wedge sensor recycles a wedged
474
+ * Apple runtime) and every persisted task's recovery. */
475
+ export function setContainerRuntimeRecheckRequester(requester: ((reason: string) => void) | null): void {
476
+ recheckRequester = requester;
477
+ }
478
+
479
+ /**
480
+ * The requester main installs (#p2jdax). A forced recheck is never started
481
+ * over an activation under way: that would only make it stale. So:
482
+ *
483
+ * - an ask while an activation (with its retries) is under way is kept, and
484
+ * asked again once that activation is done, since it may come after its
485
+ * maintenance looked for a wedge; asks kept meanwhile become one;
486
+ * - an ask while only a forced recheck is under way joins it: the
487
+ * activation it begins runs its maintenance after this ask;
488
+ * - otherwise it starts the operator's forced recheck.
489
+ */
490
+ export function containerRuntimeRecheckRequester(deps: {
491
+ activationUnderWay: () => boolean;
492
+ activationSettled: () => Promise<void>;
493
+ recheck: () => Promise<void>;
494
+ warn?: (line: string) => void;
495
+ }): (reason: string) => void {
496
+ const warn = deps.warn ?? ((line: string) => console.warn(line));
497
+ let underWay: Promise<void> | null = null;
498
+ let kept: string | null = null;
499
+ const ask = (reason: string): void => {
500
+ if (deps.activationUnderWay()) {
501
+ if (kept !== null) {
502
+ warn(`[host-agent] a container runtime recheck is already kept for after the activation under way (${reason})`);
503
+ return;
504
+ }
505
+ kept = reason;
506
+ warn(`[host-agent] a container runtime activation is under way; rechecking once it is done (${reason})`);
507
+ void deps.activationSettled().then(() => {
508
+ const next = kept;
509
+ kept = null;
510
+ if (next !== null) ask(next);
511
+ });
512
+ return;
513
+ }
514
+ if (underWay) {
515
+ warn(`[host-agent] a container runtime recheck is under way (${reason})`);
516
+ return;
517
+ }
518
+ warn(`[host-agent] rechecking the container runtime: ${reason}`);
519
+ underWay = deps
520
+ .recheck()
521
+ .catch((error: unknown) => {
522
+ warn(
523
+ `[host-agent] the requested container runtime recheck failed: ${error instanceof Error ? error.message : String(error)}`,
524
+ );
525
+ })
526
+ .finally(() => {
527
+ underWay = null;
528
+ });
529
+ };
530
+ return ask;
531
+ }
532
+
533
+ /**
534
+ * Ask the runtime's owner to recheck it (#p2jdax): what work outside
535
+ * activation does instead of restarting the runtime's services itself,
536
+ * which would stop every task's VM with nothing to recover them. Returns
537
+ * whether anyone was there to ask.
538
+ */
539
+ export function requestContainerRuntimeRecheck(reason: string): boolean {
540
+ if (!recheckRequester) return false;
541
+ recheckRequester(reason);
542
+ return true;
543
+ }
544
+
469
545
  /** Explicit operator recovery: invalidate even a stale ready verdict before
470
546
  * probing so a healthy ready-to-ready result still requires a full activation
471
547
  * and persisted-task recovery pass. */
@@ -0,0 +1,405 @@
1
+ /**
2
+ * ADR-154's durable, non-secret inventory of task-local credential paths.
3
+ *
4
+ * Writers remember a path before placing credential material there. Cleanup
5
+ * later removes the row only after it has independently verified the path is
6
+ * absent. This module records the witness; it deliberately does not perform
7
+ * cleanup or inspect credential contents.
8
+ */
9
+ import { and, asc, eq, lte, sql } from "drizzle-orm";
10
+
11
+ import { idPathSegment } from "../src/cloud-credentials";
12
+ import type {
13
+ HostCredentialCleanupPath,
14
+ HostCredentialCleanupPending,
15
+ HostLeasedEngineRuntime,
16
+ } from "../db/schema";
17
+ import { getDb, schema, type Db } from "./db";
18
+ import { assertSafeHostTaskId } from "./task-identity";
19
+ import {
20
+ LEASE_AWS_CONFIG_FILE,
21
+ LEASE_CREDENTIALS_FILE,
22
+ } from "./lease";
23
+ import {
24
+ serializeTaskEnvironmentLocator,
25
+ type TaskEnvironmentLocator,
26
+ } from "./task-environment/types";
27
+
28
+ type InventoryExecutor = Pick<Db, "select" | "insert" | "delete">;
29
+
30
+ export interface CredentialCleanupPathKey {
31
+ taskId: string;
32
+ runEpoch: number;
33
+ runtime: TaskEnvironmentLocator;
34
+ lane: CredentialCleanupLane;
35
+ path: string;
36
+ }
37
+
38
+ export type CredentialCleanupLane =
39
+ | "engine-auth"
40
+ | "github"
41
+ | "ssh"
42
+ | "files-session";
43
+
44
+ export type CredentialCleanupPendingReason =
45
+ | "removed"
46
+ | "denied"
47
+ | "expired"
48
+ | "assignment"
49
+ | "restart"
50
+ | "stop"
51
+ | "park"
52
+ | "teardown";
53
+
54
+ const CLEANUP_PENDING_REASONS = new Set<CredentialCleanupPendingReason>([
55
+ "removed",
56
+ "denied",
57
+ "expired",
58
+ "assignment",
59
+ "restart",
60
+ "stop",
61
+ "park",
62
+ "teardown",
63
+ ]);
64
+
65
+ export interface CredentialCleanupPendingInput {
66
+ taskId: string;
67
+ runEpoch: number;
68
+ reason: CredentialCleanupPendingReason;
69
+ }
70
+
71
+ export interface CredentialCleanupPathInput extends CredentialCleanupPathKey {
72
+ credentialId: string;
73
+ }
74
+
75
+ function positiveRunEpoch(value: number): void {
76
+ if (!Number.isSafeInteger(value) || value < 1) {
77
+ throw new Error("credential cleanup run epoch must be a positive safe integer");
78
+ }
79
+ }
80
+
81
+ function normalizedPending(input: CredentialCleanupPendingInput): void {
82
+ assertSafeHostTaskId(input.taskId);
83
+ positiveRunEpoch(input.runEpoch);
84
+ if (!CLEANUP_PENDING_REASONS.has(input.reason)) {
85
+ throw new Error("credential cleanup reason is invalid");
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Durable fail-closed witness written before cleanup closes any gate or starts
91
+ * any asynchronous work. A newer assignment can only raise the recorded
92
+ * epoch; a delayed older cleanup must never move the boot-recovery floor back.
93
+ */
94
+ export function rememberCredentialCleanupPending(
95
+ input: CredentialCleanupPendingInput,
96
+ db: InventoryExecutor = getDb(),
97
+ now = Date.now(),
98
+ ): void {
99
+ normalizedPending(input);
100
+ db.insert(schema.hostCredentialCleanupPending)
101
+ .values({
102
+ taskId: input.taskId,
103
+ runEpoch: input.runEpoch,
104
+ reason: input.reason,
105
+ createdAt: now,
106
+ updatedAt: now,
107
+ })
108
+ .onConflictDoUpdate({
109
+ target: schema.hostCredentialCleanupPending.taskId,
110
+ set: {
111
+ runEpoch: sql`max(${schema.hostCredentialCleanupPending.runEpoch}, excluded.run_epoch)`,
112
+ reason: sql`CASE WHEN excluded.run_epoch >= ${schema.hostCredentialCleanupPending.runEpoch} THEN excluded.reason ELSE ${schema.hostCredentialCleanupPending.reason} END`,
113
+ updatedAt: now,
114
+ },
115
+ })
116
+ .run();
117
+ }
118
+
119
+ export function listCredentialCleanupPending(
120
+ db: InventoryExecutor = getDb(),
121
+ ): HostCredentialCleanupPending[] {
122
+ return db
123
+ .select()
124
+ .from(schema.hostCredentialCleanupPending)
125
+ .orderBy(asc(schema.hostCredentialCleanupPending.taskId))
126
+ .all();
127
+ }
128
+
129
+ /** Clear only a marker no newer than the cleanup that just succeeded. */
130
+ export function forgetCredentialCleanupPending(
131
+ taskId: string,
132
+ throughRunEpoch: number,
133
+ db: InventoryExecutor = getDb(),
134
+ ): void {
135
+ assertSafeHostTaskId(taskId);
136
+ positiveRunEpoch(throughRunEpoch);
137
+ db.delete(schema.hostCredentialCleanupPending)
138
+ .where(
139
+ and(
140
+ eq(schema.hostCredentialCleanupPending.taskId, taskId),
141
+ lte(schema.hostCredentialCleanupPending.runEpoch, throughRunEpoch),
142
+ ),
143
+ )
144
+ .run();
145
+ }
146
+
147
+ /** An engine account's file, or the intermediate its writer stages
148
+ * (`.uai-tmp`) before renaming it into place. */
149
+ const ENGINE_AUTH_PATH = /^\/home\/node\/(?:\.codex(?:-acct-[A-Za-z0-9_-]+)?(?:-agent-[A-Za-z0-9_-]+)?|\.grok(?:-acct-[A-Za-z0-9_-]+)?|\.local\/share\/opencode(?:-acct-[A-Za-z0-9_-]+)?)\/auth\.json(?:\.uai-tmp)?$/;
150
+ /** A file a writer stages (`.uai-tmp`) before renaming it into place. */
151
+ function andStaged(...paths: string[]): string[] {
152
+ return paths.flatMap((path) => [path, `${path}.uai-tmp`]);
153
+ }
154
+ const GITHUB_PATHS = new Set(
155
+ andStaged("/home/node/.config/gh/hosts.yml", "/home/node/.config/gh/uai.gitconfig"),
156
+ );
157
+ const SSH_PATHS = new Set(
158
+ andStaged(
159
+ "/home/node/.ssh/id_ed25519",
160
+ "/home/node/.ssh/id_ed25519.pub",
161
+ "/home/node/.ssh/allowed_signers",
162
+ "/home/node/.ssh/uai-signing.gitconfig",
163
+ "/home/node/.ssh/uai-transport.gitconfig",
164
+ ),
165
+ );
166
+ const FILES_SESSION_PATHS = new Set([
167
+ LEASE_CREDENTIALS_FILE,
168
+ LEASE_AWS_CONFIG_FILE,
169
+ ]);
170
+
171
+ /** Destructive cleanup accepts only lane-owned files. The executor repeats
172
+ * this check after reconstructing the runtime and resolves every parent
173
+ * symlink-safely before deletion; persistence never turns an arbitrary task
174
+ * path into cleanup authority. */
175
+ export function assertCredentialCleanupPath(
176
+ lane: CredentialCleanupLane,
177
+ value: string,
178
+ ): void {
179
+ if (
180
+ typeof value !== "string" ||
181
+ value.length === 0 ||
182
+ value.length > 4096 ||
183
+ value.includes("\0") ||
184
+ value.split("/").some((part) => part === "." || part === "..")
185
+ ) {
186
+ throw new Error("credential cleanup path is invalid");
187
+ }
188
+ const allowed =
189
+ (lane === "engine-auth" && ENGINE_AUTH_PATH.test(value)) ||
190
+ (lane === "github" && GITHUB_PATHS.has(value)) ||
191
+ (lane === "ssh" && SSH_PATHS.has(value)) ||
192
+ (lane === "files-session" && FILES_SESSION_PATHS.has(value));
193
+ if (!allowed) {
194
+ throw new Error("credential cleanup path is outside its managed lane");
195
+ }
196
+ }
197
+
198
+ function normalizedKey(input: CredentialCleanupPathKey): {
199
+ taskId: string;
200
+ runEpoch: number;
201
+ runtimeLocator: string;
202
+ lane: CredentialCleanupLane;
203
+ path: string;
204
+ } {
205
+ assertSafeHostTaskId(input.taskId);
206
+ positiveRunEpoch(input.runEpoch);
207
+ assertCredentialCleanupPath(input.lane, input.path);
208
+ return {
209
+ taskId: input.taskId,
210
+ runEpoch: input.runEpoch,
211
+ runtimeLocator: serializeTaskEnvironmentLocator(input.runtime),
212
+ lane: input.lane,
213
+ path: input.path,
214
+ };
215
+ }
216
+
217
+ /** Record before writing the credential path. Re-recording the same exact
218
+ * runtime path is idempotent and preserves its first-seen timestamp. */
219
+ export function rememberCredentialCleanupPath(
220
+ input: CredentialCleanupPathInput,
221
+ db: InventoryExecutor = getDb(),
222
+ now = Date.now(),
223
+ ): void {
224
+ // The shared path encoder has the contract's exact bounded id grammar.
225
+ idPathSegment(input.credentialId);
226
+ const key = normalizedKey(input);
227
+ db.insert(schema.hostCredentialCleanupPaths)
228
+ .values({
229
+ ...key,
230
+ credentialId: input.credentialId,
231
+ createdAt: now,
232
+ updatedAt: now,
233
+ })
234
+ .onConflictDoUpdate({
235
+ target: [
236
+ schema.hostCredentialCleanupPaths.taskId,
237
+ schema.hostCredentialCleanupPaths.runEpoch,
238
+ schema.hostCredentialCleanupPaths.runtimeLocator,
239
+ schema.hostCredentialCleanupPaths.path,
240
+ ],
241
+ set: { credentialId: input.credentialId, updatedAt: now },
242
+ })
243
+ .run();
244
+ }
245
+
246
+ /** All outstanding cleanup work for a task, oldest assignment first. */
247
+ export function listCredentialCleanupPaths(
248
+ taskId: string,
249
+ db: InventoryExecutor = getDb(),
250
+ ): HostCredentialCleanupPath[] {
251
+ assertSafeHostTaskId(taskId);
252
+ return db
253
+ .select()
254
+ .from(schema.hostCredentialCleanupPaths)
255
+ .where(eq(schema.hostCredentialCleanupPaths.taskId, taskId))
256
+ .orderBy(
257
+ asc(schema.hostCredentialCleanupPaths.runEpoch),
258
+ asc(schema.hostCredentialCleanupPaths.runtimeLocator),
259
+ asc(schema.hostCredentialCleanupPaths.path),
260
+ )
261
+ .all();
262
+ }
263
+
264
+ /** Boot recovery must not depend on a current manifest or even a surviving
265
+ * host-task row. Return the entire non-secret worklist in deterministic order. */
266
+ export function listAllCredentialCleanupPaths(
267
+ db: InventoryExecutor = getDb(),
268
+ ): HostCredentialCleanupPath[] {
269
+ return db
270
+ .select()
271
+ .from(schema.hostCredentialCleanupPaths)
272
+ .orderBy(
273
+ asc(schema.hostCredentialCleanupPaths.taskId),
274
+ asc(schema.hostCredentialCleanupPaths.runEpoch),
275
+ asc(schema.hostCredentialCleanupPaths.runtimeLocator),
276
+ asc(schema.hostCredentialCleanupPaths.path),
277
+ )
278
+ .all();
279
+ }
280
+
281
+ /**
282
+ * Value-free boot worklist. A marker keeps a task recoverable even when a
283
+ * crash happened before its first path was inventoried, or after every path
284
+ * was deleted but before in-memory material was dropped.
285
+ */
286
+ export function listCredentialCleanupRecoveryTasks(
287
+ db: InventoryExecutor = getDb(),
288
+ ): Array<{ taskId: string; runEpoch: number }> {
289
+ const tasks = new Map<string, number>();
290
+ for (const pending of listCredentialCleanupPending(db)) {
291
+ tasks.set(pending.taskId, pending.runEpoch);
292
+ }
293
+ for (const row of listAllCredentialCleanupPaths(db)) {
294
+ tasks.set(row.taskId, Math.max(tasks.get(row.taskId) ?? 0, row.runEpoch));
295
+ }
296
+ // A leased engine on an env-token account leaves no path: the record of
297
+ // where it ran is the evidence its task needs a cleanup after a crash.
298
+ for (const row of listAllLeasedEngineRuntimes(db)) {
299
+ tasks.set(row.taskId, Math.max(tasks.get(row.taskId) ?? 0, row.runEpoch));
300
+ }
301
+ return [...tasks]
302
+ .sort(([left], [right]) => left.localeCompare(right))
303
+ .map(([taskId, runEpoch]) => ({ taskId, runEpoch }));
304
+ }
305
+
306
+ /** Persistence primitive for the later cleanup executor. The executor is
307
+ * responsible for proving absence before it calls this. */
308
+ export function forgetCredentialCleanupPath(
309
+ input: CredentialCleanupPathKey,
310
+ db: InventoryExecutor = getDb(),
311
+ ): void {
312
+ const key = normalizedKey(input);
313
+ db.delete(schema.hostCredentialCleanupPaths)
314
+ .where(
315
+ and(
316
+ eq(schema.hostCredentialCleanupPaths.taskId, key.taskId),
317
+ eq(schema.hostCredentialCleanupPaths.runEpoch, key.runEpoch),
318
+ eq(
319
+ schema.hostCredentialCleanupPaths.runtimeLocator,
320
+ key.runtimeLocator,
321
+ ),
322
+ eq(schema.hostCredentialCleanupPaths.lane, key.lane),
323
+ eq(schema.hostCredentialCleanupPaths.path, key.path),
324
+ ),
325
+ )
326
+ .run();
327
+ }
328
+
329
+ /**
330
+ * ADR-154 §6: record, before a leased engine starts in it, the runtime it
331
+ * starts in for this run, so the cleanup proves its engines stopped there
332
+ * even when they left no file to inventory. Idempotent.
333
+ */
334
+ export function rememberLeasedEngineRuntime(
335
+ input: { taskId: string; runEpoch: number; runtime: TaskEnvironmentLocator },
336
+ db: InventoryExecutor = getDb(),
337
+ now = Date.now(),
338
+ ): void {
339
+ assertSafeHostTaskId(input.taskId);
340
+ positiveRunEpoch(input.runEpoch);
341
+ db.insert(schema.hostLeasedEngineRuntimes)
342
+ .values({
343
+ taskId: input.taskId,
344
+ runEpoch: input.runEpoch,
345
+ runtimeLocator: serializeTaskEnvironmentLocator(input.runtime),
346
+ createdAt: now,
347
+ })
348
+ .onConflictDoNothing()
349
+ .run();
350
+ }
351
+
352
+ /** Every runtime a leased engine of the task was started in, oldest run first. */
353
+ export function listLeasedEngineRuntimes(
354
+ taskId: string,
355
+ db: InventoryExecutor = getDb(),
356
+ ): HostLeasedEngineRuntime[] {
357
+ assertSafeHostTaskId(taskId);
358
+ return db
359
+ .select()
360
+ .from(schema.hostLeasedEngineRuntimes)
361
+ .where(eq(schema.hostLeasedEngineRuntimes.taskId, taskId))
362
+ .orderBy(
363
+ asc(schema.hostLeasedEngineRuntimes.runEpoch),
364
+ asc(schema.hostLeasedEngineRuntimes.runtimeLocator),
365
+ )
366
+ .all();
367
+ }
368
+
369
+ /** Every runtime a leased engine of any task was started in. */
370
+ export function listAllLeasedEngineRuntimes(
371
+ db: InventoryExecutor = getDb(),
372
+ ): HostLeasedEngineRuntime[] {
373
+ return db
374
+ .select()
375
+ .from(schema.hostLeasedEngineRuntimes)
376
+ .orderBy(
377
+ asc(schema.hostLeasedEngineRuntimes.taskId),
378
+ asc(schema.hostLeasedEngineRuntimes.runEpoch),
379
+ asc(schema.hostLeasedEngineRuntimes.runtimeLocator),
380
+ )
381
+ .all();
382
+ }
383
+
384
+ /** After a whole-task cleanup proved every engine of these runs stopped in
385
+ * these runtimes: forget them, never a newer run's. */
386
+ export function forgetLeasedEngineRuntimes(
387
+ taskId: string,
388
+ throughRunEpoch: number,
389
+ runtimeLocators: readonly string[],
390
+ db: InventoryExecutor = getDb(),
391
+ ): void {
392
+ assertSafeHostTaskId(taskId);
393
+ positiveRunEpoch(throughRunEpoch);
394
+ for (const runtimeLocator of runtimeLocators) {
395
+ db.delete(schema.hostLeasedEngineRuntimes)
396
+ .where(
397
+ and(
398
+ eq(schema.hostLeasedEngineRuntimes.taskId, taskId),
399
+ lte(schema.hostLeasedEngineRuntimes.runEpoch, throughRunEpoch),
400
+ eq(schema.hostLeasedEngineRuntimes.runtimeLocator, runtimeLocator),
401
+ ),
402
+ )
403
+ .run();
404
+ }
405
+ }