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