@runuai/host 0.9.14 → 0.9.43

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 (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +33 -2
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3051 -200
  53. package/lib/preview-sidecar.ts +68 -14
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +956 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +459 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1405 -107
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +766 -42
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -0,0 +1,459 @@
1
+ import path from "node:path";
2
+
3
+ import { eq } from "drizzle-orm";
4
+
5
+ import { dockerCli, type DockerResult } from "../docker-exec";
6
+ import { getDb, schema, type HostTask } from "../db";
7
+ import { parsePreviewPortRuntimes } from "../preview-ports";
8
+ import { PREVIEW_SIDECAR_TASK_LABEL } from "../preview-sidecar";
9
+ import { assertSafeHostTaskId } from "../task-identity";
10
+ import {
11
+ APPLE_TASK_ENVIRONMENT_PROVIDER,
12
+ parseAppleTaskEnvironmentLocator,
13
+ } from "./apple-container";
14
+ import {
15
+ dockerTaskEnvironmentLocator,
16
+ parseDockerTaskEnvironmentLocator,
17
+ type DockerMachineIdentity,
18
+ } from "./docker";
19
+ import type { HostMachineIdentity } from "./index";
20
+ import {
21
+ parseTaskEnvironmentLocator,
22
+ serializeTaskEnvironmentLocator,
23
+ type TaskEnvironmentLocator,
24
+ } from "./types";
25
+
26
+ const INSPECT_OUTPUT_LIMIT = 128 * 1024;
27
+ const INVENTORY_OUTPUT_LIMIT = 128 * 1024;
28
+ const LEGACY_INSPECT_FORMAT =
29
+ '{{json .Id}}\t{{json .Name}}\t{{json (index .Config.Labels "com.docker.compose.project")}}\t{{json (index .Config.Labels "com.docker.compose.service")}}\t{{json .State.Status}}\t{{json .Mounts}}';
30
+ // The third column is the preview-sidecar marker: a sidecar can wear the
31
+ // task's compose project/service labels (teardown finds it by project), so
32
+ // the app-container census must see and skip it — counting one as an app
33
+ // container failed the WHOLE runtime adoption closed (live 2026-08-18).
34
+ const LEGACY_INVENTORY_FORMAT = `{{.ID}}\t{{.Names}}\t{{.Label "${PREVIEW_SIDECAR_TASK_LABEL}"}}`;
35
+
36
+ type LegacyTaskRow = Pick<
37
+ HostTask,
38
+ | "taskId"
39
+ | "composeProject"
40
+ | "worktreePath"
41
+ | "environmentProvider"
42
+ | "environmentLocator"
43
+ | "codeServerPort"
44
+ | "previewPorts"
45
+ >;
46
+
47
+ interface LegacyCandidate {
48
+ row: LegacyTaskRow;
49
+ composeProject: string;
50
+ containerName: string;
51
+ workspaceSource: string;
52
+ }
53
+
54
+ interface LegacyProof {
55
+ containerId: string;
56
+ containerName: string;
57
+ }
58
+
59
+ interface LegacyAdoption {
60
+ snapshot: LegacyTaskRow;
61
+ locator: TaskEnvironmentLocator;
62
+ }
63
+
64
+ export interface LegacyTaskEnvironmentAdoptionVerdict {
65
+ ok: boolean;
66
+ detail?: string;
67
+ }
68
+
69
+ export interface LegacyTaskEnvironmentAdoptionDeps {
70
+ docker?: typeof dockerCli;
71
+ loadTasks?: () => ReadonlyArray<LegacyTaskRow>;
72
+ /** Must atomically re-probe and return the still-selected daemon identity. */
73
+ reprobeMachine: () => Promise<HostMachineIdentity | null>;
74
+ /** Test seam. Production uses one synchronous SQLite transaction. */
75
+ persist?: (adoptions: ReadonlyArray<LegacyAdoption>) => void;
76
+ }
77
+
78
+ /**
79
+ * Adopt pre-provider rows without guessing which daemon owns them.
80
+ *
81
+ * Every Docker operation here is read-only. No locator is written until all
82
+ * legacy resources have an exact container proof and a final atomic daemon-ID
83
+ * re-probe confirms that the inspected engine is still the selected engine.
84
+ */
85
+ export async function adoptLegacyTaskEnvironments(
86
+ machine: HostMachineIdentity,
87
+ deps: LegacyTaskEnvironmentAdoptionDeps,
88
+ ): Promise<LegacyTaskEnvironmentAdoptionVerdict> {
89
+ const rows = [...(deps.loadTasks?.() ?? loadHostTasks())];
90
+ const candidates: LegacyCandidate[] = [];
91
+ try {
92
+ for (const row of rows) {
93
+ const candidate = legacyCandidate(row, machine);
94
+ if (candidate) candidates.push(candidate);
95
+ }
96
+ } catch (error) {
97
+ return failedAdoption(error);
98
+ }
99
+ if (candidates.length === 0) return { ok: true };
100
+ if (machine.backend !== "docker") {
101
+ // legacyCandidate already failed such rows; this narrows the type and
102
+ // stays fail-closed if that invariant ever changes.
103
+ return failedAdoption(
104
+ new Error(
105
+ `legacy ambient-Docker resources cannot move to ${machine.backend}`,
106
+ ),
107
+ );
108
+ }
109
+
110
+ const docker = deps.docker ?? dockerCli;
111
+ const adoptions: LegacyAdoption[] = [];
112
+ try {
113
+ for (const candidate of candidates) {
114
+ const proof = await inspectLegacyCandidate(docker, candidate);
115
+ await proveNoConflictingAppContainer(docker, candidate, proof);
116
+ const row = candidate.row;
117
+ const locator = dockerTaskEnvironmentLocator(
118
+ row.taskId,
119
+ {
120
+ composeProject: candidate.composeProject,
121
+ worktreePath: row.worktreePath!,
122
+ ...(row.codeServerPort === null
123
+ ? {}
124
+ : { codeServerPort: row.codeServerPort }),
125
+ previewPorts: parsePreviewPortRuntimes(row.previewPorts),
126
+ },
127
+ machine,
128
+ );
129
+ adoptions.push({ snapshot: row, locator });
130
+ }
131
+
132
+ const reproved = await deps.reprobeMachine();
133
+ if (!sameMachine(machine, reproved)) {
134
+ throw new Error(
135
+ "the Docker daemon identity changed during legacy resource inspection",
136
+ );
137
+ }
138
+ (deps.persist ?? persistLegacyAdoptions)(adoptions);
139
+ return { ok: true };
140
+ } catch (error) {
141
+ return failedAdoption(error);
142
+ }
143
+ }
144
+
145
+ function loadHostTasks(): LegacyTaskRow[] {
146
+ return getDb().select().from(schema.hostTasks).all();
147
+ }
148
+
149
+ function legacyCandidate(
150
+ row: LegacyTaskRow,
151
+ machine: HostMachineIdentity,
152
+ ): LegacyCandidate | null {
153
+ if ((row.environmentProvider === null) !== (row.environmentLocator === null)) {
154
+ throw new Error(`task ${row.taskId} has incomplete provider metadata`);
155
+ }
156
+ if (row.environmentProvider !== null) {
157
+ const locator = parseTaskEnvironmentLocator(row.environmentLocator!);
158
+ if (locator.provider !== row.environmentProvider) {
159
+ throw new Error(
160
+ `task ${row.taskId} provider does not match its persisted locator`,
161
+ );
162
+ }
163
+ // A row with provider metadata is already adopted, whatever its backend:
164
+ // it only needs to belong to the currently-selected machine. Hard-coding
165
+ // docker-compose here wedged the first fresh Apple host (live
166
+ // 2026-08-15): a crash-preserved prepared apple locator made adoption —
167
+ // and with it runtime selection — fail closed, so the task could not
168
+ // even be settled.
169
+ let persistedMachine: HostMachineIdentity;
170
+ if (locator.provider === "docker-compose") {
171
+ persistedMachine = parseDockerTaskEnvironmentLocator(locator).machine;
172
+ } else if (locator.provider === APPLE_TASK_ENVIRONMENT_PROVIDER) {
173
+ persistedMachine = parseAppleTaskEnvironmentLocator(locator).machine;
174
+ } else {
175
+ throw new Error(
176
+ `task ${row.taskId} belongs to unavailable environment provider ${locator.provider}`,
177
+ );
178
+ }
179
+ if (!sameMachine(machine, persistedMachine)) {
180
+ throw new Error(
181
+ `task ${row.taskId} belongs to a different container backend identity`,
182
+ );
183
+ }
184
+ return null;
185
+ }
186
+ if (row.composeProject === null && row.worktreePath === null) return null;
187
+ if (row.composeProject === null || row.worktreePath === null) {
188
+ throw new Error(`task ${row.taskId} has incomplete legacy runtime identity`);
189
+ }
190
+ if (machine.backend !== "docker") {
191
+ throw new Error(
192
+ `task ${row.taskId} has legacy ambient-Docker resources and cannot move to ${machine.backend}`,
193
+ );
194
+ }
195
+ assertSafeHostTaskId(row.taskId);
196
+ const composeProject = `task-${row.taskId}`;
197
+ if (row.composeProject !== composeProject) {
198
+ throw new Error(`task ${row.taskId} has an unexpected legacy Compose project`);
199
+ }
200
+ if (
201
+ row.worktreePath.length === 0 ||
202
+ row.worktreePath.length > 4_096 ||
203
+ !path.isAbsolute(row.worktreePath) ||
204
+ path.resolve(row.worktreePath) !== row.worktreePath ||
205
+ /[\0\r\n]/.test(row.worktreePath)
206
+ ) {
207
+ throw new Error(`task ${row.taskId} has an unsafe legacy worktree path`);
208
+ }
209
+ return {
210
+ row,
211
+ composeProject,
212
+ containerName: `${composeProject}-app-1`,
213
+ workspaceSource: path.join(row.worktreePath, "workspace"),
214
+ };
215
+ }
216
+
217
+ async function inspectLegacyCandidate(
218
+ docker: typeof dockerCli,
219
+ candidate: LegacyCandidate,
220
+ ): Promise<LegacyProof | null> {
221
+ const result = await docker(
222
+ [
223
+ "inspect",
224
+ "--type",
225
+ "container",
226
+ "--format",
227
+ LEGACY_INSPECT_FORMAT,
228
+ candidate.containerName,
229
+ ],
230
+ { timeoutMs: 30_000, maxOutputBytes: INSPECT_OUTPUT_LIMIT },
231
+ );
232
+ // Exact absence is a settleable answer, not a failure: a pruned container
233
+ // must not strand the host in a degraded runtime with teardown fenced off.
234
+ // Only Docker's literal not-found error qualifies — a daemon that cannot
235
+ // answer still fails adoption closed.
236
+ if (
237
+ result.status !== 0 &&
238
+ /No such (?:container|object)/i.test(result.stderr)
239
+ ) {
240
+ return null;
241
+ }
242
+ const fields = exactTabFields(result, 6, "legacy app inspect");
243
+ const [containerId, containerName, composeProject, service, status, mounts] =
244
+ fields.map(parseJsonField);
245
+ if (!isContainerId(containerId)) {
246
+ throw new Error(`task ${candidate.row.taskId} returned an invalid container ID`);
247
+ }
248
+ if (containerName !== `/${candidate.containerName}`) {
249
+ throw new Error(`task ${candidate.row.taskId} app container name does not match`);
250
+ }
251
+ if (composeProject !== candidate.composeProject || service !== "app") {
252
+ throw new Error(`task ${candidate.row.taskId} Compose labels do not match`);
253
+ }
254
+ if (
255
+ typeof status !== "string" ||
256
+ ![
257
+ "created",
258
+ "running",
259
+ "paused",
260
+ "restarting",
261
+ "removing",
262
+ "exited",
263
+ "dead",
264
+ ].includes(status)
265
+ ) {
266
+ throw new Error(`task ${candidate.row.taskId} container state is invalid`);
267
+ }
268
+ validateWorkspaceMount(candidate, mounts);
269
+ return { containerId, containerName: candidate.containerName };
270
+ }
271
+
272
+ function validateWorkspaceMount(candidate: LegacyCandidate, value: unknown): void {
273
+ if (!Array.isArray(value) || value.length > 256) {
274
+ throw new Error(`task ${candidate.row.taskId} container mounts are invalid`);
275
+ }
276
+ const workspaceMounts = value.filter((entry) => {
277
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
278
+ const destination = (entry as Record<string, unknown>).Destination;
279
+ return (
280
+ typeof destination === "string" &&
281
+ path.posix.normalize(destination) === "/workspace"
282
+ );
283
+ });
284
+ if (workspaceMounts.length !== 1) {
285
+ throw new Error(
286
+ `task ${candidate.row.taskId} must have exactly one /workspace mount`,
287
+ );
288
+ }
289
+ const mount = workspaceMounts[0] as Record<string, unknown>;
290
+ if (
291
+ mount.Type !== "bind" ||
292
+ mount.Destination !== "/workspace" ||
293
+ mount.Source !== candidate.workspaceSource
294
+ ) {
295
+ throw new Error(`task ${candidate.row.taskId} workspace bind does not match`);
296
+ }
297
+ }
298
+
299
+ async function proveNoConflictingAppContainer(
300
+ docker: typeof dockerCli,
301
+ candidate: LegacyCandidate,
302
+ expected: LegacyProof | null,
303
+ ): Promise<void> {
304
+ const result = await docker(
305
+ [
306
+ "ps",
307
+ "--all",
308
+ "--no-trunc",
309
+ "--filter",
310
+ `label=com.docker.compose.project=${candidate.composeProject}`,
311
+ "--filter",
312
+ "label=com.docker.compose.service=app",
313
+ "--format",
314
+ LEGACY_INVENTORY_FORMAT,
315
+ ],
316
+ { timeoutMs: 30_000, maxOutputBytes: INVENTORY_OUTPUT_LIMIT },
317
+ );
318
+ if (
319
+ result.status !== 0 ||
320
+ result.outputTruncated === true ||
321
+ Buffer.byteLength(result.stdout) > INVENTORY_OUTPUT_LIMIT
322
+ ) {
323
+ throw new Error(`task ${candidate.row.taskId} app inventory failed`);
324
+ }
325
+ if (result.stdout !== "" && !result.stdout.endsWith("\n")) {
326
+ throw new Error(`task ${candidate.row.taskId} app inventory failed`);
327
+ }
328
+ const appRows: string[][] = [];
329
+ for (const line of result.stdout === ""
330
+ ? []
331
+ : result.stdout.slice(0, -1).split("\n")) {
332
+ const fields = line.split("\t");
333
+ if (fields.length !== 3) {
334
+ throw new Error(`task ${candidate.row.taskId} app inventory failed`);
335
+ }
336
+ // A preview sidecar wearing the task's compose labels is not an app
337
+ // container; counting one here failed the whole runtime adoption closed
338
+ // (live 2026-08-18, six labeled browser sidecars on the Studio).
339
+ if (fields[2] !== "") continue;
340
+ appRows.push(fields);
341
+ }
342
+ if (expected === null) {
343
+ // The exact container is proven absent; adoption is sound only if no
344
+ // same-project replacement exists either.
345
+ if (appRows.length !== 0) {
346
+ throw new Error(
347
+ `task ${candidate.row.taskId} has conflicting app containers`,
348
+ );
349
+ }
350
+ return;
351
+ }
352
+ if (appRows.length !== 1) {
353
+ throw new Error(`task ${candidate.row.taskId} has conflicting app containers`);
354
+ }
355
+ const fields = appRows[0]!;
356
+ if (
357
+ fields[0] !== expected.containerId ||
358
+ fields[1] !== expected.containerName
359
+ ) {
360
+ throw new Error(`task ${candidate.row.taskId} app inventory does not match`);
361
+ }
362
+ }
363
+
364
+ function exactTabFields(
365
+ result: DockerResult,
366
+ count: number,
367
+ operation: string,
368
+ ): string[] {
369
+ if (
370
+ result.status !== 0 ||
371
+ result.outputTruncated === true ||
372
+ Buffer.byteLength(result.stdout) > INSPECT_OUTPUT_LIMIT ||
373
+ !result.stdout.endsWith("\n") ||
374
+ result.stdout.slice(0, -1).includes("\n")
375
+ ) {
376
+ throw new Error(`${operation} failed`);
377
+ }
378
+ const fields = result.stdout.slice(0, -1).split("\t");
379
+ if (fields.length !== count || fields.some((field) => field.length === 0)) {
380
+ throw new Error(`${operation} output is malformed`);
381
+ }
382
+ return fields;
383
+ }
384
+
385
+ function parseJsonField(value: string): unknown {
386
+ try {
387
+ return JSON.parse(value);
388
+ } catch {
389
+ throw new Error("legacy app inspect output is malformed");
390
+ }
391
+ }
392
+
393
+ function isContainerId(value: unknown): value is string {
394
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
395
+ }
396
+
397
+ function sameMachine(
398
+ expected: HostMachineIdentity,
399
+ actual: HostMachineIdentity | null,
400
+ ): boolean {
401
+ return (
402
+ actual !== null &&
403
+ actual.backend === expected.backend &&
404
+ actual.endpoint === expected.endpoint &&
405
+ actual.engineId === expected.engineId
406
+ );
407
+ }
408
+
409
+ function persistLegacyAdoptions(
410
+ adoptions: ReadonlyArray<LegacyAdoption>,
411
+ ): void {
412
+ getDb().transaction((tx) => {
413
+ for (const adoption of adoptions) {
414
+ const current = tx
415
+ .select()
416
+ .from(schema.hostTasks)
417
+ .where(eq(schema.hostTasks.taskId, adoption.snapshot.taskId))
418
+ .get();
419
+ if (!current || !sameLegacySnapshot(current, adoption.snapshot)) {
420
+ throw new Error(
421
+ `task ${adoption.snapshot.taskId} changed during legacy adoption`,
422
+ );
423
+ }
424
+ tx.update(schema.hostTasks)
425
+ .set({
426
+ environmentProvider: adoption.locator.provider,
427
+ environmentLocator: serializeTaskEnvironmentLocator(adoption.locator),
428
+ updatedAt: Date.now(),
429
+ })
430
+ .where(eq(schema.hostTasks.taskId, adoption.snapshot.taskId))
431
+ .run();
432
+ }
433
+ });
434
+ }
435
+
436
+ function sameLegacySnapshot(current: LegacyTaskRow, expected: LegacyTaskRow): boolean {
437
+ return (
438
+ current.taskId === expected.taskId &&
439
+ current.composeProject === expected.composeProject &&
440
+ current.worktreePath === expected.worktreePath &&
441
+ current.environmentProvider === null &&
442
+ current.environmentLocator === null &&
443
+ current.codeServerPort === expected.codeServerPort &&
444
+ current.previewPorts === expected.previewPorts
445
+ );
446
+ }
447
+
448
+ function failedAdoption(error: unknown): LegacyTaskEnvironmentAdoptionVerdict {
449
+ const detail = (error instanceof Error ? error.message : String(error)).slice(
450
+ 0,
451
+ 512,
452
+ );
453
+ return {
454
+ ok: false,
455
+ detail:
456
+ `${detail}. Start the ambient Docker daemon that owns this task and run ` +
457
+ "`uai-host runtime recheck`, or settle/remove the task before switching runtimes.",
458
+ };
459
+ }
@@ -0,0 +1,58 @@
1
+ import type {
2
+ TaskEnvironmentLocator,
3
+ TaskEnvironmentProvider,
4
+ } from "./types";
5
+
6
+ /**
7
+ * Provider registry kept instance-based for tests and future multi-provider
8
+ * hosts. Registration is explicit; reconstructing an unknown/newer provider
9
+ * fails closed rather than guessing Docker.
10
+ */
11
+ export class TaskEnvironmentRegistry<
12
+ TInput,
13
+ TCredentials,
14
+ TResult,
15
+ TTeardownResult = void,
16
+ > {
17
+ readonly #providers = new Map<
18
+ string,
19
+ TaskEnvironmentProvider<TInput, TCredentials, TResult, TTeardownResult>
20
+ >();
21
+
22
+ register(
23
+ provider: TaskEnvironmentProvider<
24
+ TInput,
25
+ TCredentials,
26
+ TResult,
27
+ TTeardownResult
28
+ >,
29
+ ): () => void {
30
+ if (this.#providers.has(provider.kind)) {
31
+ throw new Error(`task environment provider ${provider.kind} is already registered`);
32
+ }
33
+ this.#providers.set(provider.kind, provider);
34
+ return () => {
35
+ if (this.#providers.get(provider.kind) === provider) {
36
+ this.#providers.delete(provider.kind);
37
+ }
38
+ };
39
+ }
40
+
41
+ provider(
42
+ kind: string,
43
+ ): TaskEnvironmentProvider<TInput, TCredentials, TResult, TTeardownResult> {
44
+ const provider = this.#providers.get(kind);
45
+ if (!provider) {
46
+ throw new Error(`task environment provider ${kind} is unavailable`);
47
+ }
48
+ return provider;
49
+ }
50
+
51
+ reconstruct(locator: TaskEnvironmentLocator) {
52
+ return this.provider(locator.provider).reconstruct(locator);
53
+ }
54
+
55
+ kinds(): string[] {
56
+ return [...this.#providers.keys()].sort();
57
+ }
58
+ }