@nowcrew/daemon 0.6.101-beta.3 → 0.6.101-beta.4
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/dist/computer-main.js +0 -0
- package/dist/execution-journal-lock.js +51 -2
- package/dist/host-execution-coordinator.js +10 -0
- package/dist/machine-info.js +3 -1
- package/dist/machine-runtime-environment.js +1 -0
- package/dist/main.js +0 -0
- package/dist/runtime-tool-update-bridge.js +126 -0
- package/dist/runtime-tool-update-controller.js +151 -0
- package/dist/runtime-tool-update-protocol.js +24 -0
- package/dist/runtime-tool-update-serve.js +54 -0
- package/dist/serve.js +9 -0
- package/package.json +10 -9
package/dist/computer-main.js
CHANGED
|
File without changes
|
|
@@ -3,10 +3,25 @@ import { renameSync } from "node:fs";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { join, resolve } from "node:path";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
const ProtectedGroupSchema = z.object({ pid: z.number().int().positive(), processIdentity: z.string().min(1) }).strict();
|
|
7
|
+
export async function inspectProtectedProcessGroup(pid) {
|
|
8
|
+
try {
|
|
9
|
+
process.kill(-pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code === "ESRCH")
|
|
14
|
+
return false;
|
|
15
|
+
if (error.code === "EPERM")
|
|
16
|
+
return true;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
6
20
|
const OwnerSchema = z.object({
|
|
7
21
|
pid: z.number().int().positive(),
|
|
8
22
|
processIdentity: z.string().min(1),
|
|
9
23
|
token: z.string().uuid(),
|
|
24
|
+
protectedGroup: ProtectedGroupSchema.optional(),
|
|
10
25
|
}).strict();
|
|
11
26
|
export class JournalLockedError extends Error {
|
|
12
27
|
journalPath;
|
|
@@ -144,7 +159,8 @@ export async function inspectJournalLock(options) {
|
|
|
144
159
|
let inspection;
|
|
145
160
|
try {
|
|
146
161
|
const identity = await options.inspectIdentity(owner.pid);
|
|
147
|
-
|
|
162
|
+
const protectedAlive = owner.protectedGroup !== undefined && await (options.inspectProcessGroup ?? inspectProtectedProcessGroup)(owner.protectedGroup.pid);
|
|
163
|
+
inspection = identity === owner.processIdentity || protectedAlive
|
|
148
164
|
? {
|
|
149
165
|
status: "owned",
|
|
150
166
|
ownerPid: owner.pid,
|
|
@@ -333,7 +349,9 @@ export function createJournalLease(options) {
|
|
|
333
349
|
if (observed === null)
|
|
334
350
|
continue;
|
|
335
351
|
const identity = await options.inspectIdentity(observed.owner.pid);
|
|
336
|
-
|
|
352
|
+
const protectedAlive = observed.owner.protectedGroup !== undefined
|
|
353
|
+
&& await (options.inspectProcessGroup ?? inspectProtectedProcessGroup)(observed.owner.protectedGroup.pid);
|
|
354
|
+
if (identity === observed.owner.processIdentity || protectedAlive) {
|
|
337
355
|
throw new JournalLockedError(`Execution journal is locked by process ${observed.owner.pid}`, { journalPath, ownerPid: observed.owner.pid });
|
|
338
356
|
}
|
|
339
357
|
await options.hooks?.beforeRemoveObservedOwner?.(observed.owner);
|
|
@@ -408,6 +426,9 @@ export function createJournalLease(options) {
|
|
|
408
426
|
status = "closed";
|
|
409
427
|
return;
|
|
410
428
|
}
|
|
429
|
+
if (lease.owner.protectedGroup && await (options.inspectProcessGroup ?? inspectProtectedProcessGroup)(lease.owner.protectedGroup.pid)) {
|
|
430
|
+
throw new JournalLockedError("Protected maintenance process group is still alive", { journalPath, ownerPid: lease.owner.protectedGroup.pid });
|
|
431
|
+
}
|
|
411
432
|
status = "closing";
|
|
412
433
|
lease.releasing = true;
|
|
413
434
|
await awaitAbortable((async () => {
|
|
@@ -426,8 +447,36 @@ export function createJournalLease(options) {
|
|
|
426
447
|
status = "closed";
|
|
427
448
|
void cleanupReleasedDirectory(releasedDirectory);
|
|
428
449
|
};
|
|
450
|
+
const protectProcessGroup = async (raw) => {
|
|
451
|
+
const group = ProtectedGroupSchema.parse(raw);
|
|
452
|
+
const lease = registry.get(options.directory);
|
|
453
|
+
if (!attached || status !== "open" || !lease || lease.releasing || lease.refs !== 1)
|
|
454
|
+
throw new JournalLockedError("Lease cannot protect a worker", { journalPath });
|
|
455
|
+
await validateInstalledOwner(lease);
|
|
456
|
+
if (await options.inspectIdentity(group.pid) !== group.processIdentity
|
|
457
|
+
|| !await (options.inspectProcessGroup ?? inspectProtectedProcessGroup)(group.pid))
|
|
458
|
+
throw new JournalLockedError("Worker identity is absent", { journalPath });
|
|
459
|
+
if (lease.owner.protectedGroup) {
|
|
460
|
+
if (JSON.stringify(lease.owner.protectedGroup) === JSON.stringify(group))
|
|
461
|
+
return;
|
|
462
|
+
throw new JournalLockedError("Lease already protects a worker", { journalPath });
|
|
463
|
+
}
|
|
464
|
+
const owner = { ...lease.owner, protectedGroup: group };
|
|
465
|
+
const temp = join(options.directory, `.journal-owner.${lease.owner.token}.${randomUUID()}.tmp`);
|
|
466
|
+
try {
|
|
467
|
+
await fileSystem.writeDurableFile(temp, `${JSON.stringify(owner)}\n`);
|
|
468
|
+
await fileSystem.rename(temp, join(lockDirectory, lease.ownerFile));
|
|
469
|
+
lease.owner = owner;
|
|
470
|
+
await options.syncDirectory(lockDirectory);
|
|
471
|
+
await options.syncDirectory(options.directory);
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
await fileSystem.rm(temp, { force: true }).catch(() => undefined);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
429
477
|
return {
|
|
430
478
|
acquire,
|
|
479
|
+
protectProcessGroup,
|
|
431
480
|
assertUsable: () => {
|
|
432
481
|
if (status !== "open") {
|
|
433
482
|
throw new JournalLockedError(`Execution journal lease is ${status}`, { journalPath });
|
|
@@ -302,6 +302,16 @@ export function createHostExecutionCoordinator(options = {}) {
|
|
|
302
302
|
}
|
|
303
303
|
let releasePromise = null;
|
|
304
304
|
return {
|
|
305
|
+
protectProcessGroup: async (pid) => {
|
|
306
|
+
const processIdentity = await defaultProcessController.inspectIdentity(pid);
|
|
307
|
+
if (processIdentity === null)
|
|
308
|
+
throw new Error("maintenance worker identity is absent");
|
|
309
|
+
for (const lease of leases) {
|
|
310
|
+
if (!lease.protectProcessGroup)
|
|
311
|
+
throw new Error("lease process-group protection is unavailable");
|
|
312
|
+
await lease.protectProcessGroup({ pid, processIdentity });
|
|
313
|
+
}
|
|
314
|
+
},
|
|
305
315
|
release: () => {
|
|
306
316
|
if (releasePromise !== null)
|
|
307
317
|
return releasePromise;
|
package/dist/machine-info.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RUNTIME_TOOL_UPDATES_CAPABILITY } from "./runtime-tool-update-protocol.js";
|
|
1
2
|
import { MAINTENANCE_CAPABILITY } from "./marketplace-maintenance-protocol.js";
|
|
2
3
|
import { retryHarnessProbe } from "./runtimes/harness-probe-retry.js";
|
|
3
4
|
import { MARKETPLACE_CAPABILITY } from "./marketplace-protocol.js";
|
|
@@ -40,6 +41,7 @@ export const NATIVE_PROJECT_WORKSPACE_READINESS = Object.freeze({
|
|
|
40
41
|
nativeRuntimeAdaptersReady: true,
|
|
41
42
|
});
|
|
42
43
|
export const DAEMON_CAPABILITIES = Object.freeze([
|
|
44
|
+
RUNTIME_TOOL_UPDATES_CAPABILITY,
|
|
43
45
|
MARKETPLACE_CAPABILITY,
|
|
44
46
|
MAINTENANCE_CAPABILITY,
|
|
45
47
|
"scheduled_job_v1",
|
|
@@ -74,7 +76,7 @@ export const daemonCapabilities = (runtimePlatform = process.platform, readiness
|
|
|
74
76
|
const nativeWorkspaceReady = nativeWorkspacePlatform
|
|
75
77
|
&& readiness.resolverReady
|
|
76
78
|
&& readiness.nativeRuntimeAdaptersReady;
|
|
77
|
-
return Object.freeze(DAEMON_CAPABILITIES.filter((capability) => (((capability !== MARKETPLACE_CAPABILITY && capability !== MAINTENANCE_CAPABILITY) || nativeWorkspacePlatform)
|
|
79
|
+
return Object.freeze(DAEMON_CAPABILITIES.filter((capability) => (((capability !== MARKETPLACE_CAPABILITY && capability !== MAINTENANCE_CAPABILITY && capability !== RUNTIME_TOOL_UPDATES_CAPABILITY) || nativeWorkspacePlatform)
|
|
78
80
|
&& (capability !== PROJECT_WORKSPACES_CAPABILITY || nativeWorkspaceReady)
|
|
79
81
|
&& (capability !== "message_file_preview_v1" || nativeWorkspacePlatform)
|
|
80
82
|
&& (nativeWorkspacePlatform
|
package/dist/main.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { RuntimeToolResponseSchema } from "./runtime-tool-update-protocol.js";
|
|
7
|
+
export function runtimeToolEnvironment(env) {
|
|
8
|
+
// Keep only machine identity and lookup settings. Never pass runtime/control-plane credentials or Node injection flags.
|
|
9
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => /^(PATH|HOME|USER|LOGNAME|SHELL|TMPDIR|TMP|TEMP|LANG|LC_[A-Z_]+|XDG_CONFIG_HOME|XDG_CACHE_HOME)$/u.test(key)));
|
|
10
|
+
}
|
|
11
|
+
async function readReport(dir) {
|
|
12
|
+
const file = join(dir, "report.json");
|
|
13
|
+
if ((await stat(file)).size > 256_000)
|
|
14
|
+
throw new Error("invalid_report");
|
|
15
|
+
return RuntimeToolResponseSchema.parse(JSON.parse(await readFile(file, "utf8")));
|
|
16
|
+
}
|
|
17
|
+
async function stopGroup(pid) {
|
|
18
|
+
if (!pid)
|
|
19
|
+
return true;
|
|
20
|
+
try {
|
|
21
|
+
process.kill(-pid, "SIGKILL");
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
return error.code === "ESRCH";
|
|
25
|
+
}
|
|
26
|
+
const deadline = Date.now() + 3000;
|
|
27
|
+
while (Date.now() < deadline) {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(-pid, 0);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return error.code === "ESRCH";
|
|
33
|
+
}
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
export function createRuntimeToolProgressPoller(read, progress, intervalMs = 250) {
|
|
39
|
+
let active = true;
|
|
40
|
+
let pending = null;
|
|
41
|
+
const interval = setInterval(() => {
|
|
42
|
+
if (!active || pending || !progress)
|
|
43
|
+
return;
|
|
44
|
+
pending = read().then(async (data) => { if (active)
|
|
45
|
+
await progress(data); })
|
|
46
|
+
.catch(() => { }).finally(() => { pending = null; });
|
|
47
|
+
}, intervalMs);
|
|
48
|
+
return { async stop() { active = false; clearInterval(interval); await pending; } };
|
|
49
|
+
}
|
|
50
|
+
async function supervise(dir, env, start, progress, protectProcessGroup) {
|
|
51
|
+
const child = spawn(process.execPath, [join(dir, "runner.mjs"), "--managed-worker"], { cwd: dir, env, detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"] });
|
|
52
|
+
const closed = new Promise(resolve => { child.once("error", () => resolve(null)); child.once("close", resolve); });
|
|
53
|
+
const polling = createRuntimeToolProgressPoller(() => readReport(dir), progress);
|
|
54
|
+
let expired = false;
|
|
55
|
+
const timer = setTimeout(() => { expired = true; void stopGroup(child.pid); }, start ? 12 * 60_000 : 120_000);
|
|
56
|
+
let launchFailed = false;
|
|
57
|
+
try {
|
|
58
|
+
if (start) {
|
|
59
|
+
if (!protectProcessGroup || !child.pid)
|
|
60
|
+
throw new Error("lease_protection_unavailable");
|
|
61
|
+
await protectProcessGroup(child.pid);
|
|
62
|
+
}
|
|
63
|
+
await new Promise((resolve, reject) => child.send({ type: "runtime-tools:go" }, error => error ? reject(error) : resolve()));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
launchFailed = true;
|
|
67
|
+
await stopGroup(child.pid);
|
|
68
|
+
}
|
|
69
|
+
const code = await closed;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
await polling.stop();
|
|
72
|
+
// A successful parent exit does not prove its package-manager descendants stopped.
|
|
73
|
+
const stopped = await stopGroup(child.pid);
|
|
74
|
+
return { stopped, success: stopped && !expired && !launchFailed && code === 0 };
|
|
75
|
+
}
|
|
76
|
+
export async function runRuntimeToolBridge(config, request, acquireEnv, progress, deps = {}) {
|
|
77
|
+
let dir;
|
|
78
|
+
let started = false;
|
|
79
|
+
let processStopped = true;
|
|
80
|
+
const failure = (errorCode) => ({ processStopped, data: request.action === "start" ? { operation: {
|
|
81
|
+
operationId: request.operationId, state: started ? "unknown" : "failed", currentIndex: 0, targets: request.targets,
|
|
82
|
+
items: request.targets.map(t => ({ runtime: t.installation.runtime, state: started ? "unknown" : "failed", errorCode })),
|
|
83
|
+
updatedAt: new Date().toISOString(), errorCode,
|
|
84
|
+
} } : {} });
|
|
85
|
+
try {
|
|
86
|
+
const response = await (deps.fetch ?? fetch)(`${config.serverUrl.replace(/\/+$/, "")}/machine/runtime-tool-updates/runner/${request.runner.releaseId}`, {
|
|
87
|
+
headers: { authorization: `Bearer ${config.machineToken}` }, redirect: "error", signal: AbortSignal.timeout(15_000),
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok || !response.body)
|
|
90
|
+
return failure("runner_unavailable");
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let bytes = 0;
|
|
93
|
+
for await (const chunk of response.body) {
|
|
94
|
+
bytes += chunk.length;
|
|
95
|
+
if (bytes > 512_000)
|
|
96
|
+
return failure("runner_unavailable");
|
|
97
|
+
chunks.push(chunk);
|
|
98
|
+
}
|
|
99
|
+
const content = Buffer.concat(chunks);
|
|
100
|
+
if (createHash("sha256").update(content).digest("hex") !== request.runner.sha256)
|
|
101
|
+
return failure("runner_unavailable");
|
|
102
|
+
const env = runtimeToolEnvironment(await acquireEnv());
|
|
103
|
+
dir = await mkdtemp(join(tmpdir(), "nowwork-runtime-update-"));
|
|
104
|
+
await writeFile(join(dir, "runner.mjs"), content, { mode: 0o600 });
|
|
105
|
+
await writeFile(join(dir, "request.json"), JSON.stringify(request), { mode: 0o600 });
|
|
106
|
+
started = true;
|
|
107
|
+
processStopped = false;
|
|
108
|
+
const outcome = await supervise(dir, env, request.action === "start", progress, deps.protectProcessGroup);
|
|
109
|
+
processStopped = outcome.stopped;
|
|
110
|
+
if (!outcome.success)
|
|
111
|
+
return failure("operation_unknown");
|
|
112
|
+
const data = await readReport(dir);
|
|
113
|
+
if (request.action === "start" && data.operation?.operationId !== request.operationId)
|
|
114
|
+
return failure("operation_unknown");
|
|
115
|
+
if (request.action === "inspect" && !data.snapshot)
|
|
116
|
+
return failure("inspection_failed");
|
|
117
|
+
return { data, processStopped };
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return failure(started ? "operation_unknown" : "runner_unavailable");
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
if (dir && processStopped)
|
|
124
|
+
await rm(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { RuntimeToolRequestSchema, RuntimeToolResponseSchema } from "./runtime-tool-update-protocol.js";
|
|
2
|
+
const finalStates = new Set(["completed", "failed", "unknown", "cancelled"]);
|
|
3
|
+
const phaseRank = (state) => ({ queued: 0, preflight: 1, installing: 2, verifying: 3, completed: 4, failed: 4, unknown: 4, cancelled: 4 })[state];
|
|
4
|
+
function advancesProgress(previous, next) {
|
|
5
|
+
if (finalStates.has(previous.state) || finalStates.has(next.state)
|
|
6
|
+
|| next.operationId !== previous.operationId || JSON.stringify(next.targets) !== JSON.stringify(previous.targets)
|
|
7
|
+
|| next.currentIndex < previous.currentIndex || next.currentIndex >= previous.targets.length
|
|
8
|
+
|| (next.currentIndex === previous.currentIndex && phaseRank(next.state) < phaseRank(previous.state)))
|
|
9
|
+
return false;
|
|
10
|
+
return previous.items.every((item, index) => {
|
|
11
|
+
const candidate = next.items[index];
|
|
12
|
+
if (!candidate || candidate.runtime !== item.runtime)
|
|
13
|
+
return false;
|
|
14
|
+
return finalStates.has(item.state) ? candidate.state === item.state && candidate.errorCode === item.errorCode
|
|
15
|
+
: phaseRank(candidate.state) >= phaseRank(item.state);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function createRuntimeToolUpdateController(deps) {
|
|
19
|
+
const entries = new Map();
|
|
20
|
+
let current = null;
|
|
21
|
+
let stopped = false;
|
|
22
|
+
let orphaned = false;
|
|
23
|
+
const now = deps.now ?? Date.now;
|
|
24
|
+
const date = () => new Date(now()).toISOString();
|
|
25
|
+
const wait = deps.wait ?? (() => new Promise(resolve => setTimeout(resolve, 500)));
|
|
26
|
+
const fail = (error) => ({ ok: false, error });
|
|
27
|
+
const result = (data) => ({ ok: true, data });
|
|
28
|
+
const set = (entry, patch) => { entry.operation = { ...entry.operation, ...patch, updatedAt: date() }; };
|
|
29
|
+
async function execute(entry) {
|
|
30
|
+
const deadline = now() + 10 * 60_000;
|
|
31
|
+
let acceptingProgress = false;
|
|
32
|
+
let lease = null;
|
|
33
|
+
try {
|
|
34
|
+
while (!stopped && entry.operation.state === "queued" && now() < deadline && !lease) {
|
|
35
|
+
lease = await deps.acquire();
|
|
36
|
+
if (!lease)
|
|
37
|
+
await wait();
|
|
38
|
+
}
|
|
39
|
+
if (entry.operation.state === "cancelled")
|
|
40
|
+
return;
|
|
41
|
+
if (!lease || stopped || now() >= deadline) {
|
|
42
|
+
set(entry, { state: "failed", errorCode: "runtime_busy" });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
set(entry, { state: "preflight" });
|
|
46
|
+
// Mark unsafe before spawning: an exception or lost response cannot authorize another writer.
|
|
47
|
+
entry.processStopped = false;
|
|
48
|
+
acceptingProgress = true;
|
|
49
|
+
const output = await deps.run(entry.request, raw => {
|
|
50
|
+
if (!acceptingProgress)
|
|
51
|
+
return;
|
|
52
|
+
const parsed = RuntimeToolResponseSchema.safeParse(raw);
|
|
53
|
+
if (parsed.success && parsed.data.operation && advancesProgress(entry.operation, parsed.data.operation))
|
|
54
|
+
entry.operation = parsed.data.operation;
|
|
55
|
+
}, lease.protectProcessGroup);
|
|
56
|
+
acceptingProgress = false;
|
|
57
|
+
const data = RuntimeToolResponseSchema.parse(output.data);
|
|
58
|
+
entry.processStopped = output.processStopped;
|
|
59
|
+
if (!data.operation || data.operation.operationId !== entry.operation.operationId
|
|
60
|
+
|| JSON.stringify(data.operation.targets) !== JSON.stringify(entry.request.targets)
|
|
61
|
+
|| data.operation.currentIndex < entry.operation.currentIndex || !finalStates.has(data.operation.state))
|
|
62
|
+
throw new Error("operation_mismatch");
|
|
63
|
+
entry.operation = data.operation;
|
|
64
|
+
if (!entry.processStopped)
|
|
65
|
+
set(entry, { state: "unknown", errorCode: "operation_unknown" });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
acceptingProgress = false;
|
|
69
|
+
set(entry, { state: "unknown", errorCode: "operation_unknown" });
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
acceptingProgress = false;
|
|
73
|
+
// Retain the barrier if process-group termination could not be proved.
|
|
74
|
+
if (entry.processStopped) {
|
|
75
|
+
try {
|
|
76
|
+
await lease?.release();
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
entry.processStopped = false;
|
|
80
|
+
set(entry, { state: "unknown", errorCode: "operation_unknown" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
async handle(raw) {
|
|
87
|
+
const parsed = RuntimeToolRequestSchema.safeParse(raw);
|
|
88
|
+
if (!parsed.success || stopped)
|
|
89
|
+
return fail("invalid_request");
|
|
90
|
+
const request = parsed.data;
|
|
91
|
+
if (request.action === "inspect") {
|
|
92
|
+
try {
|
|
93
|
+
const data = RuntimeToolResponseSchema.parse((await deps.run(request)).data);
|
|
94
|
+
return data.snapshot ? result(data) : fail("inspection_failed");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return fail("inspection_failed");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const entry = entries.get(request.operationId);
|
|
101
|
+
if (request.action === "start") {
|
|
102
|
+
if (entry)
|
|
103
|
+
return result({ operation: entry.operation });
|
|
104
|
+
if (orphaned || [...entries.values()].some(x => x.operation.state === "unknown"))
|
|
105
|
+
return fail("operation_unknown");
|
|
106
|
+
if (current)
|
|
107
|
+
return fail("runtime_busy");
|
|
108
|
+
if (entries.size >= 100)
|
|
109
|
+
return fail("operation_limit");
|
|
110
|
+
if (new Set(request.targets.map(t => t.installation.runtime)).size !== request.targets.length)
|
|
111
|
+
return fail("invalid_request");
|
|
112
|
+
const operation = { operationId: request.operationId, state: "queued", currentIndex: 0, targets: request.targets,
|
|
113
|
+
items: request.targets.map(t => ({ runtime: t.installation.runtime, state: "queued", errorCode: null })), updatedAt: date(), errorCode: null };
|
|
114
|
+
const next = { operation, request, processStopped: true };
|
|
115
|
+
entries.set(request.operationId, next);
|
|
116
|
+
current = execute(next).finally(() => { current = null; });
|
|
117
|
+
return result({ operation });
|
|
118
|
+
}
|
|
119
|
+
if (!entry) {
|
|
120
|
+
orphaned = true;
|
|
121
|
+
return fail("operation_unknown");
|
|
122
|
+
}
|
|
123
|
+
if (request.action === "status")
|
|
124
|
+
return result({ operation: entry.operation });
|
|
125
|
+
if (request.action === "cancel") {
|
|
126
|
+
if (entry.operation.state !== "queued")
|
|
127
|
+
return fail("already_started");
|
|
128
|
+
set(entry, { state: "cancelled", items: entry.operation.items.map(x => ({ ...x, state: "cancelled" })) });
|
|
129
|
+
return result({ operation: entry.operation });
|
|
130
|
+
}
|
|
131
|
+
if (!entry.processStopped || current)
|
|
132
|
+
return fail("operation_unknown");
|
|
133
|
+
try {
|
|
134
|
+
const output = await deps.run({ action: "inspect", runner: entry.request.runner });
|
|
135
|
+
const snapshot = RuntimeToolResponseSchema.parse(output.data).snapshot;
|
|
136
|
+
if (!snapshot || !output.processStopped)
|
|
137
|
+
return fail("operation_unknown");
|
|
138
|
+
const matches = entry.operation.targets.every(target => snapshot.tools.some(tool => tool.runtime === target.installation.runtime
|
|
139
|
+
&& tool.version === target.targetVersion && tool.path === target.installation.path && tool.source === target.installation.source
|
|
140
|
+
&& tool.prefix === target.installation.prefix && tool.brewPath === target.installation.brewPath && tool.automatic));
|
|
141
|
+
set(entry, { state: matches ? "completed" : "failed", errorCode: matches ? null : "verification_failed", snapshot,
|
|
142
|
+
items: entry.operation.items.map(x => ({ ...x, state: matches ? "completed" : "failed", errorCode: matches ? null : "verification_failed" })) });
|
|
143
|
+
return result({ operation: entry.operation, snapshot });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return fail("inspection_failed");
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
async stop() { stopped = true; await current; },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const RUNTIME_TOOL_UPDATES_CAPABILITY = "runtime_tool_updates_v1";
|
|
3
|
+
const boundedPath = z.string().min(1).max(4096);
|
|
4
|
+
export const RuntimeToolRunnerSchema = z.object({ releaseId: z.string().regex(/^[a-f0-9]{64}$/), sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
5
|
+
export const RuntimeToolInstallationSchema = z.object({
|
|
6
|
+
runtime: z.enum(["codex", "claude"]), path: boundedPath, realPath: boundedPath,
|
|
7
|
+
version: z.string().max(100).nullable(), source: z.enum(["npm", "homebrew", "native", "unknown"]),
|
|
8
|
+
nodeVersion: z.string().max(100).nullable(), nodePath: boundedPath.nullable(), npmPath: boundedPath.nullable(), prefix: boundedPath.nullable(),
|
|
9
|
+
brewPath: boundedPath.nullable(), brewKind: z.enum(["formula", "cask"]).nullable(), brewName: z.enum(["codex", "claude-code"]).nullable(),
|
|
10
|
+
fingerprint: z.string().regex(/^[a-f0-9]{64}$/), alternatives: z.array(boundedPath).max(32), automatic: z.boolean(), reason: z.string().max(100).nullable(),
|
|
11
|
+
}).strict();
|
|
12
|
+
export const RuntimeToolSnapshotSchema = z.object({ checkedAt: z.string().datetime(), tools: z.array(RuntimeToolInstallationSchema).max(2) }).strict();
|
|
13
|
+
export const RuntimeToolTargetSchema = z.object({ installation: RuntimeToolInstallationSchema, targetVersion: z.string().regex(/^\d+\.\d+\.\d+$/).max(100) }).strict();
|
|
14
|
+
export const RuntimeToolStateSchema = z.enum(["queued", "preflight", "installing", "verifying", "completed", "failed", "unknown", "cancelled"]);
|
|
15
|
+
export const RuntimeToolOperationSchema = z.object({ operationId: z.string().uuid(), state: RuntimeToolStateSchema, currentIndex: z.number().int().min(0).max(2),
|
|
16
|
+
targets: z.array(RuntimeToolTargetSchema).min(1).max(2), items: z.array(z.object({ runtime: z.enum(["codex", "claude"]), state: RuntimeToolStateSchema, errorCode: z.string().max(100).nullable() }).strict()).max(2),
|
|
17
|
+
updatedAt: z.string().datetime(), errorCode: z.string().max(100).nullable(), snapshot: RuntimeToolSnapshotSchema.optional(),
|
|
18
|
+
}).strict();
|
|
19
|
+
export const RuntimeToolRequestSchema = z.discriminatedUnion("action", [
|
|
20
|
+
z.object({ action: z.literal("inspect"), runner: RuntimeToolRunnerSchema }).strict(),
|
|
21
|
+
z.object({ action: z.literal("start"), operationId: z.string().uuid(), targets: z.array(RuntimeToolTargetSchema).min(1).max(2), runner: RuntimeToolRunnerSchema }).strict(),
|
|
22
|
+
...["status", "cancel", "verify"].map(action => z.object({ action: z.literal(action), operationId: z.string().uuid() }).strict()),
|
|
23
|
+
]);
|
|
24
|
+
export const RuntimeToolResponseSchema = z.object({ snapshot: RuntimeToolSnapshotSchema.optional(), operation: RuntimeToolOperationSchema.optional() }).strict();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { prepareDaemonRestart } from "./daemon-updater.js";
|
|
2
|
+
import { createRuntimeToolUpdateController } from "./runtime-tool-update-controller.js";
|
|
3
|
+
import { augmentedPath } from "./runtime-path.js";
|
|
4
|
+
import { runRuntimeToolBridge } from "./runtime-tool-update-bridge.js";
|
|
5
|
+
export async function acquireRuntimeToolLease(localSlots, host) {
|
|
6
|
+
const local = await prepareDaemonRestart({ localSlots });
|
|
7
|
+
if (!local.ok)
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const shared = await host.tryAcquireExclusiveExecution();
|
|
11
|
+
if (!shared) {
|
|
12
|
+
await local.release();
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
let released = false;
|
|
16
|
+
return { protectProcessGroup: async (pid) => {
|
|
17
|
+
if (!shared.protectProcessGroup)
|
|
18
|
+
throw new Error("lease protection unavailable");
|
|
19
|
+
await shared.protectProcessGroup(pid);
|
|
20
|
+
}, async release() { if (released)
|
|
21
|
+
return; await shared.release(); await local.release(); released = true; } };
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
await local.release();
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function createServeRuntimeToolUpdates(config, localSlots, host, acquireEnv, refresh) {
|
|
29
|
+
const controller = createRuntimeToolUpdateController({ acquire: () => acquireRuntimeToolLease(localSlots, host),
|
|
30
|
+
run: async (request, progress, protectProcessGroup) => {
|
|
31
|
+
const result = await runRuntimeToolBridge(config, request, async () => {
|
|
32
|
+
const env = await acquireEnv();
|
|
33
|
+
return { ...env, PATH: augmentedPath(env, {}, process.platform, "append") };
|
|
34
|
+
}, progress, { ...(protectProcessGroup ? { protectProcessGroup } : {}) });
|
|
35
|
+
if (request.action === "start" && result.processStopped)
|
|
36
|
+
refresh();
|
|
37
|
+
return result;
|
|
38
|
+
} });
|
|
39
|
+
return {
|
|
40
|
+
handle(raw, send) {
|
|
41
|
+
if (!raw || typeof raw !== "object" || !("type" in raw) || raw.type !== "runtime-tools:operation")
|
|
42
|
+
return false;
|
|
43
|
+
const { reqId, type: _type, ...request } = raw;
|
|
44
|
+
if (typeof reqId === "string") {
|
|
45
|
+
if (process.platform !== "linux" && process.platform !== "darwin")
|
|
46
|
+
send({ type: "fs:result", reqId, ok: false, error: "unsupported_platform" });
|
|
47
|
+
else
|
|
48
|
+
void controller.handle(request).then(result => send({ type: "fs:result", reqId, ...result })).catch(() => send({ type: "fs:result", reqId, ok: false, error: "operation_unknown" }));
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
},
|
|
52
|
+
stop: controller.stop,
|
|
53
|
+
};
|
|
54
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -48,6 +48,7 @@ import { initializedProjectSkillsReconciler } from "./project-skills/initialized
|
|
|
48
48
|
import { parseMemoryPruneTraceId } from "./memory-prune-diagnostics.js";
|
|
49
49
|
import { probeRuntimeHealth } from "./runtime-health.js";
|
|
50
50
|
import { createServeMarketplace } from "./marketplace-serve.js";
|
|
51
|
+
import { createServeRuntimeToolUpdates } from "./runtime-tool-update-serve.js";
|
|
51
52
|
import { buildControlPlaneUrl } from "./control-plane-url.js";
|
|
52
53
|
import { resolveDaemonLocalStateRootFromEnv } from "./daemon-local-home.js";
|
|
53
54
|
import { bindStorageServerIdentity, createStorageIdentityAdmission, } from "./storage-serve-adapter.js";
|
|
@@ -210,6 +211,7 @@ export function serve(config, opts = {}) {
|
|
|
210
211
|
onError: (code, context) => dslog("execution.runtime_detection_failed", "机器环境或 runtime 探测失败", { level: "ERROR", error_code: code, ...context }),
|
|
211
212
|
});
|
|
212
213
|
machineRuntime.prewarm();
|
|
214
|
+
const runtimeToolUpdates = createServeRuntimeToolUpdates(config, localSlots, hostCoordinator, async () => ({ ...(await machineRuntime.provider.acquire()).env }), () => machineRuntime.refresh());
|
|
213
215
|
const initializedProjectSkills = initializedProjectSkillsReconciler(() => projectSkillsController.ensureScanned(), projectSkillsReconciler);
|
|
214
216
|
const configuredCapabilities = opts.machineInfo?.capabilities ?? daemonCapabilities(process.platform);
|
|
215
217
|
const prepareCapabilities = async () => daemonCapabilityBindings(process.platform, [...configuredCapabilities, ...await managedDaemonCapabilities(updateEligibility)]);
|
|
@@ -842,6 +844,12 @@ export function serve(config, opts = {}) {
|
|
|
842
844
|
}
|
|
843
845
|
return;
|
|
844
846
|
}
|
|
847
|
+
const runtimeUpdateSocket = ws;
|
|
848
|
+
if (runtimeToolUpdates.handle(msg, frame => { try {
|
|
849
|
+
runtimeUpdateSocket?.send(JSON.stringify(frame));
|
|
850
|
+
}
|
|
851
|
+
catch { /* Server reconciles via status. */ } }))
|
|
852
|
+
return;
|
|
845
853
|
if (marketplace.handle(msg, frame => { try {
|
|
846
854
|
ws?.send(JSON.stringify(frame));
|
|
847
855
|
}
|
|
@@ -1326,6 +1334,7 @@ export function serve(config, opts = {}) {
|
|
|
1326
1334
|
webSocketClosed,
|
|
1327
1335
|
updateController.drain(),
|
|
1328
1336
|
marketplace.stop(),
|
|
1337
|
+
runtimeToolUpdates.stop(),
|
|
1329
1338
|
executionFrameQueue,
|
|
1330
1339
|
...executionRuns.values(),
|
|
1331
1340
|
...[...legacyRuns.values()].map((run) => run.done),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowcrew/daemon",
|
|
3
|
-
"version": "0.6.101-beta.
|
|
3
|
+
"version": "0.6.101-beta.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
"publishConfig": {
|
|
19
19
|
"access": "public"
|
|
20
20
|
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
|
|
23
|
+
"build": "tsc -p tsconfig.json",
|
|
24
|
+
"prepublishOnly": "pnpm build && node ../scripts/daemon-release-artifact.mjs --strict-registry",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:release-critical": "vitest run test/automatic-daemon-installer.test.ts test/automatic-install-target.test.ts test/automatic-service-migration.test.ts test/computer-cli.test.ts test/computer-service.test.ts test/computer-setup-client.test.ts test/computer-setup-orchestration.test.ts test/computer-setup.test.ts test/daemon-release-version.test.ts test/daemon-update-controller.test.ts test/daemon-update-eligibility.test.ts test/daemon-updater.test.ts test/execution-protocol.test.ts test/machine-info.test.ts --maxWorkers=2 --minWorkers=1",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
21
29
|
"dependencies": {
|
|
22
30
|
"@agentclientprotocol/sdk": "1.2.1",
|
|
23
31
|
"@nowcrew/cli": "0.4.17",
|
|
@@ -39,12 +47,5 @@
|
|
|
39
47
|
"tsx": "^4.19.0",
|
|
40
48
|
"typescript": "^5.6.0",
|
|
41
49
|
"vitest": "^2.1.0"
|
|
42
|
-
},
|
|
43
|
-
"scripts": {
|
|
44
|
-
"daemon": "pnpm --filter @nowcrew/cli build && tsx src/main.ts",
|
|
45
|
-
"build": "tsc -p tsconfig.json",
|
|
46
|
-
"test": "vitest run",
|
|
47
|
-
"test:release-critical": "vitest run test/automatic-daemon-installer.test.ts test/automatic-install-target.test.ts test/automatic-service-migration.test.ts test/computer-cli.test.ts test/computer-service.test.ts test/computer-setup-client.test.ts test/computer-setup-orchestration.test.ts test/computer-setup.test.ts test/daemon-release-version.test.ts test/daemon-update-controller.test.ts test/daemon-update-eligibility.test.ts test/daemon-updater.test.ts test/execution-protocol.test.ts test/machine-info.test.ts --maxWorkers=2 --minWorkers=1",
|
|
48
|
-
"typecheck": "tsc --noEmit"
|
|
49
50
|
}
|
|
50
|
-
}
|
|
51
|
+
}
|