@cydm/happy-elves 0.1.0-beta.495 → 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 +16 -0
- package/apps/daemon/dist/local-control.d.ts +4 -0
- package/apps/daemon/dist/local-control.js +111 -0
- package/apps/daemon/dist/orchestration/watcher.d.ts +1 -0
- package/apps/daemon/dist/orchestration/watcher.js +67 -7
- 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/register.js +1 -0
- package/apps/daemon/dist/relay/remote-command.js +5 -0
- package/apps/daemon/dist/session/he-official/common.d.ts +3 -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/shared/dist/protocol-schemas.d.ts +6 -0
- package/packages/shared/dist/protocol-schemas.js +3 -0
- package/packages/shared/dist/protocol-types.d.ts +3 -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),
|
|
@@ -742,6 +757,7 @@ class DirectState {
|
|
|
742
757
|
acpxVersion: runtimeCapabilities.version,
|
|
743
758
|
daemonVersion: daemonPackage.version,
|
|
744
759
|
...(runtimeCapabilities.turnPrimitives ? { turnPrimitives: runtimeCapabilities.turnPrimitives } : {}),
|
|
760
|
+
runtimeEvents: { forwardCursor: true },
|
|
745
761
|
sessionPrimitives: runtimePrimitiveSupportForCapabilities(runtimeCapabilities.sessionPrimitives, "native"),
|
|
746
762
|
...(runtimeCapabilities.sidechat ? { sidechat: runtimeCapabilities.sidechat } : {}),
|
|
747
763
|
directoryBrowser: true,
|
|
@@ -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
|
|
@@ -22,6 +22,7 @@ export declare function catchUpSubscriptionEvents(input: {
|
|
|
22
22
|
config: DaemonConfig;
|
|
23
23
|
subscription: OrchestrationSubscriptionRecord;
|
|
24
24
|
}): Promise<SubscriptionCatchUpResult>;
|
|
25
|
+
export declare function remoteEventCursorForSubscription(crewMachineId: string, subscription: Pick<OrchestrationSubscriptionRecord, "remoteEventCursor" | "remoteEventSeq">): string | undefined;
|
|
25
26
|
export declare function attentionFromReconcile(result: TurnReconcileResult): {
|
|
26
27
|
eventType: TaskAttentionEventType;
|
|
27
28
|
reason: string;
|
|
@@ -4,10 +4,13 @@ import { RemoteCommandError, requestRemoteMachineResult } from "../relay/remote-
|
|
|
4
4
|
import { runtimeRead } from "../session/runtime-truth.js";
|
|
5
5
|
import { activeSubscriptionsForTurn, appendAttention, markSubscriptionLiveness, markSubscriptionRemoteScan, recordTurnRuntimeBinding, } from "./store.js";
|
|
6
6
|
import { scheduleRemoteOrchestrationWork } from "./remote-work-budget.js";
|
|
7
|
+
import { relayMachineRegistry } from "../state.js";
|
|
7
8
|
const remoteCatchupsInFlight = new Map();
|
|
8
9
|
const legacyRemoteCursorFallbackUntil = new Map();
|
|
10
|
+
const remoteForwardCursorConfirmed = new Set();
|
|
9
11
|
const legacyRemoteCursorFallbackMs = 5 * 60 * 1000;
|
|
10
12
|
const initialRemoteEventCursor = "event_000000000000";
|
|
13
|
+
const forwardEventCursorSinceBeta = 495;
|
|
11
14
|
export async function observeSessionEvent(input) {
|
|
12
15
|
const attention = attentionFromEvent(input.payload);
|
|
13
16
|
if (!attention) {
|
|
@@ -116,11 +119,10 @@ export async function catchUpSubscriptionEvents(input) {
|
|
|
116
119
|
}
|
|
117
120
|
async function catchUpSubscriptionEventsInternal(input) {
|
|
118
121
|
const crewMachineId = input.subscription.crewMachineId ?? input.config.machineId;
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
? input.subscription.remoteEventCursor
|
|
122
|
-
?? (input.subscription.remoteEventSeq ? `event_${String(input.subscription.remoteEventSeq).padStart(12, "0")}` : initialRemoteEventCursor)
|
|
122
|
+
const remoteCursor = crewMachineId !== input.config.machineId
|
|
123
|
+
? remoteEventCursorForSubscription(crewMachineId, input.subscription)
|
|
123
124
|
: undefined;
|
|
125
|
+
const remoteCursorEnabled = remoteCursor !== undefined;
|
|
124
126
|
try {
|
|
125
127
|
const result = crewMachineId === input.config.machineId
|
|
126
128
|
? await runtimeRead(input.config, {
|
|
@@ -171,7 +173,14 @@ async function catchUpSubscriptionEventsInternal(input) {
|
|
|
171
173
|
// the forward cursor in its response. Treat that as the legacy path for
|
|
172
174
|
// this machine; in particular, never advance a durable cursor from a
|
|
173
175
|
// page whose peer cannot prove append-only progress.
|
|
174
|
-
if (crewMachineId !== input.config.machineId &&
|
|
176
|
+
if (crewMachineId !== input.config.machineId && result.cursorSupported === true) {
|
|
177
|
+
remoteForwardCursorConfirmed.add(crewMachineId);
|
|
178
|
+
}
|
|
179
|
+
else if (crewMachineId !== input.config.machineId && remoteCursorEnabled && !result.nextAfter) {
|
|
180
|
+
// A peer that accepted the forward request but cannot prove the cursor
|
|
181
|
+
// contract is no longer trusted. Remove the positive handshake so the
|
|
182
|
+
// next safe, legacy-compatible request can renegotiate it.
|
|
183
|
+
remoteForwardCursorConfirmed.delete(crewMachineId);
|
|
175
184
|
legacyRemoteCursorFallbackUntil.set(crewMachineId, Date.now() + legacyRemoteCursorFallbackMs);
|
|
176
185
|
}
|
|
177
186
|
const observed = await observeSubscriptionRuntimeEvents({
|
|
@@ -201,6 +210,7 @@ async function catchUpSubscriptionEventsInternal(input) {
|
|
|
201
210
|
}
|
|
202
211
|
catch (error) {
|
|
203
212
|
if (crewMachineId !== input.config.machineId && remoteCursorEnabled && isRemoteEventCursorCompatibilityFailure(error)) {
|
|
213
|
+
remoteForwardCursorConfirmed.delete(crewMachineId);
|
|
204
214
|
legacyRemoteCursorFallbackUntil.set(crewMachineId, Date.now() + legacyRemoteCursorFallbackMs);
|
|
205
215
|
}
|
|
206
216
|
await appendOrchestrationAudit(input.config, "orchestration.subscription.catchup_failed", `Subscription catch-up failed for ${input.subscription.taskId}`, {
|
|
@@ -216,13 +226,63 @@ async function catchUpSubscriptionEventsInternal(input) {
|
|
|
216
226
|
function canUseRemoteEventCursor(crewMachineId) {
|
|
217
227
|
const until = legacyRemoteCursorFallbackUntil.get(crewMachineId);
|
|
218
228
|
if (until === undefined)
|
|
219
|
-
return
|
|
229
|
+
return remotePeerAdvertisesForwardCursor(crewMachineId);
|
|
220
230
|
if (until <= Date.now()) {
|
|
221
231
|
legacyRemoteCursorFallbackUntil.delete(crewMachineId);
|
|
222
|
-
return
|
|
232
|
+
return remotePeerAdvertisesForwardCursor(crewMachineId);
|
|
223
233
|
}
|
|
224
234
|
return false;
|
|
225
235
|
}
|
|
236
|
+
export function remoteEventCursorForSubscription(crewMachineId, subscription) {
|
|
237
|
+
const fallbackUntil = legacyRemoteCursorFallbackUntil.get(crewMachineId);
|
|
238
|
+
if (fallbackUntil !== undefined && fallbackUntil > Date.now())
|
|
239
|
+
return undefined;
|
|
240
|
+
if (fallbackUntil !== undefined)
|
|
241
|
+
legacyRemoteCursorFallbackUntil.delete(crewMachineId);
|
|
242
|
+
const advertised = remotePeerForwardCursorSupport(crewMachineId);
|
|
243
|
+
// An explicit old version wins over a stale persisted cursor (for example,
|
|
244
|
+
// after a peer downgrade). Unknown manifests are different: a persisted
|
|
245
|
+
// cursor is still positive evidence until the peer identity is refreshed.
|
|
246
|
+
if (advertised === false)
|
|
247
|
+
return undefined;
|
|
248
|
+
// A persisted cursor is positive evidence from a previous successful
|
|
249
|
+
// forward-page response. It remains usable even when the peer's manifest is
|
|
250
|
+
// temporarily unavailable during reconnect.
|
|
251
|
+
if (subscription.remoteEventCursor)
|
|
252
|
+
return subscription.remoteEventCursor;
|
|
253
|
+
if (advertised !== true || !canUseRemoteEventCursor(crewMachineId))
|
|
254
|
+
return undefined;
|
|
255
|
+
return subscription.remoteEventSeq
|
|
256
|
+
? `event_${String(subscription.remoteEventSeq).padStart(12, "0")}`
|
|
257
|
+
: initialRemoteEventCursor;
|
|
258
|
+
}
|
|
259
|
+
function remotePeerAdvertisesForwardCursor(crewMachineId) {
|
|
260
|
+
return remotePeerForwardCursorSupport(crewMachineId) === true;
|
|
261
|
+
}
|
|
262
|
+
function remotePeerForwardCursorSupport(crewMachineId) {
|
|
263
|
+
const machine = relayMachineRegistry.get(crewMachineId);
|
|
264
|
+
const daemonVersion = machine?.capabilities.daemonVersion?.trim();
|
|
265
|
+
const advertisedFeature = machine?.capabilities.runtimeEvents?.forwardCursor;
|
|
266
|
+
if (advertisedFeature === false)
|
|
267
|
+
return false;
|
|
268
|
+
if (!daemonVersion) {
|
|
269
|
+
if (advertisedFeature === true || remoteForwardCursorConfirmed.has(crewMachineId))
|
|
270
|
+
return true;
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
// Published beta495 introduced the append-only eventsPage `after` field.
|
|
274
|
+
// Workspace daemons also publish an explicit feature bit. The historical
|
|
275
|
+
// package version 0.1.0 alone is not evidence: older development daemons
|
|
276
|
+
// used that same version while rejecting `after`.
|
|
277
|
+
const match = /^0\.1\.0-beta\.(\d+)$/.exec(daemonVersion);
|
|
278
|
+
if (match && Number(match[1]) < forwardEventCursorSinceBeta)
|
|
279
|
+
return false;
|
|
280
|
+
if (advertisedFeature === true || remoteForwardCursorConfirmed.has(crewMachineId))
|
|
281
|
+
return true;
|
|
282
|
+
if (match)
|
|
283
|
+
return Number(match[1]) >= forwardEventCursorSinceBeta;
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
226
286
|
function isRemoteEventCursorCompatibilityFailure(error) {
|
|
227
287
|
if (!(error instanceof RemoteCommandError))
|
|
228
288
|
return false;
|
|
@@ -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) {
|