@aron98/load-lock 0.1.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -12
- package/demo/demo.sh +1 -1
- package/dist/adapters/opencode/plugin.js +39 -18
- package/dist/adapters/opencode/tui.d.ts +61 -0
- package/dist/adapters/opencode/tui.js +358 -0
- package/dist/cli/hook.js +35 -1
- package/dist/cli/install.js +708 -24
- package/dist/cli/main.js +130 -61
- package/dist/cli/top.d.ts +7 -0
- package/dist/cli/top.js +99 -0
- package/dist/core/daemon.d.ts +25 -4
- package/dist/core/daemon.js +67 -30
- package/dist/core/persist.js +5 -12
- package/dist/core/state.js +7 -0
- package/dist/dashboard/feed.d.ts +74 -0
- package/dist/dashboard/feed.js +256 -0
- package/dist/dashboard/frame.d.ts +13 -0
- package/dist/dashboard/frame.js +95 -0
- package/dist/dashboard/view.d.ts +149 -0
- package/dist/dashboard/view.js +214 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +54 -19
- package/dist/policy/text.d.ts +1 -1
- package/dist/policy/text.js +3 -0
- package/dist/shared/client.d.ts +10 -0
- package/dist/shared/client.js +41 -14
- package/dist/shared/protocol.d.ts +29 -0
- package/dist/shared/transport.d.ts +40 -0
- package/dist/shared/transport.js +136 -0
- package/dist/shared/wait-turn.d.ts +29 -0
- package/dist/shared/wait-turn.js +315 -0
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +99 -0
- package/dist/test/dashboard.test.d.ts +1 -0
- package/dist/test/dashboard.test.js +406 -0
- package/dist/test/hook.test.js +53 -3
- package/dist/test/install.test.d.ts +1 -0
- package/dist/test/install.test.js +658 -0
- package/dist/test/mcp.test.js +36 -1
- package/dist/test/platform.test.d.ts +1 -0
- package/dist/test/platform.test.js +193 -0
- package/dist/test/testutil.js +4 -1
- package/dist/test/tui.test.d.ts +1 -0
- package/dist/test/tui.test.js +89 -0
- package/dist/test/wait-turn.test.d.ts +1 -0
- package/dist/test/wait-turn.test.js +376 -0
- package/docs/ADAPTERS.md +5 -2
- package/docs/AGENT_GUIDE.md +34 -1
- package/docs/ARCHITECTURE.md +25 -5
- package/docs/COMPATIBILITY.md +217 -26
- package/docs/OPERATIONS.md +335 -8
- package/package.json +25 -1
- package/skills/load-lock/SKILL.md +4 -0
package/dist/cli/main.js
CHANGED
|
@@ -7,12 +7,15 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
-
import { homedir } from "node:os";
|
|
11
10
|
import { join } from "node:path";
|
|
12
11
|
import { LLClient, withClient } from "../shared/client.js";
|
|
13
12
|
import { ProtocolError } from "../shared/protocol.js";
|
|
13
|
+
import { waitForTurn } from "../shared/wait-turn.js";
|
|
14
|
+
import { stateDir, socketPath } from "../core/persist.js";
|
|
15
|
+
import { isWindows, probeTransport, killProcessGroup } from "../shared/transport.js";
|
|
14
16
|
import { installCommand, uninstallCommand } from "./install.js";
|
|
15
17
|
import { hookCommand } from "./hook.js";
|
|
18
|
+
import { topCommand } from "./top.js";
|
|
16
19
|
// ---------------------------------------------------------------- arg parsing
|
|
17
20
|
const argv = process.argv.slice(2);
|
|
18
21
|
let flags = {};
|
|
@@ -58,7 +61,84 @@ function defaultAgent() {
|
|
|
58
61
|
return (process.env.LOAD_LOCK_AGENT ||
|
|
59
62
|
"cli:" + (process.env.USER || "user") + "@" + (process.env.HOSTNAME || "localhost"));
|
|
60
63
|
}
|
|
64
|
+
const HELP_GROUPS = [
|
|
65
|
+
{
|
|
66
|
+
title: "permit operations",
|
|
67
|
+
items: [
|
|
68
|
+
{ names: ["acquire"], desc: "request a permit: --pool --op-key --reason [--wait-ms] [--no-queue]" },
|
|
69
|
+
{ names: ["claim"], desc: "claim an offered permit before its window expires: --offer <token>" },
|
|
70
|
+
{ names: ["release"], desc: "release a held permit: --permit <token> [--outcome success|failure]" },
|
|
71
|
+
{ names: ["cancel"], desc: "cancel a queued/offered/paused request: --id <request-id> [--admin]" },
|
|
72
|
+
{ names: ["rejoin"], desc: "rejoin the queue after your offer expired or you declined: --id <request-id>" },
|
|
73
|
+
{ names: ["mark-started"], desc: "mark a permit as started when no pid can be attached: --permit <token>" },
|
|
74
|
+
{ names: ["attach-pid"], desc: "tie a running process to a permit (auto-release on exit): --permit <token> --pid <pid>" },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
title: "waiting & running",
|
|
79
|
+
items: [
|
|
80
|
+
{ names: ["wait"], desc: "sleep until a queued request's turn, then auto-claim the offer: --id <request-id> [--timeout <ms>]" },
|
|
81
|
+
{ names: ["run"], desc: "acquire (or reuse) a permit and run a command under it: run [--permit <token> | acquire flags] -- <cmd>" },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
title: "read-only",
|
|
86
|
+
items: [
|
|
87
|
+
{ names: ["status"], desc: "pools, holders, offers, queue — the machine-wide view" },
|
|
88
|
+
{ names: ["pending"], desc: "your outstanding offers, queue entries and paused requests" },
|
|
89
|
+
{ names: ["events"], desc: "durable event log: [--since <id>] [--follow]" },
|
|
90
|
+
{ names: ["inspect", "machine"], desc: "machine resources (incl. cgroup limits) and current pressure" },
|
|
91
|
+
{ names: ["config", "get-config"], desc: "current configuration" },
|
|
92
|
+
{ names: ["configure"], desc: "change configuration (compare-and-swap): --reason required" },
|
|
93
|
+
{ names: ["dashboard", "top"], desc: "full-screen live dashboard, read-only (never acquires): [--once]" },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
title: "service & diagnostics",
|
|
98
|
+
items: [
|
|
99
|
+
{ names: ["service"], desc: "daemon lifecycle: start | stop | status" },
|
|
100
|
+
{ names: ["doctor"], desc: "health report: state dir, socket, daemon, machine" },
|
|
101
|
+
{ names: ["recover"], desc: "list held permits with diagnostics; recover force-release --id <request-id>" },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
title: "setup & docs",
|
|
106
|
+
items: [
|
|
107
|
+
{ names: ["install"], desc: "install the load-lock adapter for a harness: --harness <name>" },
|
|
108
|
+
{ names: ["uninstall"], desc: "remove what install added: --harness <name>" },
|
|
109
|
+
{ names: ["hook"], desc: "harness hook handlers (called by integrations, not humans)" },
|
|
110
|
+
{ names: ["agent-guide"], desc: "print the full agent guide" },
|
|
111
|
+
{ names: ["help"], desc: "this usage; also bare load-lock, --help, -h; help <command> for one line" },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
function displayName(item) {
|
|
116
|
+
return item.names[0] + (item.names.length > 1 ? ` (${item.names.slice(1).join(", ")})` : "");
|
|
117
|
+
}
|
|
118
|
+
const HELP_PAD = Math.max(...HELP_GROUPS.flatMap((g) => g.items.map((i) => displayName(i).length)));
|
|
119
|
+
function helpLine(item) {
|
|
120
|
+
return ` ${displayName(item).padEnd(HELP_PAD)} ${item.desc}`;
|
|
121
|
+
}
|
|
122
|
+
/** Grouped one-screen usage, printed by `help`, bare `load-lock`, `--help`/`-h`. */
|
|
123
|
+
function usage() {
|
|
124
|
+
const lines = [
|
|
125
|
+
"load-lock — machine-wide heavy-work budget shared by all coding agents",
|
|
126
|
+
"usage: load-lock <command> [flags] (--json works on read commands)",
|
|
127
|
+
];
|
|
128
|
+
for (const g of HELP_GROUPS) {
|
|
129
|
+
lines.push("", `${g.title}:`);
|
|
130
|
+
for (const item of g.items)
|
|
131
|
+
lines.push(helpLine(item));
|
|
132
|
+
}
|
|
133
|
+
lines.push("", "exit codes: 0 ok, 64 usage, 65 protocol, 68 not granted yet, 69 offer/claim failed, 70 internal");
|
|
134
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
135
|
+
}
|
|
61
136
|
async function main() {
|
|
137
|
+
// `--help`, `-h` and a bare `load-lock` all print the same usage as `help`.
|
|
138
|
+
if (f["help"] !== undefined || cmd === "-h" || (positional.length === 0 && argv.length === 0)) {
|
|
139
|
+
usage();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
62
142
|
switch (cmd) {
|
|
63
143
|
// ------------------------------------------------------------- read-only
|
|
64
144
|
case "status": {
|
|
@@ -81,6 +161,30 @@ async function main() {
|
|
|
81
161
|
});
|
|
82
162
|
return;
|
|
83
163
|
}
|
|
164
|
+
// ------------------------------------------------------------- dashboard
|
|
165
|
+
case "dashboard":
|
|
166
|
+
case "top": {
|
|
167
|
+
// Full-screen live dashboard (read-only, never acquires a permit).
|
|
168
|
+
// `dashboard` is the primary name; `top` stays as a hidden compat
|
|
169
|
+
// alias. Falls back to a single rendered frame when stdout is not a
|
|
170
|
+
// TTY (or with --once).
|
|
171
|
+
await topCommand({ once: B("once") });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
case "help": {
|
|
175
|
+
// `load-lock help` — grouped one-screen usage. `help <command>` is
|
|
176
|
+
// the optional per-command form: just that command's one-liner.
|
|
177
|
+
const topic = rest[0];
|
|
178
|
+
if (topic === undefined) {
|
|
179
|
+
usage();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const item = HELP_GROUPS.flatMap((g) => g.items).find((i) => i.names.includes(topic));
|
|
183
|
+
if (!item)
|
|
184
|
+
fail(`help: unknown command "${topic}" — run "load-lock help" for the full list`);
|
|
185
|
+
process.stdout.write(`load-lock ${item.names[0]} — ${item.desc}\n`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
84
188
|
case "inspect":
|
|
85
189
|
case "machine":
|
|
86
190
|
out(await withClient((c) => c.request("inspect", {})));
|
|
@@ -108,6 +212,8 @@ async function main() {
|
|
|
108
212
|
changes.offerTimeoutMs = N("offer-timeout-ms", 0);
|
|
109
213
|
if (S("max-wait-ms"))
|
|
110
214
|
changes.maxWaitMs = N("max-wait-ms", 0);
|
|
215
|
+
if (S("max-turn-wait-ms"))
|
|
216
|
+
changes.maxTurnWaitMs = N("max-turn-wait-ms", 0);
|
|
111
217
|
if (S("start-timeout-ms"))
|
|
112
218
|
changes.startTimeoutMs = N("start-timeout-ms", 0);
|
|
113
219
|
if (S("untracked-lease-ms"))
|
|
@@ -115,7 +221,7 @@ async function main() {
|
|
|
115
221
|
if (S("heavy-work-heuristic"))
|
|
116
222
|
changes.guidance = { heavyWorkHeuristic: S("heavy-work-heuristic") };
|
|
117
223
|
if (!Object.keys(changes).length)
|
|
118
|
-
fail("configure: no changes given (--capacity, --offer-timeout-ms, --max-wait-ms, --start-timeout-ms, --untracked-lease-ms, --heavy-work-heuristic)");
|
|
224
|
+
fail("configure: no changes given (--capacity, --offer-timeout-ms, --max-wait-ms, --max-turn-wait-ms, --start-timeout-ms, --untracked-lease-ms, --heavy-work-heuristic)");
|
|
119
225
|
out(await c.request("configure", { expectedVersion: expected, changes, reason, author }));
|
|
120
226
|
});
|
|
121
227
|
return;
|
|
@@ -196,55 +302,24 @@ async function main() {
|
|
|
196
302
|
case "wait": {
|
|
197
303
|
// Watches a queued request; auto-claims the offer when capacity is
|
|
198
304
|
// handed to it and prints the permit token. Honest bounded blocking
|
|
199
|
-
// for scriptable flows (aider, plain shell scripts).
|
|
305
|
+
// for scriptable flows (aider, plain shell scripts). Shares the exact
|
|
306
|
+
// implementation with the MCP wait_for_turn tool and the OpenCode
|
|
307
|
+
// plugin (src/shared/wait-turn.ts).
|
|
200
308
|
const id = S("id") || fail("wait: --id <request-id> required");
|
|
201
309
|
const timeoutMs = N("timeout", 600_000);
|
|
202
310
|
const agent = S("agent", defaultAgent());
|
|
203
311
|
const c = new LLClient();
|
|
204
312
|
await c.connect();
|
|
205
313
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const timer = timeoutMs > 0 ? setTimeout(() => finish(null), timeoutMs) : null;
|
|
216
|
-
void (async () => {
|
|
217
|
-
while (!resolved) {
|
|
218
|
-
try {
|
|
219
|
-
const r = (await c.request("events", { sinceEventId: last, agentId: agent }));
|
|
220
|
-
last = r.latestEventId;
|
|
221
|
-
for (const ev of r.events) {
|
|
222
|
-
if (ev.entry?.requestId !== id)
|
|
223
|
-
continue;
|
|
224
|
-
if (ev.type === "offered" && ev.offerToken) {
|
|
225
|
-
const claimed = (await c.request("claim", { offerToken: ev.offerToken }));
|
|
226
|
-
if (timer)
|
|
227
|
-
clearTimeout(timer);
|
|
228
|
-
finish(claimed.entry.permitToken);
|
|
229
|
-
}
|
|
230
|
-
else if (ev.type === "cancelled" || ev.type === "paused" || ev.type === "reclaimed") {
|
|
231
|
-
if (timer)
|
|
232
|
-
clearTimeout(timer);
|
|
233
|
-
finish(null);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
catch {
|
|
238
|
-
/* transient; retry */
|
|
239
|
-
}
|
|
240
|
-
await new Promise((r2) => setTimeout(r2, 200));
|
|
241
|
-
}
|
|
242
|
-
})();
|
|
243
|
-
});
|
|
244
|
-
if (!permit)
|
|
245
|
-
fail("wait: timed out or the request was cancelled/paused/reclaimed", 69);
|
|
246
|
-
out({ permit_token: permit });
|
|
247
|
-
return;
|
|
314
|
+
const r = await waitForTurn({ client: c, agentId: agent, requestId: id, timeoutMs });
|
|
315
|
+
if (r.state === "granted" && r.entry?.permitToken) {
|
|
316
|
+
out({ permit_token: r.entry.permitToken, state: r.state, request_id: r.requestId, auto_claimed: r.autoClaimed ?? false });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
out({ state: r.state, request_id: r.requestId, detail: r.detail ?? r.nextAction });
|
|
320
|
+
if (r.state === "still_queued")
|
|
321
|
+
fail(`wait: still queued (position ${r.queuePosition ?? "?"}) after ${r.waitedMs}ms`, 68);
|
|
322
|
+
fail(`wait: request is ${r.state.replace("_", " ")} — ${r.nextAction}`, 69);
|
|
248
323
|
}
|
|
249
324
|
finally {
|
|
250
325
|
c.close();
|
|
@@ -264,7 +339,7 @@ async function main() {
|
|
|
264
339
|
const sub = rest[0] ?? "status";
|
|
265
340
|
if (sub === "stop") {
|
|
266
341
|
// Connect and ask... simplest: SIGTERM via daemon.json pid.
|
|
267
|
-
const dir =
|
|
342
|
+
const dir = stateDir();
|
|
268
343
|
try {
|
|
269
344
|
const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
|
|
270
345
|
process.kill(info.pid, "SIGTERM");
|
|
@@ -291,9 +366,13 @@ async function main() {
|
|
|
291
366
|
const report = {};
|
|
292
367
|
report.node = process.version;
|
|
293
368
|
report.platform = process.platform;
|
|
294
|
-
const dir =
|
|
369
|
+
const dir = stateDir();
|
|
295
370
|
report.stateDir = dir;
|
|
296
|
-
|
|
371
|
+
const sock = socketPath();
|
|
372
|
+
report.socket = sock;
|
|
373
|
+
// Named pipes are kernel objects, not filesystem entries — probe by
|
|
374
|
+
// connecting on Windows; on POSIX a file check is enough.
|
|
375
|
+
report.socketExists = isWindows() ? await probeTransport(sock) : existsSync(sock);
|
|
297
376
|
report.configExists = existsSync(join(dir, "config.json"));
|
|
298
377
|
report.journalExists = existsSync(join(dir, "journal.json"));
|
|
299
378
|
try {
|
|
@@ -356,7 +435,7 @@ async function main() {
|
|
|
356
435
|
return;
|
|
357
436
|
}
|
|
358
437
|
default:
|
|
359
|
-
fail(`unknown command "${cmd}"
|
|
438
|
+
fail(`unknown command "${cmd}" — run "load-lock help" for the list of commands`);
|
|
360
439
|
}
|
|
361
440
|
}
|
|
362
441
|
// ------------------------------------------------------------------ run helper
|
|
@@ -416,21 +495,11 @@ async function runWithPermit(jobArgv) {
|
|
|
416
495
|
await client.request("start_job", { permitToken, pid: child.pid, pgid });
|
|
417
496
|
}
|
|
418
497
|
catch (err) {
|
|
419
|
-
|
|
420
|
-
process.kill(-pgid, "SIGKILL");
|
|
421
|
-
}
|
|
422
|
-
catch {
|
|
423
|
-
/* already gone */
|
|
424
|
-
}
|
|
498
|
+
killProcessGroup(pgid, "SIGKILL");
|
|
425
499
|
fail(`permit no longer startable (${err.message}); job not started`, 69);
|
|
426
500
|
}
|
|
427
501
|
const forward = (sig) => {
|
|
428
|
-
|
|
429
|
-
process.kill(-pgid, sig);
|
|
430
|
-
}
|
|
431
|
-
catch {
|
|
432
|
-
/* already gone */
|
|
433
|
-
}
|
|
502
|
+
killProcessGroup(pgid, sig);
|
|
434
503
|
};
|
|
435
504
|
process.on("SIGTERM", () => forward("SIGTERM"));
|
|
436
505
|
process.on("SIGINT", () => forward("SIGINT"));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface TopOptions {
|
|
2
|
+
/** Render one frame and exit (also implied when stdout is not a TTY). */
|
|
3
|
+
once?: boolean;
|
|
4
|
+
/** Local re-render cadence for countdowns (default 500 ms). */
|
|
5
|
+
tickMs?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function topCommand(opts?: TopOptions): Promise<void>;
|
package/dist/cli/top.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `load-lock dashboard` (hidden compat alias: `load-lock top`) — portable,
|
|
3
|
+
* zero-dependency full-screen terminal dashboard for the machine-wide
|
|
4
|
+
* budget. Uses the library's own client + protocol
|
|
5
|
+
* (never scrapes CLI output); live updates come from the `watch` event
|
|
6
|
+
* stream, with reconnect + event-cursor resume across daemon restarts.
|
|
7
|
+
*
|
|
8
|
+
* Read-only. The viewer itself is light work — it never acquires a permit.
|
|
9
|
+
*/
|
|
10
|
+
import { DashboardFeed } from "../dashboard/feed.js";
|
|
11
|
+
import { renderFrame } from "../dashboard/frame.js";
|
|
12
|
+
import { LLClient } from "../shared/client.js";
|
|
13
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
14
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
15
|
+
const ALT_SCREEN = "\x1b[?1049h";
|
|
16
|
+
const LEAVE_ALT = "\x1b[?1049l";
|
|
17
|
+
export async function topCommand(opts = {}) {
|
|
18
|
+
const once = opts.once || !process.stdout.isTTY;
|
|
19
|
+
const client = new LLClient();
|
|
20
|
+
const feed = new DashboardFeed(client);
|
|
21
|
+
try {
|
|
22
|
+
await feed.start();
|
|
23
|
+
if (once) {
|
|
24
|
+
const frame = renderFrame(feed.view(), process.stdout.columns || 100, 1000);
|
|
25
|
+
process.stdout.write(frame.join("\n") + "\n");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await runLive(feed, opts);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await feed.stop();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function runLive(feed, opts) {
|
|
35
|
+
const out = process.stdout;
|
|
36
|
+
const tickMs = Math.max(100, opts.tickMs ?? 500);
|
|
37
|
+
let stopped = false;
|
|
38
|
+
const paint = () => {
|
|
39
|
+
const width = out.columns || 100;
|
|
40
|
+
const height = out.rows || 40;
|
|
41
|
+
const frame = renderFrame(feed.view(), width, Math.max(1, height - 1));
|
|
42
|
+
// Pad to full height so old content never lingers after shrinking.
|
|
43
|
+
while (frame.length < height - 1)
|
|
44
|
+
frame.push("");
|
|
45
|
+
const body = frame.map((l) => l + "\x1b[K").join("\n");
|
|
46
|
+
out.write(`\x1b[H\x1b[2J${body}\n`);
|
|
47
|
+
};
|
|
48
|
+
// The feed signals change asynchronously; the tick loop below repaints on
|
|
49
|
+
// every tick anyway (cheap string render) so new events and countdowns
|
|
50
|
+
// both appear promptly without a paint storm per event burst.
|
|
51
|
+
out.write(ALT_SCREEN + HIDE_CURSOR);
|
|
52
|
+
out.write("\x1b[H\x1b[2J");
|
|
53
|
+
process.stdin.setRawMode?.(true);
|
|
54
|
+
process.stdin.resume();
|
|
55
|
+
const restore = () => {
|
|
56
|
+
process.stdin.setRawMode?.(false);
|
|
57
|
+
out.write(SHOW_CURSOR + LEAVE_ALT);
|
|
58
|
+
};
|
|
59
|
+
let exiting = false;
|
|
60
|
+
const quit = () => {
|
|
61
|
+
if (exiting)
|
|
62
|
+
return;
|
|
63
|
+
exiting = true;
|
|
64
|
+
restore();
|
|
65
|
+
stopped = true;
|
|
66
|
+
process.stdin.pause();
|
|
67
|
+
};
|
|
68
|
+
const onKey = (data) => {
|
|
69
|
+
const k = data.toString("utf8");
|
|
70
|
+
if (k === "q" || k === "Q" || k === "\x1b" || k === "\x03")
|
|
71
|
+
quit();
|
|
72
|
+
else if (k === "r" || k === "R")
|
|
73
|
+
void feed.resync().catch(() => { });
|
|
74
|
+
};
|
|
75
|
+
process.stdin.on("data", onKey);
|
|
76
|
+
const onResize = () => paint();
|
|
77
|
+
out.on("resize", onResize);
|
|
78
|
+
const onSignal = () => {
|
|
79
|
+
quit();
|
|
80
|
+
};
|
|
81
|
+
process.on("SIGINT", onSignal);
|
|
82
|
+
process.on("SIGTERM", onSignal);
|
|
83
|
+
paint();
|
|
84
|
+
try {
|
|
85
|
+
while (!stopped) {
|
|
86
|
+
await new Promise((r) => setTimeout(r, tickMs));
|
|
87
|
+
if (stopped)
|
|
88
|
+
continue;
|
|
89
|
+
paint(); // cheap local re-render keeps countdowns ticking and shows new events
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
restore();
|
|
94
|
+
process.stdin.removeListener("data", onKey);
|
|
95
|
+
out.removeListener("resize", onResize);
|
|
96
|
+
process.removeListener("SIGINT", onSignal);
|
|
97
|
+
process.removeListener("SIGTERM", onSignal);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -22,6 +22,10 @@ export declare class Daemon {
|
|
|
22
22
|
machine: Machine;
|
|
23
23
|
server: Server | null;
|
|
24
24
|
private watchers;
|
|
25
|
+
/** All open client connections — destroyed on stop() so a graceful
|
|
26
|
+
* shutdown cuts waiting clients loose (they reconnect + detect the
|
|
27
|
+
* restart via the event cursor) and the process can exit promptly. */
|
|
28
|
+
private conns;
|
|
25
29
|
private waiters;
|
|
26
30
|
private sentEventId;
|
|
27
31
|
private stateDirPath;
|
|
@@ -36,7 +40,11 @@ export declare class Daemon {
|
|
|
36
40
|
recover?: boolean;
|
|
37
41
|
});
|
|
38
42
|
start(): Promise<void>;
|
|
39
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Bind the endpoint, replacing it only if it is verifiably stale.
|
|
45
|
+
* Windows: named pipes are kernel objects — no stale file can exist, so
|
|
46
|
+
* just refuse if another daemon owns the pipe.
|
|
47
|
+
*/
|
|
40
48
|
private claimSocket;
|
|
41
49
|
stop(): void;
|
|
42
50
|
private onConnection;
|
|
@@ -65,15 +73,28 @@ export declare class Daemon {
|
|
|
65
73
|
*/
|
|
66
74
|
private sweep;
|
|
67
75
|
}
|
|
68
|
-
/**
|
|
69
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Liveness via signal 0 — the /proc-less fallback (macOS, Windows).
|
|
78
|
+
* EPERM counts as alive: the process exists but is not signalable by us.
|
|
79
|
+
*/
|
|
80
|
+
export declare function aliveBySignalZero(pid: number): boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse.
|
|
83
|
+
* Windows: no /proc and no start time — signal-0 fallback only; the
|
|
84
|
+
* pid-reuse defense is unavailable there (documented limitation).
|
|
85
|
+
* macOS: /proc is absent, so the signal-0 fallback applies too.
|
|
86
|
+
*/
|
|
87
|
+
export declare function isProcessAlive(pid: number, expectedStartTime?: string, platform?: NodeJS.Platform): boolean | null;
|
|
70
88
|
/**
|
|
71
89
|
* Job liveness: a tracked job is alive if its pid is alive (start-time
|
|
72
90
|
* matched) OR any process in its process group is alive — covering
|
|
73
91
|
* subprocesses and background jobs that outlive their parent. Returns
|
|
74
92
|
* true/false on Linux; on platforms without /proc the pgid scan is
|
|
75
93
|
* unavailable and the result may be null (unknown => conservative keep).
|
|
94
|
+
* Windows: no process groups — only the main pid is checked, so a permit
|
|
95
|
+
* is released once the main pid dies even if children survive (documented
|
|
96
|
+
* limitation; adapters should kill the tree via taskkill /T).
|
|
76
97
|
*/
|
|
77
|
-
export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string): boolean | null;
|
|
98
|
+
export declare function jobAlive(pid?: number, pgid?: number, pidStartTime?: string, platform?: NodeJS.Platform): boolean | null;
|
|
78
99
|
/** Read the Linux start time of a pid (pid-reuse-safe association). */
|
|
79
100
|
export declare function processStartTime(pid: number): string | undefined;
|
package/dist/core/daemon.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* All state mutations run on one event loop => every RPC is atomic wrt all
|
|
17
17
|
* others; start_job is therefore atomic wrt reclamation.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
19
|
+
import { createServer } from "node:net";
|
|
20
20
|
import { existsSync, mkdirSync, unlinkSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
21
21
|
import { readdirSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
@@ -24,10 +24,15 @@ import { Machine } from "./state.js";
|
|
|
24
24
|
import { loadState, saveState, loadJournal, saveJournal, socketPath as defaultSocket, stateDir as defaultStateDir } from "./persist.js";
|
|
25
25
|
import { inspectMachine } from "./sysinfo.js";
|
|
26
26
|
import { ProtocolError } from "../shared/protocol.js";
|
|
27
|
+
import { isWindows, probeTransport } from "../shared/transport.js";
|
|
27
28
|
export class Daemon {
|
|
28
29
|
machine;
|
|
29
30
|
server = null;
|
|
30
31
|
watchers = new Set();
|
|
32
|
+
/** All open client connections — destroyed on stop() so a graceful
|
|
33
|
+
* shutdown cuts waiting clients loose (they reconnect + detect the
|
|
34
|
+
* restart via the event cursor) and the process can exit promptly. */
|
|
35
|
+
conns = new Set();
|
|
31
36
|
waiters = new Map();
|
|
32
37
|
sentEventId;
|
|
33
38
|
stateDirPath;
|
|
@@ -70,10 +75,20 @@ export class Daemon {
|
|
|
70
75
|
this.sweepTimer.unref?.();
|
|
71
76
|
this.sweep(); // catch anything that expired while we were down
|
|
72
77
|
}
|
|
73
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Bind the endpoint, replacing it only if it is verifiably stale.
|
|
80
|
+
* Windows: named pipes are kernel objects — no stale file can exist, so
|
|
81
|
+
* just refuse if another daemon owns the pipe.
|
|
82
|
+
*/
|
|
74
83
|
async claimSocket() {
|
|
84
|
+
if (isWindows()) {
|
|
85
|
+
if (await probeTransport(this.socketPath)) {
|
|
86
|
+
throw new ProtocolError("denied", `another daemon is already listening on ${this.socketPath}`);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
75
90
|
if (existsSync(this.socketPath)) {
|
|
76
|
-
const live = await
|
|
91
|
+
const live = await probeTransport(this.socketPath);
|
|
77
92
|
if (live) {
|
|
78
93
|
throw new ProtocolError("denied", `another daemon is already listening on ${this.socketPath}`);
|
|
79
94
|
}
|
|
@@ -96,13 +111,23 @@ export class Daemon {
|
|
|
96
111
|
for (const w of this.watchers)
|
|
97
112
|
w.socket.destroy();
|
|
98
113
|
this.watchers.clear();
|
|
114
|
+
// Cut every client loose too: a graceful stop must not leave a zombie
|
|
115
|
+
// serving already-connected clients while the socket path moves on.
|
|
116
|
+
// Clients see the disconnect, reconnect, and detect the restart.
|
|
117
|
+
for (const s of this.conns)
|
|
118
|
+
s.destroy();
|
|
119
|
+
this.conns.clear();
|
|
99
120
|
this.server?.close();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
if (!isWindows()) {
|
|
122
|
+
// Windows: the named pipe is a kernel object and vanishes on close;
|
|
123
|
+
// touching the pipe path from the filesystem side can block.
|
|
124
|
+
try {
|
|
125
|
+
if (existsSync(this.socketPath))
|
|
126
|
+
unlinkSync(this.socketPath);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* ignore */
|
|
130
|
+
}
|
|
106
131
|
}
|
|
107
132
|
try {
|
|
108
133
|
rmSync(join(this.stateDirPath, "daemon.json"), { force: true });
|
|
@@ -113,6 +138,7 @@ export class Daemon {
|
|
|
113
138
|
}
|
|
114
139
|
// ----------------------------------------------------------------- framing
|
|
115
140
|
onConnection(sock) {
|
|
141
|
+
this.conns.add(sock);
|
|
116
142
|
let buf = "";
|
|
117
143
|
sock.on("data", (chunk) => {
|
|
118
144
|
buf += chunk.toString("utf8");
|
|
@@ -126,6 +152,7 @@ export class Daemon {
|
|
|
126
152
|
});
|
|
127
153
|
sock.on("error", () => sock.destroy());
|
|
128
154
|
sock.on("close", () => {
|
|
155
|
+
this.conns.delete(sock);
|
|
129
156
|
for (const w of [...this.watchers])
|
|
130
157
|
if (w.socket === sock)
|
|
131
158
|
this.watchers.delete(w);
|
|
@@ -428,18 +455,31 @@ export class Daemon {
|
|
|
428
455
|
}
|
|
429
456
|
}
|
|
430
457
|
// --------------------------------------------------------------------- helpers
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
458
|
+
/**
|
|
459
|
+
* Liveness via signal 0 — the /proc-less fallback (macOS, Windows).
|
|
460
|
+
* EPERM counts as alive: the process exists but is not signalable by us.
|
|
461
|
+
*/
|
|
462
|
+
export function aliveBySignalZero(pid) {
|
|
463
|
+
try {
|
|
464
|
+
process.kill(pid, 0);
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
return err?.code === "EPERM";
|
|
469
|
+
}
|
|
440
470
|
}
|
|
441
|
-
/**
|
|
442
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Linux: /proc/<pid>/stat field 22 (starttime) defeats pid reuse.
|
|
473
|
+
* Windows: no /proc and no start time — signal-0 fallback only; the
|
|
474
|
+
* pid-reuse defense is unavailable there (documented limitation).
|
|
475
|
+
* macOS: /proc is absent, so the signal-0 fallback applies too.
|
|
476
|
+
*/
|
|
477
|
+
export function isProcessAlive(pid, expectedStartTime, platform = process.platform) {
|
|
478
|
+
if (isWindows(platform)) {
|
|
479
|
+
// Start time is unobtainable without native APIs; pid reuse is possible
|
|
480
|
+
// during this window (documented in docs/OPERATIONS.md).
|
|
481
|
+
return aliveBySignalZero(pid);
|
|
482
|
+
}
|
|
443
483
|
try {
|
|
444
484
|
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
445
485
|
if (expectedStartTime !== undefined) {
|
|
@@ -455,13 +495,7 @@ export function isProcessAlive(pid, expectedStartTime) {
|
|
|
455
495
|
// /proc unavailable (macOS): fall back to signal 0. Pid reuse is possible
|
|
456
496
|
// here; documented limitation — that is why adapters should prefer
|
|
457
497
|
// run_with_permit / process-group tracking where available.
|
|
458
|
-
|
|
459
|
-
process.kill(pid, 0);
|
|
460
|
-
return true;
|
|
461
|
-
}
|
|
462
|
-
catch {
|
|
463
|
-
return false;
|
|
464
|
-
}
|
|
498
|
+
return aliveBySignalZero(pid);
|
|
465
499
|
}
|
|
466
500
|
}
|
|
467
501
|
/**
|
|
@@ -470,14 +504,17 @@ export function isProcessAlive(pid, expectedStartTime) {
|
|
|
470
504
|
* subprocesses and background jobs that outlive their parent. Returns
|
|
471
505
|
* true/false on Linux; on platforms without /proc the pgid scan is
|
|
472
506
|
* unavailable and the result may be null (unknown => conservative keep).
|
|
507
|
+
* Windows: no process groups — only the main pid is checked, so a permit
|
|
508
|
+
* is released once the main pid dies even if children survive (documented
|
|
509
|
+
* limitation; adapters should kill the tree via taskkill /T).
|
|
473
510
|
*/
|
|
474
|
-
export function jobAlive(pid, pgid, pidStartTime) {
|
|
511
|
+
export function jobAlive(pid, pgid, pidStartTime, platform = process.platform) {
|
|
475
512
|
if (pid !== undefined) {
|
|
476
|
-
const alive = isProcessAlive(pid, pidStartTime);
|
|
513
|
+
const alive = isProcessAlive(pid, pidStartTime, platform);
|
|
477
514
|
if (alive === true)
|
|
478
515
|
return true;
|
|
479
516
|
}
|
|
480
|
-
if (pgid !== undefined) {
|
|
517
|
+
if (pgid !== undefined && !isWindows(platform)) {
|
|
481
518
|
const scan = scanProcessGroup(pgid);
|
|
482
519
|
if (scan !== null)
|
|
483
520
|
return scan;
|
package/dist/core/persist.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Atomic JSON persistence for daemon config + change history.
|
|
3
|
-
* State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock
|
|
3
|
+
* State dir: $XDG_STATE_HOME/load-lock (default ~/.local/state/load-lock;
|
|
4
|
+
* Windows: %LOCALAPPDATA%\load-lock — see shared/transport.ts).
|
|
4
5
|
*
|
|
5
6
|
* Only *configuration* persists across daemon restarts. Permits and queue
|
|
6
7
|
* are ephemeral by design (see docs/ARCHITECTURE.md — restart behavior):
|
|
@@ -9,20 +10,12 @@
|
|
|
9
10
|
import { mkdirSync, writeFileSync, renameSync, readFileSync, existsSync } from "node:fs";
|
|
10
11
|
import { dirname, join } from "node:path";
|
|
11
12
|
import { defaultConfig } from "./state.js";
|
|
13
|
+
import { resolveStateDir, resolveSocketPath } from "../shared/transport.js";
|
|
12
14
|
export function stateDir(override) {
|
|
13
|
-
|
|
14
|
-
return override;
|
|
15
|
-
if (process.env.LOAD_LOCK_STATE_DIR)
|
|
16
|
-
return process.env.LOAD_LOCK_STATE_DIR;
|
|
17
|
-
const xdg = process.env.XDG_STATE_HOME || join(process.env.HOME ?? "~", ".local", "state");
|
|
18
|
-
return join(xdg, "load-lock");
|
|
15
|
+
return resolveStateDir(process.platform, process.env, override);
|
|
19
16
|
}
|
|
20
17
|
export function socketPath(override) {
|
|
21
|
-
|
|
22
|
-
return override;
|
|
23
|
-
if (process.env.LOAD_LOCK_SOCKET)
|
|
24
|
-
return process.env.LOAD_LOCK_SOCKET;
|
|
25
|
-
return join(stateDir(), "daemon.sock");
|
|
18
|
+
return resolveSocketPath(process.platform, process.env, override);
|
|
26
19
|
}
|
|
27
20
|
export function loadState(dir) {
|
|
28
21
|
const path = join(dir, "config.json");
|