@cydm/happy-elves 0.1.0-beta.496 → 0.1.0-beta.497
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/apps/cli/dist/commands/lib/args.js +5 -0
- package/apps/cli/dist/commands/lib/usage.js +12 -1
- package/apps/cli/dist/commands/machine.js +122 -0
- package/apps/cli/package.json +1 -1
- package/apps/daemon/dist/direct/server.js +15 -0
- package/apps/daemon/dist/local-control.d.ts +4 -0
- package/apps/daemon/dist/local-control.js +111 -0
- package/apps/daemon/dist/paths.d.ts +1 -0
- package/apps/daemon/dist/paths.js +1 -0
- package/apps/daemon/dist/process.d.ts +11 -1
- package/apps/daemon/dist/process.js +55 -9
- package/apps/daemon/dist/relay/connection.js +53 -2
- package/apps/daemon/dist/relay/remote-command.js +5 -0
- package/apps/daemon/dist/start.js +4 -1
- package/apps/daemon/dist/turn-coordinator.d.ts +2 -0
- package/apps/daemon/dist/turn-coordinator.js +6 -0
- package/apps/daemon/dist/update-barrier.d.ts +26 -0
- package/apps/daemon/dist/update-barrier.js +89 -0
- package/apps/daemon/package.json +1 -1
- package/apps/relay/dist/relay-context.d.ts +1 -1
- package/apps/relay/dist/websocket.js +11 -2
- package/apps/updater/dist/autostart.d.ts +15 -0
- package/apps/updater/dist/autostart.js +62 -0
- package/apps/updater/dist/cli.d.ts +2 -0
- package/apps/updater/dist/cli.js +111 -0
- package/apps/updater/dist/control.d.ts +5 -0
- package/apps/updater/dist/control.js +49 -0
- package/apps/updater/dist/daemon-config.d.ts +19 -0
- package/apps/updater/dist/daemon-config.js +28 -0
- package/apps/updater/dist/daemon.d.ts +20 -0
- package/apps/updater/dist/daemon.js +166 -0
- package/apps/updater/dist/host-jobs.d.ts +16 -0
- package/apps/updater/dist/host-jobs.js +494 -0
- package/apps/updater/dist/launcher.d.ts +9 -0
- package/apps/updater/dist/launcher.js +64 -0
- package/apps/updater/dist/node-runtime.d.ts +5 -0
- package/apps/updater/dist/node-runtime.js +40 -0
- package/apps/updater/dist/paths.d.ts +30 -0
- package/apps/updater/dist/paths.js +50 -0
- package/apps/updater/dist/relay.d.ts +15 -0
- package/apps/updater/dist/relay.js +338 -0
- package/apps/updater/dist/release.d.ts +30 -0
- package/apps/updater/dist/release.js +143 -0
- package/apps/updater/dist/run.d.ts +4 -0
- package/apps/updater/dist/run.js +396 -0
- package/apps/updater/dist/slots.d.ts +15 -0
- package/apps/updater/dist/slots.js +48 -0
- package/apps/updater/dist/state.d.ts +29 -0
- package/apps/updater/dist/state.js +122 -0
- package/apps/updater/dist/supervisor.d.ts +31 -0
- package/apps/updater/dist/supervisor.js +364 -0
- package/package.json +5 -2
- package/packages/client/dist/client.d.ts +25 -0
- package/packages/client/dist/client.js +96 -0
- package/packages/client/dist/index.d.ts +2 -1
- package/packages/client/dist/types.d.ts +3 -0
- package/packages/client/dist/update.d.ts +6 -0
- package/packages/client/dist/update.js +123 -0
- package/packages/update-protocol/dist/index.d.ts +311 -0
- package/packages/update-protocol/dist/index.js +124 -0
|
@@ -35,6 +35,14 @@ Check local controller config, relay reachability, and daemon state.
|
|
|
35
35
|
machine: `Usage:
|
|
36
36
|
happy-elves machine list [--verbose] [--json]
|
|
37
37
|
happy-elves machine status <machineId> [--verbose] [--json]
|
|
38
|
+
happy-elves machine update check <machineId> [--json]
|
|
39
|
+
happy-elves machine update status <machineId> [--operation <operationId>] [--json]
|
|
40
|
+
happy-elves machine update apply <machineId> --version <version> [--reject] [--max-wait-ms <ms>] [--json]
|
|
41
|
+
happy-elves machine daemon <status|start|stop|restart|diagnose|logs> <machineId> [--limit-bytes <n>] [--json]
|
|
42
|
+
happy-elves machine host exec <machineId> <command> [args...] --operation-id <id> [--cwd <path>] [--timeout-ms <ms>] [--json]
|
|
43
|
+
happy-elves machine host exec <machineId> --shell <command> --operation-id <id> [--cwd <path>] [--timeout-ms <ms>] [--json]
|
|
44
|
+
happy-elves machine host status <machineId> --operation-id <id> [--json]
|
|
45
|
+
happy-elves machine host cancel <machineId> <jobId> [--json]
|
|
38
46
|
happy-elves machine diagnose <machineId> [--json]
|
|
39
47
|
happy-elves machine directory <machineId> [--path <path>] [--json]
|
|
40
48
|
happy-elves machine exec <machineId> --shell <command> [--cwd <path>] [--timeout-ms <ms>] [--wait-timeout-ms <ms>] [--json]
|
|
@@ -42,7 +50,7 @@ Check local controller config, relay reachability, and daemon state.
|
|
|
42
50
|
happy-elves machine file info <machineId> --root <cwd> --path <path> [--json]
|
|
43
51
|
happy-elves machine file preview <machineId> --root <cwd> --path <path> [--mode auto|text|image|html-source|html-sandbox] [--output <file>] [--json]
|
|
44
52
|
|
|
45
|
-
diagnose/exec are LAN/dev-only.
|
|
53
|
+
diagnose/exec are LAN/dev-only. host is the durable updater-managed host command path.
|
|
46
54
|
--timeout-ms limits the remote process. --wait-timeout-ms limits how long this CLI waits for the result.
|
|
47
55
|
file preview is read-only and limited to files inside --root.
|
|
48
56
|
`,
|
|
@@ -266,6 +274,9 @@ Happy Elves is a remote controller and orchestration layer for coding agents acr
|
|
|
266
274
|
Core commands:
|
|
267
275
|
machine list [--json]
|
|
268
276
|
machine status <machineId> [--verbose] [--json]
|
|
277
|
+
machine update check <machineId> [--json]
|
|
278
|
+
machine update status <machineId> [--operation <operationId>] [--json]
|
|
279
|
+
machine update apply <machineId> --version <version> [--reject] [--json]
|
|
269
280
|
machine directory <machineId> [--path <path>] [--json]
|
|
270
281
|
|
|
271
282
|
session list [--machine <machineId>] [--cwd <path>] [--limit 20] [--json]
|
|
@@ -162,6 +162,57 @@ export async function handleMachine({ domain, action, positional, flags }) {
|
|
|
162
162
|
return false;
|
|
163
163
|
const config = await readConfig(flags);
|
|
164
164
|
const client = new ControllerClient(config);
|
|
165
|
+
if (action === "update") {
|
|
166
|
+
const updateAction = positional[0];
|
|
167
|
+
const machineId = positional[1] ?? (typeof flags.machine === "string" ? flags.machine : undefined);
|
|
168
|
+
if (!machineId)
|
|
169
|
+
throw new CliError("Usage: happy-elves machine update <check|status|apply> <machineId> [--version <version>]", "USAGE");
|
|
170
|
+
if (updateAction === "check") {
|
|
171
|
+
const result = await client.machineUpdateCheck(machineId);
|
|
172
|
+
if (!wantsJson(flags)) {
|
|
173
|
+
console.log(`${machineId}: ${result.phase}${result.currentVersion ? ` (${result.currentVersion})` : ""}`);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
ok("machine.update.check", { machineId, result }, { machineId });
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
if (updateAction === "status") {
|
|
180
|
+
const operationId = typeof flags.operation === "string" ? flags.operation : undefined;
|
|
181
|
+
const result = await client.machineUpdateStatus(machineId, operationId);
|
|
182
|
+
if (!wantsJson(flags)) {
|
|
183
|
+
console.log(`${machineId}: ${result.phase}${result.operationId ? ` ${result.operationId}` : ""}`);
|
|
184
|
+
if (result.error)
|
|
185
|
+
console.log(` ${result.errorCode ?? "error"}: ${result.error}`);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
ok("machine.update.status", { machineId, result }, { machineId, ...(result.operationId ? { operationId: result.operationId } : {}) });
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
if (updateAction === "apply") {
|
|
192
|
+
const version = typeof flags.version === "string" ? flags.version : positional[2];
|
|
193
|
+
if (!version)
|
|
194
|
+
throw new CliError("Missing --version <version>", "INVALID_ARGUMENT");
|
|
195
|
+
const result = await client.machineUpdateApply({
|
|
196
|
+
machineId,
|
|
197
|
+
targetVersion: version,
|
|
198
|
+
operationId: typeof flags["operation-id"] === "string" ? flags["operation-id"] : undefined,
|
|
199
|
+
busyPolicy: flags.reject === true ? "reject" : "wait",
|
|
200
|
+
maxWaitMs: typeof flags["max-wait-ms"] === "string" ? Number(flags["max-wait-ms"]) : undefined,
|
|
201
|
+
});
|
|
202
|
+
const success = result.phase === "succeeded" || result.phase === "recovered";
|
|
203
|
+
if (!success)
|
|
204
|
+
process.exitCode = 1;
|
|
205
|
+
if (!wantsJson(flags)) {
|
|
206
|
+
console.log(`${machineId}: ${result.phase}${result.operationId ? ` ${result.operationId}` : ""}`);
|
|
207
|
+
if (result.error)
|
|
208
|
+
console.log(` ${result.errorCode ?? "error"}: ${result.error}`);
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
ok("machine.update.apply", { machineId, result }, { machineId, ...(result.operationId ? { operationId: result.operationId } : {}) }, success);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
throw new CliError("Usage: happy-elves machine update <check|status|apply> <machineId>", "USAGE");
|
|
215
|
+
}
|
|
165
216
|
if (action === "directory" || action === "dir" || action === "ls") {
|
|
166
217
|
const machineId = requirePositional(positional[0], "machineId");
|
|
167
218
|
const listing = await client.listDirectory({
|
|
@@ -269,6 +320,77 @@ export async function handleMachine({ domain, action, positional, flags }) {
|
|
|
269
320
|
ok("machine.diagnose", { machineId, diagnostics }, { machineId });
|
|
270
321
|
return true;
|
|
271
322
|
}
|
|
323
|
+
if (action === "daemon") {
|
|
324
|
+
const daemonAction = positional[0];
|
|
325
|
+
const machineId = requirePositional(positional[1], "machineId");
|
|
326
|
+
if (!daemonAction || !["status", "start", "stop", "restart", "diagnose", "logs"].includes(daemonAction))
|
|
327
|
+
throw new CliError("Usage: happy-elves machine daemon <status|start|stop|restart|diagnose|logs> <machineId>", "USAGE");
|
|
328
|
+
const result = await client.daemonManage(machineId, daemonAction, typeof flags["limit-bytes"] === "string" ? Number(flags["limit-bytes"]) : undefined);
|
|
329
|
+
if (!wantsJson(flags)) {
|
|
330
|
+
console.log(`${machineId}: ${result.running ? "running" : "stopped"}${result.pid ? ` pid ${result.pid}` : ""}${result.version ? ` ${result.version}` : ""}`);
|
|
331
|
+
if (result.error)
|
|
332
|
+
console.log(`${result.errorCode ?? "error"}: ${result.error}`);
|
|
333
|
+
if (result.output)
|
|
334
|
+
console.log(result.output);
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
const success = !result.errorCode;
|
|
338
|
+
if (!success)
|
|
339
|
+
process.exitCode = 1;
|
|
340
|
+
ok(`machine.daemon.${daemonAction}`, { machineId, result }, { machineId }, success);
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
if (action === "host") {
|
|
344
|
+
const hostAction = positional[0];
|
|
345
|
+
const machineId = requirePositional(positional[1], "machineId");
|
|
346
|
+
if (hostAction === "exec") {
|
|
347
|
+
const operationId = typeof flags["operation-id"] === "string" ? flags["operation-id"] : "";
|
|
348
|
+
if (!operationId)
|
|
349
|
+
throw new CliError("Missing --operation-id <id>", "INVALID_ARGUMENT");
|
|
350
|
+
const shell = typeof flags.shell === "string" ? flags.shell : undefined;
|
|
351
|
+
const command = shell ?? requirePositional(positional[2], "command");
|
|
352
|
+
const args = shell ? [] : positional.slice(3);
|
|
353
|
+
const timeoutMs = typeof flags["timeout-ms"] === "string" ? Number(flags["timeout-ms"]) : undefined;
|
|
354
|
+
if (timeoutMs !== undefined && (!Number.isInteger(timeoutMs) || timeoutMs <= 0))
|
|
355
|
+
throw new CliError(`Invalid --timeout-ms: ${flags["timeout-ms"]}`, "INVALID_ARGUMENT");
|
|
356
|
+
const job = await client.hostExec({
|
|
357
|
+
machineId, operationId, command, args,
|
|
358
|
+
...(typeof flags.cwd === "string" ? { cwd: flags.cwd } : {}),
|
|
359
|
+
...(shell ? { shell } : {}),
|
|
360
|
+
...(timeoutMs ? { timeoutMs } : {}),
|
|
361
|
+
});
|
|
362
|
+
if (!wantsJson(flags)) {
|
|
363
|
+
console.log(`${job.jobId} ${job.status} ${job.operationId}`);
|
|
364
|
+
if (job.stdout)
|
|
365
|
+
console.log(job.stdout);
|
|
366
|
+
if (job.stderr)
|
|
367
|
+
console.error(job.stderr);
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
ok("machine.host.exec", { machineId, job }, { machineId });
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
if (hostAction === "status" || hostAction === "cancel") {
|
|
374
|
+
const jobId = positional[2];
|
|
375
|
+
const operationId = typeof flags["operation-id"] === "string" ? flags["operation-id"] : undefined;
|
|
376
|
+
if (hostAction === "status" && !operationId)
|
|
377
|
+
throw new CliError("Missing --operation-id <id>", "INVALID_ARGUMENT");
|
|
378
|
+
if (hostAction === "cancel" && !jobId)
|
|
379
|
+
throw new CliError("Missing jobId", "INVALID_ARGUMENT");
|
|
380
|
+
const job = hostAction === "status" ? await client.hostOperationStatus(machineId, operationId) : await client.hostJobCancel(machineId, jobId);
|
|
381
|
+
if (!wantsJson(flags)) {
|
|
382
|
+
console.log(`${job.jobId} ${job.status}`);
|
|
383
|
+
if (job.stdout)
|
|
384
|
+
console.log(job.stdout);
|
|
385
|
+
if (job.stderr)
|
|
386
|
+
console.error(job.stderr);
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
ok(`machine.host.${hostAction}`, { machineId, job }, { machineId });
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
throw new CliError("Usage: happy-elves machine host <exec|status|cancel> <machineId> ...", "USAGE");
|
|
393
|
+
}
|
|
272
394
|
if (action === "exec" || action === "dev-exec") {
|
|
273
395
|
const machineId = requirePositional(positional[0], "machineId");
|
|
274
396
|
const shell = typeof flags.shell === "string" ? flags.shell : undefined;
|
package/apps/cli/package.json
CHANGED
|
@@ -13,6 +13,7 @@ import { handleInboundPromptCommand } from "../relay/inbound-prompt.js";
|
|
|
13
13
|
import { markDirectSocket, sendMachineMessageToRelay } from "../relay/send.js";
|
|
14
14
|
import { setCurrentDirectTransportMetadata } from "./state.js";
|
|
15
15
|
import { DirectGrantVerifier, DirectTicketStore, directAuthError, directTicketConfigFromEnv } from "./tickets.js";
|
|
16
|
+
import { isUpdateAdmissionCommand, updateAdmissionBlocked, updateAdmissionError } from "../update-barrier.js";
|
|
16
17
|
const DIRECT_PROTOCOL = "he-direct-v1";
|
|
17
18
|
const DEFAULT_DIRECT_PORT = 8790;
|
|
18
19
|
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
@@ -282,6 +283,20 @@ class DirectState {
|
|
|
282
283
|
this.ackStoredPacket(connection, message.packet);
|
|
283
284
|
return;
|
|
284
285
|
}
|
|
286
|
+
if (isUpdateAdmissionCommand(command.type)) {
|
|
287
|
+
const barrier = await updateAdmissionBlocked();
|
|
288
|
+
if (barrier) {
|
|
289
|
+
await this.sendMachinePacket({
|
|
290
|
+
type: "machine:error",
|
|
291
|
+
requestId: command.requestId,
|
|
292
|
+
sessionId: sessionIdFromMachineCommand(command),
|
|
293
|
+
code: updateAdmissionError(barrier).code,
|
|
294
|
+
message: updateAdmissionError(barrier).message,
|
|
295
|
+
}, connection);
|
|
296
|
+
this.ackStoredPacket(connection, message.packet);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
285
300
|
if (command.type === "machine:prompt") {
|
|
286
301
|
await handleInboundPromptCommand({
|
|
287
302
|
ws: this.handlerSocket(connection),
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { configDir } from "./paths.js";
|
|
7
|
+
import { readDaemonIdentity } from "./process.js";
|
|
8
|
+
import { beginUpdateBarrier, clearUpdateBarrier, markUpdateBarrierPending } from "./update-barrier.js";
|
|
9
|
+
import { pendingOptionsForSession } from "./state.js";
|
|
10
|
+
import { sessionTurns } from "./state.js";
|
|
11
|
+
import { sessions } from "./state.js";
|
|
12
|
+
function localControlPath(machineId) {
|
|
13
|
+
return process.platform === "win32"
|
|
14
|
+
? `\\\\.\\pipe\\happy-elves-daemon-${machineId}`
|
|
15
|
+
: unixControlPath(configDir);
|
|
16
|
+
}
|
|
17
|
+
function unixControlPath(root) {
|
|
18
|
+
const preferred = path.join(root, "daemon-control.sock");
|
|
19
|
+
if (preferred.length < 96)
|
|
20
|
+
return preferred;
|
|
21
|
+
const digest = crypto.createHash("sha256").update(path.resolve(root)).digest("hex").slice(0, 24);
|
|
22
|
+
return path.join(os.tmpdir(), `happy-elves-daemon-${digest}.sock`);
|
|
23
|
+
}
|
|
24
|
+
export async function startLocalControl(machineId) {
|
|
25
|
+
const socketPath = localControlPath(machineId);
|
|
26
|
+
if (process.platform !== "win32")
|
|
27
|
+
await fs.rm(socketPath, { force: true }).catch(() => undefined);
|
|
28
|
+
const server = net.createServer((socket) => {
|
|
29
|
+
let buffer = "";
|
|
30
|
+
socket.setEncoding("utf8");
|
|
31
|
+
socket.on("data", (chunk) => {
|
|
32
|
+
buffer += chunk;
|
|
33
|
+
let index = buffer.indexOf("\n");
|
|
34
|
+
while (index >= 0) {
|
|
35
|
+
const line = buffer.slice(0, index);
|
|
36
|
+
buffer = buffer.slice(index + 1);
|
|
37
|
+
index = buffer.indexOf("\n");
|
|
38
|
+
void handleControlRequest(line, socket);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
server.once("error", reject);
|
|
44
|
+
server.listen(socketPath, () => resolve());
|
|
45
|
+
});
|
|
46
|
+
if (process.platform !== "win32")
|
|
47
|
+
await fs.chmod(socketPath, 0o600).catch(() => undefined);
|
|
48
|
+
return {
|
|
49
|
+
close: async () => {
|
|
50
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
51
|
+
if (process.platform !== "win32")
|
|
52
|
+
await fs.rm(socketPath, { force: true }).catch(() => undefined);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
async function handleControlRequest(raw, socket) {
|
|
57
|
+
try {
|
|
58
|
+
const request = JSON.parse(raw);
|
|
59
|
+
if (request.type === "identity") {
|
|
60
|
+
const identity = await readDaemonIdentity();
|
|
61
|
+
if (!identity || identity.pid !== process.pid || identity.stateRoot !== path.resolve(configDir)) {
|
|
62
|
+
write(socket, { ok: false, code: "DAEMON_IDENTITY_UNAVAILABLE" });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
write(socket, { ok: true, pid: process.pid, stateRoot: identity.stateRoot, machineId: identity.machineId, nonce: identity.nonce, ...(identity.generation ? { generation: identity.generation } : {}), startedAt: identity.startedAt });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (request.type === "status") {
|
|
69
|
+
const activeTurns = sessionTurns.activeCount();
|
|
70
|
+
const pendingOptions = [...sessions.values()].reduce((count, session) => count + (sessionTurns.activeTurnId(session.sessionId) ? pendingOptionsForSession(session.sessionId, sessionTurns.activeTurnId(session.sessionId)).length : 0), 0);
|
|
71
|
+
write(socket, { ok: true, activeTurns, pendingOptions, busy: activeTurns > 0 || pendingOptions > 0 });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (request.type === "begin-update") {
|
|
75
|
+
const releasePending = markUpdateBarrierPending({ operationId: request.operationId, generation: request.generation, leaseMs: request.leaseMs ?? 120_000 });
|
|
76
|
+
try {
|
|
77
|
+
const activeTurns = sessionTurns.activeCount();
|
|
78
|
+
const pendingOptions = [...sessions.values()].reduce((count, session) => count + (sessionTurns.activeTurnId(session.sessionId) ? pendingOptionsForSession(session.sessionId, sessionTurns.activeTurnId(session.sessionId)).length : 0), 0);
|
|
79
|
+
if (activeTurns > 0 || pendingOptions > 0) {
|
|
80
|
+
write(socket, { ok: false, code: "DAEMON_BUSY", activeTurns, pendingOptions });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const barrier = await beginUpdateBarrier({ operationId: request.operationId, generation: request.generation, leaseMs: request.leaseMs ?? 120_000 });
|
|
84
|
+
write(socket, { ok: true, barrier });
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
releasePending();
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (request.type === "end-update") {
|
|
92
|
+
await clearUpdateBarrier(request.operationId);
|
|
93
|
+
write(socket, { ok: true });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (request.type === "shutdown") {
|
|
97
|
+
write(socket, { ok: true });
|
|
98
|
+
setTimeout(() => process.kill(process.pid, "SIGTERM"), 25).unref?.();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
write(socket, { ok: false, code: "INVALID_CONTROL_COMMAND" });
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
write(socket, { ok: false, code: "INVALID_CONTROL_COMMAND", error: error instanceof Error ? error.message : String(error) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function write(socket, value) {
|
|
108
|
+
if (!socket.destroyed)
|
|
109
|
+
socket.write(`${JSON.stringify(value)}\n`);
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=local-control.js.map
|
|
@@ -13,6 +13,7 @@ export declare const orchestrationStorePath: string;
|
|
|
13
13
|
export declare const artifactStorePath: string;
|
|
14
14
|
export declare const memoryRootDir: string;
|
|
15
15
|
export declare const pidPath: string;
|
|
16
|
+
export declare const identityPath: string;
|
|
16
17
|
export declare const daemonScriptPath: string;
|
|
17
18
|
export declare const daemonScriptRelativePath: string;
|
|
18
19
|
export declare const daemonScriptRepoSuffix: string;
|
|
@@ -18,6 +18,7 @@ export const orchestrationStorePath = path.join(configDir, "orchestration.json")
|
|
|
18
18
|
export const artifactStorePath = path.join(configDir, "artifacts.json");
|
|
19
19
|
export const memoryRootDir = path.join(configDir, "memory");
|
|
20
20
|
export const pidPath = path.join(configDir, "daemon.pid");
|
|
21
|
+
export const identityPath = path.join(configDir, "daemon.identity.json");
|
|
21
22
|
export const daemonScriptPath = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./cli.ts" : "./cli.js", import.meta.url));
|
|
22
23
|
export const daemonScriptRelativePath = path.relative(process.cwd(), daemonScriptPath);
|
|
23
24
|
export const daemonScriptRepoSuffix = path.join("apps", "daemon", "dist", "cli.js");
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
+
export type DaemonIdentity = {
|
|
2
|
+
pid: number;
|
|
3
|
+
stateRoot: string;
|
|
4
|
+
machineId: string;
|
|
5
|
+
nonce: string;
|
|
6
|
+
generation?: string;
|
|
7
|
+
startedAt: string;
|
|
8
|
+
};
|
|
1
9
|
export declare function isManagedDaemonCommand(command: string | undefined): boolean;
|
|
2
|
-
export declare function
|
|
10
|
+
export declare function readDaemonIdentity(): Promise<DaemonIdentity | undefined>;
|
|
11
|
+
export declare function writePid(machineId: string): Promise<void>;
|
|
3
12
|
export declare function removePidIfCurrent(): Promise<void>;
|
|
4
13
|
export declare function localStatus(): Promise<{
|
|
5
14
|
pidPath: string;
|
|
6
15
|
pid?: number;
|
|
7
16
|
running: boolean;
|
|
17
|
+
identityVerified?: boolean;
|
|
8
18
|
}>;
|
|
9
19
|
export declare function stopDaemon(timeoutMs?: number): Promise<{
|
|
10
20
|
pidPath: string;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
-
import { daemonScriptPath, daemonScriptRelativePath, daemonScriptRepoSuffix, pidPath } from "./paths.js";
|
|
5
|
+
import { configDir, daemonScriptPath, daemonScriptRelativePath, daemonScriptRepoSuffix, identityPath, pidPath } from "./paths.js";
|
|
5
6
|
import { sleep } from "./utils.js";
|
|
6
7
|
function isPidAlive(pid) {
|
|
7
8
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
@@ -47,31 +48,74 @@ export function isManagedDaemonCommand(command) {
|
|
|
47
48
|
command.includes(path.join("apps", "daemon", "src", "cli.ts"))) &&
|
|
48
49
|
/\bstart\b/.test(command));
|
|
49
50
|
}
|
|
50
|
-
async function
|
|
51
|
+
async function readPidRecord() {
|
|
51
52
|
try {
|
|
52
53
|
const raw = await fs.readFile(pidPath, "utf8");
|
|
53
54
|
const pid = Number(raw.trim());
|
|
54
|
-
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
55
|
+
return Number.isInteger(pid) && pid > 0 ? { pid } : undefined;
|
|
55
56
|
}
|
|
56
57
|
catch (error) {
|
|
57
58
|
if (error.code === "ENOENT")
|
|
58
59
|
return undefined;
|
|
59
|
-
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function readDaemonIdentity() {
|
|
64
|
+
try {
|
|
65
|
+
const value = JSON.parse(await fs.readFile(identityPath, "utf8"));
|
|
66
|
+
if (typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0 ||
|
|
67
|
+
typeof value.stateRoot !== "string" || !path.isAbsolute(value.stateRoot) ||
|
|
68
|
+
typeof value.machineId !== "string" || !value.machineId ||
|
|
69
|
+
typeof value.nonce !== "string" || !value.nonce ||
|
|
70
|
+
typeof value.startedAt !== "string" || !value.startedAt)
|
|
71
|
+
return undefined;
|
|
72
|
+
return {
|
|
73
|
+
pid: value.pid,
|
|
74
|
+
stateRoot: path.resolve(value.stateRoot),
|
|
75
|
+
machineId: value.machineId,
|
|
76
|
+
nonce: value.nonce,
|
|
77
|
+
...(typeof value.generation === "string" && value.generation ? { generation: value.generation } : {}),
|
|
78
|
+
startedAt: value.startedAt,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
60
83
|
}
|
|
61
84
|
}
|
|
62
|
-
export async function writePid() {
|
|
85
|
+
export async function writePid(machineId) {
|
|
63
86
|
await fs.mkdir(path.dirname(pidPath), { recursive: true });
|
|
87
|
+
const identity = {
|
|
88
|
+
pid: process.pid,
|
|
89
|
+
stateRoot: path.resolve(configDir),
|
|
90
|
+
machineId,
|
|
91
|
+
nonce: crypto.randomUUID(),
|
|
92
|
+
...(process.env.HAPPY_ELVES_UPDATER_GENERATION ? { generation: process.env.HAPPY_ELVES_UPDATER_GENERATION } : {}),
|
|
93
|
+
startedAt: new Date().toISOString(),
|
|
94
|
+
};
|
|
64
95
|
await fs.writeFile(pidPath, `${process.pid}\n`, { mode: 0o600 });
|
|
96
|
+
await fs.chmod(pidPath, 0o600);
|
|
97
|
+
const temporary = `${identityPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
98
|
+
await fs.writeFile(temporary, `${JSON.stringify(identity)}\n`, { mode: 0o600 });
|
|
99
|
+
await fs.rename(temporary, identityPath);
|
|
100
|
+
await fs.chmod(identityPath, 0o600);
|
|
65
101
|
}
|
|
66
102
|
export async function removePidIfCurrent() {
|
|
67
|
-
const
|
|
68
|
-
|
|
103
|
+
const record = await readPidRecord();
|
|
104
|
+
const identity = await readDaemonIdentity();
|
|
105
|
+
if (record?.pid === process.pid && identity?.pid === process.pid && identity.stateRoot === path.resolve(configDir)) {
|
|
69
106
|
await fs.rm(pidPath, { force: true });
|
|
107
|
+
await fs.rm(identityPath, { force: true });
|
|
70
108
|
}
|
|
71
109
|
}
|
|
72
110
|
export async function localStatus() {
|
|
73
|
-
const
|
|
74
|
-
|
|
111
|
+
const record = await readPidRecord();
|
|
112
|
+
const pid = record?.pid;
|
|
113
|
+
const alive = pid !== undefined && isPidAlive(pid) && isManagedDaemonPid(pid);
|
|
114
|
+
if (!alive)
|
|
115
|
+
return { pidPath, ...(pid ? { pid } : {}), running: false };
|
|
116
|
+
const identity = await readDaemonIdentity();
|
|
117
|
+
const verified = identity?.pid === pid && identity.stateRoot === path.resolve(configDir);
|
|
118
|
+
return { pidPath, pid, running: true, identityVerified: verified };
|
|
75
119
|
}
|
|
76
120
|
export async function stopDaemon(timeoutMs = 5000) {
|
|
77
121
|
const status = await localStatus();
|
|
@@ -79,6 +123,8 @@ export async function stopDaemon(timeoutMs = 5000) {
|
|
|
79
123
|
await fs.rm(pidPath, { force: true });
|
|
80
124
|
return { ...status, stopped: false, running: false };
|
|
81
125
|
}
|
|
126
|
+
if (status.identityVerified !== true)
|
|
127
|
+
throw Object.assign(new Error("The daemon PID is alive but its state-root identity cannot be verified; refusing to stop it"), { code: "DAEMON_IDENTITY_UNVERIFIED" });
|
|
82
128
|
process.kill(status.pid, "SIGTERM");
|
|
83
129
|
const deadline = Date.now() + timeoutMs;
|
|
84
130
|
while (Date.now() < deadline) {
|
|
@@ -23,9 +23,11 @@ import { loadSessionProjections } from "../session/store.js";
|
|
|
23
23
|
import { relayWsUrl } from "../relay-http.js";
|
|
24
24
|
import { applyRelayMachinePresence, applyRelayMachineSnapshot, reconcileRelayMachinePresence, relayMachineRegistry, sessions, sessionTurns } from "../state.js";
|
|
25
25
|
import { handleDevExec, handleDiagnose } from "./devtools.js";
|
|
26
|
+
import { isUpdateAdmissionCommand, updateAdmissionBlocked, updateAdmissionError } from "../update-barrier.js";
|
|
26
27
|
import { handleInboundPromptCommand, recoverInboundPromptAdmissions } from "./inbound-prompt.js";
|
|
27
28
|
import { registerMachine } from "./register.js";
|
|
28
29
|
import { clearActiveRelaySocket, configureRelaySender, flushRelayOutbox, handleMachineMessageAck, handleMachineMessageError, handleRetainedPacketAck, handleRetainedPacketError, sendCommandResponse, setActiveRelaySocket, } from "./send.js";
|
|
30
|
+
const daemonInternalChannel = "product:daemon-internal";
|
|
29
31
|
async function applyMachineManifest(config, retained, presence) {
|
|
30
32
|
try {
|
|
31
33
|
const message = parseMachineClientMessage(await decryptJson(config.accountSecret, retained.encryptedPayload));
|
|
@@ -297,12 +299,14 @@ async function handleRelayWireMessage(ws, config, message) {
|
|
|
297
299
|
if (message.type !== "server:packet")
|
|
298
300
|
return;
|
|
299
301
|
let payload;
|
|
302
|
+
let internalPayload = false;
|
|
300
303
|
try {
|
|
301
304
|
const decrypted = await decryptJson(config.accountSecret, message.packet.encryptedPayload);
|
|
302
305
|
try {
|
|
303
306
|
payload = parseControllerClientMessage(decrypted);
|
|
304
307
|
}
|
|
305
308
|
catch {
|
|
309
|
+
internalPayload = true;
|
|
306
310
|
payload = parseInternalControllerMessage(decrypted);
|
|
307
311
|
}
|
|
308
312
|
}
|
|
@@ -313,6 +317,17 @@ async function handleRelayWireMessage(ws, config, message) {
|
|
|
313
317
|
ackIncomingPacket(ws, message);
|
|
314
318
|
return;
|
|
315
319
|
}
|
|
320
|
+
if (internalPayload && message.packet.channelId !== daemonInternalChannel) {
|
|
321
|
+
await sendCommandResponse(ws, {
|
|
322
|
+
type: "machine:error",
|
|
323
|
+
requestId: requestIdFromControllerMessage(payload),
|
|
324
|
+
sessionId: sessionIdFromControllerMessage(payload),
|
|
325
|
+
code: "DAEMON_INTERNAL_CHANNEL_REQUIRED",
|
|
326
|
+
message: "Daemon-internal commands must arrive on the machine-only internal channel.",
|
|
327
|
+
});
|
|
328
|
+
ackIncomingPacket(ws, message);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
316
331
|
let command;
|
|
317
332
|
try {
|
|
318
333
|
command = await machineCommandFromController(ws, config, payload, message.packet.fromNodeId);
|
|
@@ -328,6 +343,20 @@ async function handleRelayWireMessage(ws, config, message) {
|
|
|
328
343
|
ackIncomingPacket(ws, message);
|
|
329
344
|
return;
|
|
330
345
|
}
|
|
346
|
+
if (command && isUpdateAdmissionCommand(command.type)) {
|
|
347
|
+
const barrier = await updateAdmissionBlocked();
|
|
348
|
+
if (barrier) {
|
|
349
|
+
await sendCommandResponse(ws, {
|
|
350
|
+
type: "machine:error",
|
|
351
|
+
requestId: command.requestId,
|
|
352
|
+
sessionId: sessionIdFromMachineCommand(command),
|
|
353
|
+
code: updateAdmissionError(barrier).code,
|
|
354
|
+
message: updateAdmissionError(barrier).message,
|
|
355
|
+
});
|
|
356
|
+
ackIncomingPacket(ws, message);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
331
360
|
if (command?.type === "machine:prompt") {
|
|
332
361
|
await handleInboundPromptCommand({
|
|
333
362
|
ws,
|
|
@@ -462,10 +491,32 @@ async function machineCommandFromController(ws, config, message, senderNodeId) {
|
|
|
462
491
|
return { ...message, type: "machine:dispatch" };
|
|
463
492
|
if (message.type === "controller:queue")
|
|
464
493
|
return { ...message, type: "machine:queue" };
|
|
465
|
-
if (message.type === "controller:crewQueueEnqueue")
|
|
494
|
+
if (message.type === "controller:crewQueueEnqueue") {
|
|
495
|
+
if (senderNodeId !== message.consumerMachineId) {
|
|
496
|
+
await sendCommandResponse(ws, {
|
|
497
|
+
type: "machine:error",
|
|
498
|
+
requestId: message.requestId,
|
|
499
|
+
code: "INTERNAL_SENDER_MISMATCH",
|
|
500
|
+
capability: "crewQueue.enqueue",
|
|
501
|
+
message: "Crew queue enqueue sender must be the declared consumer machine.",
|
|
502
|
+
});
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
466
505
|
return { ...message, type: "machine:crewQueueEnqueue" };
|
|
467
|
-
|
|
506
|
+
}
|
|
507
|
+
if (message.type === "controller:crewQueueUpdate") {
|
|
508
|
+
if (senderNodeId !== message.crewMachineId) {
|
|
509
|
+
await sendCommandResponse(ws, {
|
|
510
|
+
type: "machine:error",
|
|
511
|
+
requestId: message.requestId,
|
|
512
|
+
code: "INTERNAL_SENDER_MISMATCH",
|
|
513
|
+
capability: "crewQueue.update",
|
|
514
|
+
message: "Crew queue update sender must be the declared Crew owner machine.",
|
|
515
|
+
});
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
468
518
|
return { ...message, type: "machine:crewQueueUpdate" };
|
|
519
|
+
}
|
|
469
520
|
if (message.type === "controller:artifactReplication") {
|
|
470
521
|
if (!senderNodeId) {
|
|
471
522
|
await sendCommandResponse(ws, {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { decryptJson, encryptJson, parseMachineClientMessage, parseRelayServerWireMessage, randomId, } from "../../../../packages/shared/dist/index.js";
|
|
2
2
|
import { relayWsUrl } from "../relay-http.js";
|
|
3
3
|
const controllerProjectionChannel = "product:controllers";
|
|
4
|
+
const daemonInternalChannel = "product:daemon-internal";
|
|
4
5
|
export class RemoteCommandError extends Error {
|
|
5
6
|
code;
|
|
6
7
|
retryable;
|
|
@@ -147,6 +148,7 @@ async function remoteCommandPacket(config, packetId, targetMachineId, command) {
|
|
|
147
148
|
packet: {
|
|
148
149
|
packetId,
|
|
149
150
|
toNodeId: targetMachineId,
|
|
151
|
+
...(isInternalControllerMessage(command) ? { channelId: daemonInternalChannel } : {}),
|
|
150
152
|
streamId: "sessionId" in command ? command.sessionId : "machineId" in command ? command.machineId : undefined,
|
|
151
153
|
correlationId: command.requestId,
|
|
152
154
|
idempotencyKey: command.requestId,
|
|
@@ -156,4 +158,7 @@ async function remoteCommandPacket(config, packetId, targetMachineId, command) {
|
|
|
156
158
|
},
|
|
157
159
|
};
|
|
158
160
|
}
|
|
161
|
+
function isInternalControllerMessage(command) {
|
|
162
|
+
return command.type === "controller:artifactReplication" || command.type === "controller:crewQueueEnqueue" || command.type === "controller:crewQueueUpdate";
|
|
163
|
+
}
|
|
159
164
|
//# sourceMappingURL=remote-command.js.map
|
|
@@ -11,6 +11,7 @@ import { localStatus, removePidIfCurrent, writePid } from "./process.js";
|
|
|
11
11
|
import { configureRuntimeDaemonContext, disposeRuntime, initializeRuntime } from "./runtime.js";
|
|
12
12
|
import { sleep } from "./utils.js";
|
|
13
13
|
import { loadSessionProjections } from "./session/store.js";
|
|
14
|
+
import { startLocalControl } from "./local-control.js";
|
|
14
15
|
export async function start(argv) {
|
|
15
16
|
const flags = parseDaemonFlags(argv);
|
|
16
17
|
const config = await readConfig();
|
|
@@ -40,7 +41,8 @@ export async function start(argv) {
|
|
|
40
41
|
void drainPendingArtifactFeedbackAttentions(config).catch((error) => {
|
|
41
42
|
console.error(`Artifact feedback attention recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
43
|
});
|
|
43
|
-
await writePid();
|
|
44
|
+
await writePid(config.machineId);
|
|
45
|
+
const localControl = await startLocalControl(config.machineId);
|
|
44
46
|
const cleanup = () => {
|
|
45
47
|
disposeRuntime();
|
|
46
48
|
void Promise.resolve()
|
|
@@ -48,6 +50,7 @@ export async function start(argv) {
|
|
|
48
50
|
.then(() => directTransport?.close())
|
|
49
51
|
.catch(() => undefined)
|
|
50
52
|
.then(() => stopGatewayRuntime())
|
|
53
|
+
.then(() => localControl.close())
|
|
51
54
|
.finally(() => removePidIfCurrent().finally(() => process.exit(0)));
|
|
52
55
|
};
|
|
53
56
|
process.once("SIGINT", cleanup);
|
|
@@ -25,4 +25,6 @@ export declare class SessionTurnCoordinator<Turn> {
|
|
|
25
25
|
activeTurn(sessionId: string): Turn | undefined;
|
|
26
26
|
activeTurnId(sessionId: string): string | undefined;
|
|
27
27
|
activeClaim(sessionId: string): SessionTurnClaim | undefined;
|
|
28
|
+
activeCount(): number;
|
|
29
|
+
hasActiveWork(): boolean;
|
|
28
30
|
}
|
|
@@ -80,5 +80,11 @@ export class SessionTurnCoordinator {
|
|
|
80
80
|
const claim = this.claims.get(sessionId);
|
|
81
81
|
return claim ? { ...claim } : undefined;
|
|
82
82
|
}
|
|
83
|
+
activeCount() {
|
|
84
|
+
return this.activeTurnIds.size;
|
|
85
|
+
}
|
|
86
|
+
hasActiveWork() {
|
|
87
|
+
return this.activeTurnIds.size > 0;
|
|
88
|
+
}
|
|
83
89
|
}
|
|
84
90
|
//# sourceMappingURL=turn-coordinator.js.map
|