@runuai/host 0.9.14 → 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 +33 -2
- 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 +3060 -218
- 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 +1405 -107
- 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 +766 -42
- 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,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-100 L2 authenticated task inventory.
|
|
3
|
+
*
|
|
4
|
+
* This is deliberately positive evidence: an unavailable Docker query can
|
|
5
|
+
* omit resources, but can never turn that omission into permission to delete.
|
|
6
|
+
* One immutable, sorted snapshot is retained for the active scan so duplicate
|
|
7
|
+
* cursor requests are idempotent while cleanup mutates the underlying host.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { lstatSync, opendirSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { sql } from "drizzle-orm";
|
|
14
|
+
|
|
15
|
+
import { dockerCli, type DockerResult } from "./docker-exec";
|
|
16
|
+
import { containerRuntimeProblem } from "./container-runtime";
|
|
17
|
+
import { env } from "./env";
|
|
18
|
+
import { getDb, schema } from "./db";
|
|
19
|
+
import {
|
|
20
|
+
MAX_HOST_TASK_INVENTORY_PAGE_SIZE,
|
|
21
|
+
MAX_HOST_TASK_INVENTORY_SCAN_ID_CHARS,
|
|
22
|
+
MAX_HOST_TASK_INVENTORY_TASK_ID_CHARS,
|
|
23
|
+
type HostTaskInventoryEntry,
|
|
24
|
+
type HostTaskInventorySource,
|
|
25
|
+
} from "../src/protocol";
|
|
26
|
+
import {
|
|
27
|
+
assertSafeHostTaskId,
|
|
28
|
+
isSafeHostTaskId,
|
|
29
|
+
} from "./task-identity";
|
|
30
|
+
import {
|
|
31
|
+
taskContainerBackend,
|
|
32
|
+
taskContainerCli,
|
|
33
|
+
} from "./task-container-cli";
|
|
34
|
+
import { appleTaskContainerName } from "./task-environment/apple-container";
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
assertSafeHostTaskId,
|
|
38
|
+
isSafeHostTaskId,
|
|
39
|
+
SAFE_HOST_TASK_ID,
|
|
40
|
+
} from "./task-identity";
|
|
41
|
+
|
|
42
|
+
const SOURCE_ORDER: readonly HostTaskInventorySource[] = [
|
|
43
|
+
"db",
|
|
44
|
+
"session",
|
|
45
|
+
"event",
|
|
46
|
+
"workspace",
|
|
47
|
+
"compose",
|
|
48
|
+
"network",
|
|
49
|
+
"volume",
|
|
50
|
+
"preview",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
|
|
54
|
+
const PREVIEW_TASK_LABEL = "com.runuai.preview-sidecar.task";
|
|
55
|
+
export const MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES = 10_000;
|
|
56
|
+
const MAX_HOST_TASK_INVENTORY_DOCKER_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
57
|
+
|
|
58
|
+
export function isSafeInventoryScanId(value: unknown): value is string {
|
|
59
|
+
return (
|
|
60
|
+
typeof value === "string" &&
|
|
61
|
+
value.length > 0 &&
|
|
62
|
+
value.length <= MAX_HOST_TASK_INVENTORY_SCAN_ID_CHARS &&
|
|
63
|
+
// Scan ids are correlation values only, but controls/whitespace make logs
|
|
64
|
+
// and duplicate-page diagnosis ambiguous. Keep the wire boundary exact.
|
|
65
|
+
/^[A-Za-z0-9_-]+$/.test(value)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface InventorySnapshot {
|
|
70
|
+
entries: HostTaskInventoryEntry[];
|
|
71
|
+
runtimeEnumerated: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface HostTaskInventoryPage {
|
|
75
|
+
entries: HostTaskInventoryEntry[];
|
|
76
|
+
nextCursor: string | null;
|
|
77
|
+
runtimeEnumerated: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface HostTaskInventoryDeps {
|
|
81
|
+
hostTaskIds(): string[];
|
|
82
|
+
sessionTaskIds(): string[];
|
|
83
|
+
eventTaskIds(): string[];
|
|
84
|
+
workspaceTaskIds(): string[];
|
|
85
|
+
runtimeReady(): boolean;
|
|
86
|
+
docker(args: string[]): Promise<DockerResult>;
|
|
87
|
+
/** Whether the pinned backend is apple-container (round 4: inventory and
|
|
88
|
+
* the orphan proof were Docker-only, so apple orphans were invisible and
|
|
89
|
+
* apple GC could never prune local state). */
|
|
90
|
+
appleBackend(): boolean;
|
|
91
|
+
/** Apple container inventory: `list --all --format json`. */
|
|
92
|
+
appleList(): Promise<DockerResult>;
|
|
93
|
+
/** Apple exact-name/image probes through the bundled CLI. */
|
|
94
|
+
appleCli(args: string[]): Promise<DockerResult>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function databaseTaskIds(
|
|
98
|
+
table:
|
|
99
|
+
| typeof schema.hostTasks
|
|
100
|
+
| typeof schema.hostAgentSessions
|
|
101
|
+
| typeof schema.hostEvents,
|
|
102
|
+
): string[] {
|
|
103
|
+
const rows = getDb()
|
|
104
|
+
.selectDistinct({ taskId: table.taskId })
|
|
105
|
+
.from(table)
|
|
106
|
+
.where(
|
|
107
|
+
sql`length(${table.taskId}) between 1 and ${MAX_HOST_TASK_INVENTORY_TASK_ID_CHARS}`,
|
|
108
|
+
)
|
|
109
|
+
.limit(MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES + 1)
|
|
110
|
+
.all();
|
|
111
|
+
if (rows.length > MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES) {
|
|
112
|
+
throw new Error("host inventory DB source exceeds snapshot limit");
|
|
113
|
+
}
|
|
114
|
+
return rows.map((row) => row.taskId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function workspaceTaskIds(): string[] {
|
|
118
|
+
const root = resolve(env.workspaceRoot, "tasks");
|
|
119
|
+
let directory: ReturnType<typeof opendirSync>;
|
|
120
|
+
try {
|
|
121
|
+
directory = opendirSync(root);
|
|
122
|
+
} catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const taskIds: string[] = [];
|
|
126
|
+
try {
|
|
127
|
+
for (;;) {
|
|
128
|
+
const entry = directory.readSync();
|
|
129
|
+
if (!entry) break;
|
|
130
|
+
const taskId = entry.name;
|
|
131
|
+
if (!isSafeHostTaskId(taskId)) continue;
|
|
132
|
+
let realDirectory = false;
|
|
133
|
+
try {
|
|
134
|
+
const stat = lstatSync(resolve(root, taskId));
|
|
135
|
+
realDirectory = stat.isDirectory() && !stat.isSymbolicLink();
|
|
136
|
+
} catch {
|
|
137
|
+
// A concurrent taskDown removed it. Positive omission is safe.
|
|
138
|
+
}
|
|
139
|
+
if (!realDirectory) continue;
|
|
140
|
+
taskIds.push(taskId);
|
|
141
|
+
if (taskIds.length > MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES) {
|
|
142
|
+
throw new Error("host inventory workspace exceeds snapshot limit");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} finally {
|
|
146
|
+
directory.closeSync();
|
|
147
|
+
}
|
|
148
|
+
return taskIds;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const defaultDeps: HostTaskInventoryDeps = {
|
|
152
|
+
hostTaskIds: () => databaseTaskIds(schema.hostTasks),
|
|
153
|
+
sessionTaskIds: () => databaseTaskIds(schema.hostAgentSessions),
|
|
154
|
+
eventTaskIds: () => databaseTaskIds(schema.hostEvents),
|
|
155
|
+
workspaceTaskIds,
|
|
156
|
+
runtimeReady: () => containerRuntimeProblem() === null,
|
|
157
|
+
docker: (args) =>
|
|
158
|
+
dockerCli(args, {
|
|
159
|
+
timeoutMs: 30_000,
|
|
160
|
+
maxOutputBytes: MAX_HOST_TASK_INVENTORY_DOCKER_OUTPUT_BYTES,
|
|
161
|
+
}),
|
|
162
|
+
appleBackend: () => taskContainerBackend().apple,
|
|
163
|
+
appleList: () =>
|
|
164
|
+
taskContainerCli(["list", "--all", "--format", "json"], {
|
|
165
|
+
timeoutMs: 30_000,
|
|
166
|
+
maxOutputBytes: MAX_HOST_TASK_INVENTORY_DOCKER_OUTPUT_BYTES,
|
|
167
|
+
}),
|
|
168
|
+
appleCli: (args) =>
|
|
169
|
+
taskContainerCli(args, {
|
|
170
|
+
timeoutMs: 30_000,
|
|
171
|
+
maxOutputBytes: MAX_HOST_TASK_INVENTORY_DOCKER_OUTPUT_BYTES,
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
function addEvidence(
|
|
176
|
+
evidence: Map<string, Set<HostTaskInventorySource>>,
|
|
177
|
+
taskId: string,
|
|
178
|
+
source: HostTaskInventorySource,
|
|
179
|
+
): void {
|
|
180
|
+
if (!isSafeHostTaskId(taskId)) return;
|
|
181
|
+
let sources = evidence.get(taskId);
|
|
182
|
+
if (!sources) {
|
|
183
|
+
if (evidence.size >= MAX_HOST_TASK_INVENTORY_SNAPSHOT_ENTRIES) {
|
|
184
|
+
throw new Error("host inventory snapshot exceeds task limit");
|
|
185
|
+
}
|
|
186
|
+
sources = new Set();
|
|
187
|
+
evidence.set(taskId, sources);
|
|
188
|
+
}
|
|
189
|
+
sources.add(source);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
type AppleInventoryRow = { id: string; taskLabel: string | null };
|
|
193
|
+
|
|
194
|
+
/** Strict apple inventory shape (rounds 5+6). Every row must be structurally
|
|
195
|
+
* valid — an object whose configuration.id is a non-empty string and whose
|
|
196
|
+
* com.uai.task label, when present, is a string. Anything else invalidates
|
|
197
|
+
* the WHOLE inventory (returns null): a [null] entry or a malformed label
|
|
198
|
+
* could be hiding exactly the labeled container a caller needs to see, so a
|
|
199
|
+
* partially-readable answer is no answer. */
|
|
200
|
+
function parseAppleInventoryRows(stdout: string): AppleInventoryRow[] | null {
|
|
201
|
+
let parsed: unknown;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(stdout);
|
|
204
|
+
} catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (!Array.isArray(parsed)) return null;
|
|
208
|
+
const rows: AppleInventoryRow[] = [];
|
|
209
|
+
for (const row of parsed) {
|
|
210
|
+
if (typeof row !== "object" || row === null) return null;
|
|
211
|
+
const configuration = (row as { configuration?: unknown }).configuration;
|
|
212
|
+
if (typeof configuration !== "object" || configuration === null) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const id = (configuration as { id?: unknown }).id;
|
|
216
|
+
if (typeof id !== "string" || id === "") return null;
|
|
217
|
+
const labels = (configuration as { labels?: unknown }).labels;
|
|
218
|
+
if (labels === undefined || labels === null) {
|
|
219
|
+
rows.push({ id, taskLabel: null });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (typeof labels !== "object" || Array.isArray(labels)) return null;
|
|
223
|
+
const label = (labels as Record<string, unknown>)["com.uai.task"];
|
|
224
|
+
if (label === undefined) {
|
|
225
|
+
rows.push({ id, taskLabel: null });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (typeof label !== "string") return null;
|
|
229
|
+
rows.push({ id, taskLabel: label });
|
|
230
|
+
}
|
|
231
|
+
return rows;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function composeTaskId(project: string): string | null {
|
|
235
|
+
if (!project.startsWith("task-")) return null;
|
|
236
|
+
const taskId = project.slice("task-".length);
|
|
237
|
+
return isSafeHostTaskId(taskId) ? taskId : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function outputLines(result: DockerResult): string[] {
|
|
241
|
+
return result.stdout
|
|
242
|
+
.split(/\r?\n/)
|
|
243
|
+
.map((line) => line.trim())
|
|
244
|
+
.filter(Boolean);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function buildSnapshot(
|
|
248
|
+
deps: HostTaskInventoryDeps,
|
|
249
|
+
): Promise<InventorySnapshot> {
|
|
250
|
+
const evidence = new Map<string, Set<HostTaskInventorySource>>();
|
|
251
|
+
for (const taskId of deps.hostTaskIds()) addEvidence(evidence, taskId, "db");
|
|
252
|
+
for (const taskId of deps.sessionTaskIds()) {
|
|
253
|
+
addEvidence(evidence, taskId, "session");
|
|
254
|
+
}
|
|
255
|
+
for (const taskId of deps.eventTaskIds()) {
|
|
256
|
+
addEvidence(evidence, taskId, "event");
|
|
257
|
+
}
|
|
258
|
+
for (const taskId of deps.workspaceTaskIds()) {
|
|
259
|
+
addEvidence(evidence, taskId, "workspace");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The container-runtime module owns the authoritative readiness verdict.
|
|
263
|
+
// When it is already degraded, four potentially expensive CLI timeouts
|
|
264
|
+
// cannot add trustworthy negative evidence, so return local positives now.
|
|
265
|
+
if (!deps.runtimeReady()) {
|
|
266
|
+
return {
|
|
267
|
+
entries: inventoryEntries(evidence),
|
|
268
|
+
runtimeEnumerated: false,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (deps.appleBackend()) {
|
|
273
|
+
// One inventory call covers containers; apple tasks have no compose
|
|
274
|
+
// networks/volumes or preview sidecars. One ownership rule everywhere
|
|
275
|
+
// (round 5): a task-shaped NAME is evidence only when the com.uai.task
|
|
276
|
+
// label derives it — a foreign container occupying the reserved shape
|
|
277
|
+
// must not feed the GC that deletes by that name. The label alone is
|
|
278
|
+
// evidence regardless of the name (a renamed container still carries
|
|
279
|
+
// it). Round 6: a structurally invalid inventory (any malformed row)
|
|
280
|
+
// is no inventory — it could be hiding a labeled container.
|
|
281
|
+
const listed = await deps.appleList();
|
|
282
|
+
let runtimeEnumerated = false;
|
|
283
|
+
if (listed.status === 0) {
|
|
284
|
+
const rows = parseAppleInventoryRows(listed.stdout);
|
|
285
|
+
if (rows !== null) {
|
|
286
|
+
runtimeEnumerated = true;
|
|
287
|
+
for (const row of rows) {
|
|
288
|
+
if (row.taskLabel !== null) {
|
|
289
|
+
addEvidence(evidence, row.taskLabel, "compose");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
entries: inventoryEntries(evidence),
|
|
296
|
+
runtimeEnumerated,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const [compose, networks, volumes, previews] = await Promise.all([
|
|
301
|
+
deps.docker([
|
|
302
|
+
"ps",
|
|
303
|
+
"-a",
|
|
304
|
+
"--filter",
|
|
305
|
+
`label=${COMPOSE_PROJECT_LABEL}`,
|
|
306
|
+
"--format",
|
|
307
|
+
`{{.Label \"${COMPOSE_PROJECT_LABEL}\"}}`,
|
|
308
|
+
]),
|
|
309
|
+
deps.docker([
|
|
310
|
+
"network",
|
|
311
|
+
"ls",
|
|
312
|
+
"--filter",
|
|
313
|
+
`label=${COMPOSE_PROJECT_LABEL}`,
|
|
314
|
+
"--format",
|
|
315
|
+
`{{.Label \"${COMPOSE_PROJECT_LABEL}\"}}`,
|
|
316
|
+
]),
|
|
317
|
+
deps.docker([
|
|
318
|
+
"volume",
|
|
319
|
+
"ls",
|
|
320
|
+
"--filter",
|
|
321
|
+
`label=${COMPOSE_PROJECT_LABEL}`,
|
|
322
|
+
"--format",
|
|
323
|
+
`{{.Label \"${COMPOSE_PROJECT_LABEL}\"}}`,
|
|
324
|
+
]),
|
|
325
|
+
deps.docker([
|
|
326
|
+
"ps",
|
|
327
|
+
"-a",
|
|
328
|
+
"--filter",
|
|
329
|
+
`label=${PREVIEW_TASK_LABEL}`,
|
|
330
|
+
"--format",
|
|
331
|
+
`{{.Label \"${PREVIEW_TASK_LABEL}\"}}`,
|
|
332
|
+
]),
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
for (const project of outputLines(compose)) {
|
|
336
|
+
const taskId = composeTaskId(project);
|
|
337
|
+
if (taskId) addEvidence(evidence, taskId, "compose");
|
|
338
|
+
}
|
|
339
|
+
for (const project of outputLines(networks)) {
|
|
340
|
+
const taskId = composeTaskId(project);
|
|
341
|
+
if (taskId) addEvidence(evidence, taskId, "network");
|
|
342
|
+
}
|
|
343
|
+
for (const project of outputLines(volumes)) {
|
|
344
|
+
const taskId = composeTaskId(project);
|
|
345
|
+
if (taskId) addEvidence(evidence, taskId, "volume");
|
|
346
|
+
}
|
|
347
|
+
for (const taskId of outputLines(previews)) {
|
|
348
|
+
addEvidence(evidence, taskId, "preview");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
entries: inventoryEntries(evidence),
|
|
353
|
+
runtimeEnumerated: [compose, networks, volumes, previews].every(
|
|
354
|
+
(result) => result.status === 0,
|
|
355
|
+
),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function inventoryEntries(
|
|
360
|
+
evidence: Map<string, Set<HostTaskInventorySource>>,
|
|
361
|
+
): HostTaskInventoryEntry[] {
|
|
362
|
+
return [...evidence.entries()]
|
|
363
|
+
.map(([taskId, sources]) => ({
|
|
364
|
+
taskId,
|
|
365
|
+
sources: SOURCE_ORDER.filter((source) => sources.has(source)),
|
|
366
|
+
}))
|
|
367
|
+
.sort((left, right) =>
|
|
368
|
+
left.taskId < right.taskId ? -1 : left.taskId > right.taskId ? 1 : 0,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** One-active-scan snapshot cache. A new scan supersedes the old connection's
|
|
373
|
+
* state; duplicate requests for the same scan share the same build promise. */
|
|
374
|
+
export class HostTaskInventorySnapshots {
|
|
375
|
+
private scanId: string | null = null;
|
|
376
|
+
private snapshot: Promise<InventorySnapshot> | null = null;
|
|
377
|
+
|
|
378
|
+
constructor(private readonly deps: HostTaskInventoryDeps = defaultDeps) {}
|
|
379
|
+
|
|
380
|
+
clear(): void {
|
|
381
|
+
this.scanId = null;
|
|
382
|
+
this.snapshot = null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async page(request: {
|
|
386
|
+
scanId: string;
|
|
387
|
+
cursor: string | null;
|
|
388
|
+
limit: number;
|
|
389
|
+
}): Promise<HostTaskInventoryPage> {
|
|
390
|
+
if (!isSafeInventoryScanId(request.scanId)) {
|
|
391
|
+
throw new Error("invalid host inventory scan id");
|
|
392
|
+
}
|
|
393
|
+
if (
|
|
394
|
+
!Number.isSafeInteger(request.limit) ||
|
|
395
|
+
request.limit < 1 ||
|
|
396
|
+
request.limit > MAX_HOST_TASK_INVENTORY_PAGE_SIZE
|
|
397
|
+
) {
|
|
398
|
+
throw new Error("invalid host inventory page limit");
|
|
399
|
+
}
|
|
400
|
+
if (request.cursor !== null && !isSafeHostTaskId(request.cursor)) {
|
|
401
|
+
throw new Error("invalid host inventory cursor");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (request.cursor === null) {
|
|
405
|
+
if (this.scanId !== request.scanId || !this.snapshot) {
|
|
406
|
+
this.scanId = request.scanId;
|
|
407
|
+
this.snapshot = buildSnapshot(this.deps);
|
|
408
|
+
}
|
|
409
|
+
} else if (this.scanId !== request.scanId || !this.snapshot) {
|
|
410
|
+
throw new Error("unknown host inventory scan");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const snapshot = await this.snapshot;
|
|
414
|
+
let start = 0;
|
|
415
|
+
if (request.cursor !== null) {
|
|
416
|
+
const cursorIndex = snapshot.entries.findIndex(
|
|
417
|
+
(entry) => entry.taskId === request.cursor,
|
|
418
|
+
);
|
|
419
|
+
if (cursorIndex < 0) throw new Error("unknown host inventory cursor");
|
|
420
|
+
start = cursorIndex + 1;
|
|
421
|
+
}
|
|
422
|
+
const entries = snapshot.entries.slice(start, start + request.limit);
|
|
423
|
+
const hasMore = start + entries.length < snapshot.entries.length;
|
|
424
|
+
return {
|
|
425
|
+
entries,
|
|
426
|
+
nextCursor: hasMore ? (entries.at(-1)?.taskId ?? null) : null,
|
|
427
|
+
runtimeEnumerated: snapshot.runtimeEnumerated,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export const hostTaskInventorySnapshots = new HostTaskInventorySnapshots();
|
|
433
|
+
|
|
434
|
+
/** Final orphan-GC proof. Never interpret a failed Docker query as absence. */
|
|
435
|
+
export async function proveOrphanTaskResourcesAbsent(
|
|
436
|
+
taskId: string,
|
|
437
|
+
docker: HostTaskInventoryDeps["docker"] = defaultDeps.docker,
|
|
438
|
+
appleDeps: Pick<HostTaskInventoryDeps, "appleBackend" | "appleCli"> = defaultDeps,
|
|
439
|
+
): Promise<void> {
|
|
440
|
+
assertSafeHostTaskId(taskId);
|
|
441
|
+
if (appleDeps.appleBackend()) {
|
|
442
|
+
// Apple task resources: the fixed-name container and the derived image.
|
|
443
|
+
// No compose networks/volumes exist there. Absence is proven only by the
|
|
444
|
+
// exact not-found grammars (a failed query is never absence).
|
|
445
|
+
// Derive the apple name from the branch's own backend decision, not the
|
|
446
|
+
// global pin — this proof runs exactly when appleBackend() says apple.
|
|
447
|
+
const containerName = appleTaskContainerName(taskId);
|
|
448
|
+
const inspectedContainer = await appleDeps.appleCli([
|
|
449
|
+
"inspect",
|
|
450
|
+
containerName,
|
|
451
|
+
]);
|
|
452
|
+
if (inspectedContainer.status === 0) {
|
|
453
|
+
throw new Error(`orphan proof: container ${containerName} still exists`);
|
|
454
|
+
}
|
|
455
|
+
// Round 6: absence needs a SETTLED nonzero exit (a killed probe with a
|
|
456
|
+
// stale stderr line proves nothing) plus empty stdout and the exact
|
|
457
|
+
// whole-line grammar.
|
|
458
|
+
const containerAbsent =
|
|
459
|
+
typeof inspectedContainer.status === "number" &&
|
|
460
|
+
inspectedContainer.stdout.trim() === "" &&
|
|
461
|
+
inspectedContainer.stderr
|
|
462
|
+
.split("\n")
|
|
463
|
+
.some(
|
|
464
|
+
(line) =>
|
|
465
|
+
line.trim() === `Error: container not found: ${containerName}`,
|
|
466
|
+
);
|
|
467
|
+
if (!containerAbsent) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`orphan proof: container ${containerName} could not be proven absent (exit ${inspectedContainer.status ?? "killed"})`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const imageRef = `uai-task-${taskId.toLowerCase()}`;
|
|
473
|
+
const inspected = await appleDeps.appleCli(["image", "inspect", imageRef]);
|
|
474
|
+
const imageAbsent =
|
|
475
|
+
typeof inspected.status === "number" &&
|
|
476
|
+
inspected.status !== 0 &&
|
|
477
|
+
inspected.stdout.trim() === "" &&
|
|
478
|
+
inspected.stderr
|
|
479
|
+
.split("\n")
|
|
480
|
+
.some((line) => line.trim() === `Error: image not found: ${imageRef}`);
|
|
481
|
+
if (!imageAbsent) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
inspected.status === 0
|
|
484
|
+
? `orphan proof: derived image ${imageRef} still exists`
|
|
485
|
+
: `orphan proof: derived image ${imageRef} could not be proven absent (exit ${inspected.status ?? "killed"})`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
// The same ownership set the enumeration uses (round 5): a RENAMED
|
|
489
|
+
// container still carrying this task's label is this task's resource,
|
|
490
|
+
// and the exact-name check above cannot see it. An unreadable inventory
|
|
491
|
+
// proves nothing.
|
|
492
|
+
const listed = await appleDeps.appleCli(["list", "--all", "--format", "json"]);
|
|
493
|
+
if (listed.status !== 0) {
|
|
494
|
+
throw new Error(
|
|
495
|
+
`orphan proof: container inventory failed (exit ${listed.status ?? "killed"})`,
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
const rows = parseAppleInventoryRows(listed.stdout);
|
|
499
|
+
if (rows === null) {
|
|
500
|
+
// Round 6: [null] entries or malformed labels invalidate the whole
|
|
501
|
+
// answer — a partially-readable inventory could be hiding exactly the
|
|
502
|
+
// labeled container this proof exists to find.
|
|
503
|
+
throw new Error("orphan proof: container inventory was unparseable");
|
|
504
|
+
}
|
|
505
|
+
for (const row of rows) {
|
|
506
|
+
if (row.taskLabel === taskId) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`orphan proof: container ${row.id} still carries this task's ownership label`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Workspace absence, exactly like the Docker proof below.
|
|
513
|
+
const appleTaskDir = resolve(env.workspaceRoot, "tasks", taskId);
|
|
514
|
+
try {
|
|
515
|
+
lstatSync(appleTaskDir);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
throw new Error("task workspace remains after task teardown");
|
|
521
|
+
}
|
|
522
|
+
const composeProject = `task-${taskId}`;
|
|
523
|
+
const checks: Array<{ label: string; args: string[] }> = [
|
|
524
|
+
{
|
|
525
|
+
label: "compose containers",
|
|
526
|
+
args: [
|
|
527
|
+
"ps",
|
|
528
|
+
"-aq",
|
|
529
|
+
"--filter",
|
|
530
|
+
`label=${COMPOSE_PROJECT_LABEL}=${composeProject}`,
|
|
531
|
+
],
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
label: "compose networks",
|
|
535
|
+
args: [
|
|
536
|
+
"network",
|
|
537
|
+
"ls",
|
|
538
|
+
"-q",
|
|
539
|
+
"--filter",
|
|
540
|
+
`label=${COMPOSE_PROJECT_LABEL}=${composeProject}`,
|
|
541
|
+
],
|
|
542
|
+
},
|
|
543
|
+
{
|
|
544
|
+
label: "compose volumes",
|
|
545
|
+
args: [
|
|
546
|
+
"volume",
|
|
547
|
+
"ls",
|
|
548
|
+
"-q",
|
|
549
|
+
"--filter",
|
|
550
|
+
`label=${COMPOSE_PROJECT_LABEL}=${composeProject}`,
|
|
551
|
+
],
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
label: "preview sidecars",
|
|
555
|
+
args: [
|
|
556
|
+
"ps",
|
|
557
|
+
"-aq",
|
|
558
|
+
"--filter",
|
|
559
|
+
`label=${PREVIEW_TASK_LABEL}=${taskId}`,
|
|
560
|
+
],
|
|
561
|
+
},
|
|
562
|
+
];
|
|
563
|
+
const results = await Promise.all(checks.map((check) => docker(check.args)));
|
|
564
|
+
for (let index = 0; index < checks.length; index++) {
|
|
565
|
+
const check = checks[index]!;
|
|
566
|
+
const result = results[index]!;
|
|
567
|
+
if (result.status !== 0) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`could not prove ${check.label} absent: ${result.stderr.trim().slice(0, 200) || "docker did not answer"}`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (outputLines(result).length > 0) {
|
|
573
|
+
throw new Error(`${check.label} remain after task teardown`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const taskDir = resolve(env.workspaceRoot, "tasks", taskId);
|
|
578
|
+
try {
|
|
579
|
+
lstatSync(taskDir);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
throw new Error("task workspace remains after task teardown");
|
|
585
|
+
}
|