@aron98/load-lock 1.0.0 → 1.2.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 +19 -5
- package/dist/adapters/opencode/tui.d.ts +35 -4
- package/dist/adapters/opencode/tui.js +124 -17
- package/dist/cli/main.js +98 -2
- package/dist/cli/top.js +3 -2
- package/dist/core/state.js +11 -2
- package/dist/dashboard/frame.d.ts +3 -2
- package/dist/dashboard/frame.js +4 -3
- package/dist/dashboard/view.d.ts +2 -1
- package/dist/mcp/server.js +2 -0
- package/dist/shared/client.d.ts +13 -0
- package/dist/shared/client.js +48 -0
- package/dist/shared/protocol.d.ts +8 -0
- package/dist/test/cli.test.d.ts +1 -0
- package/dist/test/cli.test.js +106 -0
- package/dist/test/core.test.js +27 -0
- package/dist/test/dashboard.test.js +5 -4
- package/dist/test/mcp.test.js +2 -1
- package/dist/test/sandbox.test.d.ts +1 -0
- package/dist/test/sandbox.test.js +155 -0
- package/dist/test/tui.test.d.ts +1 -0
- package/dist/test/tui.test.js +89 -0
- package/docs/ADAPTERS.md +1 -1
- package/docs/AGENT_GUIDE.md +1 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/COMPATIBILITY.md +15 -13
- package/docs/OPERATIONS.md +413 -16
- package/package.json +1 -1
package/dist/shared/client.js
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* NTFS) and starts a detached daemon; losers wait for the endpoint to
|
|
13
13
|
* appear. The freshly spawned daemon itself re-checks the endpoint before
|
|
14
14
|
* binding, so a double-spawn is harmless (the loser exits).
|
|
15
|
+
*
|
|
16
|
+
* Client-only mode (LOAD_LOCK_NO_SPAWN): sandboxed clients must be thin
|
|
17
|
+
* clients of the HOST daemon. A sandbox-local daemon is useless (it cannot
|
|
18
|
+
* see host processes or host-wide load) and hides the failure — so when the
|
|
19
|
+
* env var is set (any non-empty value except "0"), no daemon is ever
|
|
20
|
+
* auto-spawned here or in ensureDaemon(); an unreachable endpoint fails
|
|
21
|
+
* loudly with a pointer to the Sandboxes section of docs/OPERATIONS.md.
|
|
15
22
|
*/
|
|
16
23
|
import { connect } from "node:net";
|
|
17
24
|
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
@@ -48,6 +55,9 @@ export class LLClient {
|
|
|
48
55
|
catch {
|
|
49
56
|
/* fall through to spawn */
|
|
50
57
|
}
|
|
58
|
+
if (noSpawnRequested()) {
|
|
59
|
+
throw new ProtocolError("unavailable", noSpawnError(this.socketPath()));
|
|
60
|
+
}
|
|
51
61
|
if (!this.opts.spawnDaemon) {
|
|
52
62
|
throw new ProtocolError("unavailable", `load-lock daemon is not running on ${this.socketPath()}`);
|
|
53
63
|
}
|
|
@@ -134,6 +144,13 @@ export class LLClient {
|
|
|
134
144
|
}
|
|
135
145
|
/** Issue one RPC. Reconnects once if the connection was lost. */
|
|
136
146
|
async request(method, params = {}, timeoutMs = 20_000) {
|
|
147
|
+
if (method === "acquire" && params.labelPrefix === undefined) {
|
|
148
|
+
// Sandbox wrapper tagging (LOAD_LOCK_LABEL_PREFIX): applied by the
|
|
149
|
+
// shared client so every adapter (CLI, MCP, plugin, hooks) gets it.
|
|
150
|
+
const prefix = envLabelPrefix();
|
|
151
|
+
if (prefix)
|
|
152
|
+
params = { ...params, labelPrefix: prefix };
|
|
153
|
+
}
|
|
137
154
|
if (!this.sock || this.sock.destroyed)
|
|
138
155
|
await this.connect();
|
|
139
156
|
const id = this.nextId++;
|
|
@@ -179,6 +196,33 @@ export class LLClient {
|
|
|
179
196
|
}
|
|
180
197
|
}
|
|
181
198
|
// ------------------------------------------------------------------- spawning
|
|
199
|
+
/**
|
|
200
|
+
* Client-only mode: LOAD_LOCK_NO_SPAWN set to any non-empty value except
|
|
201
|
+
* "0" forbids auto-spawning a daemon. Set this inside OS-level sandboxes
|
|
202
|
+
* (bubblewrap/omac, Docker, ...) so a sandbox-local daemon is never
|
|
203
|
+
* silently spawned and an unreachable host socket fails loudly instead.
|
|
204
|
+
*/
|
|
205
|
+
export function noSpawnRequested(env = process.env) {
|
|
206
|
+
const v = env.LOAD_LOCK_NO_SPAWN;
|
|
207
|
+
return v !== undefined && v !== "" && v !== "0";
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Env-supplied holder-label prefix (LOAD_LOCK_LABEL_PREFIX), applied to
|
|
211
|
+
* every acquire so sandboxed clients are identifiable in the dashboard
|
|
212
|
+
* (e.g. "omac:issue-42 build"). An explicit labelPrefix param wins.
|
|
213
|
+
*/
|
|
214
|
+
export function envLabelPrefix(env = process.env) {
|
|
215
|
+
const v = env.LOAD_LOCK_LABEL_PREFIX?.trim();
|
|
216
|
+
return v || undefined;
|
|
217
|
+
}
|
|
218
|
+
function noSpawnError(sockPath) {
|
|
219
|
+
return (`load-lock daemon is not reachable at ${sockPath} and auto-spawn is disabled (LOAD_LOCK_NO_SPAWN is set): ` +
|
|
220
|
+
`this client is client-only and started no daemon. Run the daemon on the host machine ` +
|
|
221
|
+
`(e.g. load-lock service start) and bridge its socket into this sandbox, ` +
|
|
222
|
+
`then point LOAD_LOCK_SOCKET at the bridged path — see the "Sandboxes" section of ` +
|
|
223
|
+
`docs/OPERATIONS.md for per-sandbox recipes (bubblewrap/omac, Docker, seatbelt, WSL2). ` +
|
|
224
|
+
`To allow spawning a daemon here instead, unset LOAD_LOCK_NO_SPAWN.`);
|
|
225
|
+
}
|
|
182
226
|
function daemonMainPath() {
|
|
183
227
|
if (process.env.LOAD_LOCK_DAEMON_BIN)
|
|
184
228
|
return process.env.LOAD_LOCK_DAEMON_BIN;
|
|
@@ -187,6 +231,10 @@ function daemonMainPath() {
|
|
|
187
231
|
}
|
|
188
232
|
/** Idempotently ensure a daemon is running and listening on `sockPath`. */
|
|
189
233
|
export async function ensureDaemon(stateDir, sockPath) {
|
|
234
|
+
if (noSpawnRequested()) {
|
|
235
|
+
// Client-only mode: never spawn, even for direct callers.
|
|
236
|
+
throw new ProtocolError("unavailable", noSpawnError(sockPath));
|
|
237
|
+
}
|
|
190
238
|
if (await probe(sockPath))
|
|
191
239
|
return;
|
|
192
240
|
const lockDir = join(stateDir, "spawn.lock");
|
|
@@ -226,6 +226,14 @@ export interface AcquireParams {
|
|
|
226
226
|
pool?: string;
|
|
227
227
|
agentId: string;
|
|
228
228
|
agentLabel?: string;
|
|
229
|
+
/**
|
|
230
|
+
* Optional prefix prepended to the holder label (composed daemon-side) so
|
|
231
|
+
* sandboxed/thin clients are identifiable in the dashboard, e.g.
|
|
232
|
+
* labelPrefix "omac" + agentLabel "issue-42 build" -> "omac:issue-42
|
|
233
|
+
* build". Falls back to agentId when no agentLabel is given. Adapters may
|
|
234
|
+
* source it from LOAD_LOCK_LABEL_PREFIX (the shared client injects it).
|
|
235
|
+
*/
|
|
236
|
+
labelPrefix?: string;
|
|
229
237
|
cwd?: string;
|
|
230
238
|
opKey: string;
|
|
231
239
|
reason: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI dispatch tests (issue #22): `dashboard` as the primary name of the
|
|
3
|
+
* live dashboard (with `top` as a hidden compat alias) and the `help`
|
|
4
|
+
* command (bare `load-lock`, `--help`, `-h`, `help`, `help <command>`),
|
|
5
|
+
* plus the unknown-command pointer to `load-lock help`.
|
|
6
|
+
*
|
|
7
|
+
* The help paths never connect to the daemon; the dashboard paths run the
|
|
8
|
+
* real CLI against a real daemon (piped stdout, so `--once` behavior is
|
|
9
|
+
* exercised both explicitly and via the non-TTY auto-once fallback).
|
|
10
|
+
*/
|
|
11
|
+
import test, { before, after } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { startDaemon, runCli } from "./testutil.js";
|
|
14
|
+
let d;
|
|
15
|
+
before(async () => {
|
|
16
|
+
d = await startDaemon();
|
|
17
|
+
});
|
|
18
|
+
after(async () => {
|
|
19
|
+
await d.stop();
|
|
20
|
+
});
|
|
21
|
+
// ------------------------------------------------------------------ help
|
|
22
|
+
test("load-lock help prints a grouped one-screen usage", async () => {
|
|
23
|
+
const r = await runCli(d, ["help"]);
|
|
24
|
+
assert.equal(r.code, 0);
|
|
25
|
+
for (const group of ["permit operations:", "waiting & running:", "read-only:", "service & diagnostics:", "setup & docs:"]) {
|
|
26
|
+
assert.ok(r.stdout.includes(group), `group "${group}" present`);
|
|
27
|
+
}
|
|
28
|
+
// Every command (primary names and aliases) gets a one-liner.
|
|
29
|
+
for (const name of [
|
|
30
|
+
"acquire", "claim", "release", "cancel", "rejoin", "mark-started", "attach-pid",
|
|
31
|
+
"wait", "run", "status", "pending", "events", "inspect", "config", "configure",
|
|
32
|
+
"dashboard", "top", "service", "doctor", "recover", "install", "uninstall",
|
|
33
|
+
"hook", "agent-guide", "help",
|
|
34
|
+
]) {
|
|
35
|
+
assert.ok(r.stdout.includes(name), `command "${name}" described`);
|
|
36
|
+
}
|
|
37
|
+
assert.ok(r.stdout.includes("exit codes"), "exit codes line present");
|
|
38
|
+
});
|
|
39
|
+
test("bare load-lock and --help/-h print the same usage as help", async () => {
|
|
40
|
+
const help = await runCli(d, ["help"]);
|
|
41
|
+
const bare = await runCli(d, []);
|
|
42
|
+
const longFlag = await runCli(d, ["--help"]);
|
|
43
|
+
const shortFlag = await runCli(d, ["-h"]);
|
|
44
|
+
for (const r of [help, bare, longFlag, shortFlag])
|
|
45
|
+
assert.equal(r.code, 0);
|
|
46
|
+
assert.equal(bare.stdout, help.stdout, "bare load-lock matches help");
|
|
47
|
+
assert.equal(longFlag.stdout, help.stdout, "--help matches help");
|
|
48
|
+
assert.equal(shortFlag.stdout, help.stdout, "-h matches help");
|
|
49
|
+
});
|
|
50
|
+
test("load-lock help <command> prints one line; unknown topic fails", async () => {
|
|
51
|
+
const r = await runCli(d, ["help", "dashboard"]);
|
|
52
|
+
assert.equal(r.code, 0);
|
|
53
|
+
assert.ok(r.stdout.includes("load-lock dashboard"), "primary name shown");
|
|
54
|
+
assert.ok(r.stdout.includes("--once"), "one-liner mentions --once");
|
|
55
|
+
const alias = await runCli(d, ["help", "top"]);
|
|
56
|
+
assert.equal(alias.code, 0);
|
|
57
|
+
assert.equal(alias.stdout, r.stdout, "alias resolves to the same command");
|
|
58
|
+
const bad = await runCli(d, ["help", "frobnicate"]);
|
|
59
|
+
assert.equal(bad.code, 64);
|
|
60
|
+
assert.ok(bad.stderr.includes("load-lock help"), "points back to help");
|
|
61
|
+
});
|
|
62
|
+
test("unknown command points at load-lock help", async () => {
|
|
63
|
+
const r = await runCli(d, ["frobnicate"]);
|
|
64
|
+
assert.equal(r.code, 64);
|
|
65
|
+
assert.ok(r.stderr.includes('unknown command "frobnicate"'), "names the bad command");
|
|
66
|
+
assert.ok(r.stderr.includes("load-lock help"), "points to help");
|
|
67
|
+
});
|
|
68
|
+
test("flags without a command still default to status (not help)", async () => {
|
|
69
|
+
// Regression guard: only a *bare* invocation prints usage; `--json` alone
|
|
70
|
+
// keeps the historical status default.
|
|
71
|
+
const r = await runCli(d, ["--json"]);
|
|
72
|
+
assert.equal(r.code, 0);
|
|
73
|
+
const parsed = JSON.parse(r.stdout);
|
|
74
|
+
assert.ok(Array.isArray(parsed.pools), "status JSON");
|
|
75
|
+
});
|
|
76
|
+
// -------------------------------------------------------------- dashboard
|
|
77
|
+
test("load-lock dashboard --once renders one frame and exits clean", async () => {
|
|
78
|
+
const r = await runCli(d, ["dashboard", "--once"]);
|
|
79
|
+
assert.equal(r.code, 0);
|
|
80
|
+
assert.ok(r.stdout.includes("load-lock dashboard"), "banner present");
|
|
81
|
+
assert.ok(r.stdout.includes("POOL heavy"), "pool line present");
|
|
82
|
+
assert.ok(r.stdout.includes("never acquires a permit"), "read-only note present");
|
|
83
|
+
});
|
|
84
|
+
test("load-lock dashboard auto-once when stdout is not a TTY", async () => {
|
|
85
|
+
// Piped stdout must not enter the alt-screen live loop.
|
|
86
|
+
const r = await runCli(d, ["dashboard"]);
|
|
87
|
+
assert.equal(r.code, 0);
|
|
88
|
+
assert.ok(r.stdout.includes("load-lock dashboard"), "frame rendered");
|
|
89
|
+
assert.ok(!r.stdout.includes("\x1b[?1049h"), "no alt-screen escape in a pipe");
|
|
90
|
+
});
|
|
91
|
+
test("top stays a working compat alias for dashboard", async () => {
|
|
92
|
+
const alias = await runCli(d, ["top", "--once"]);
|
|
93
|
+
const primary = await runCli(d, ["dashboard", "--once"]);
|
|
94
|
+
assert.equal(alias.code, 0);
|
|
95
|
+
assert.equal(primary.code, 0);
|
|
96
|
+
// "synced N ago" and daemon uptime tick between runs; normalize before comparing.
|
|
97
|
+
// The MACHINE block is live host telemetry — load averages, available memory
|
|
98
|
+
// and the used-percentage in the pressure note move (and round across
|
|
99
|
+
// boundaries) between the two back-to-back runs — so collapse it wholesale;
|
|
100
|
+
// the alias check is that everything *else* renders identically.
|
|
101
|
+
const norm = (s) => s
|
|
102
|
+
.replace(/synced \S+ ago/g, "synced N ago")
|
|
103
|
+
.replace(/uptime \S+/g, "uptime N")
|
|
104
|
+
.replace(/^MACHINE.*\n\s*pressure:.*\n/m, "MACHINE <live telemetry>\n");
|
|
105
|
+
assert.equal(norm(alias.stdout), norm(primary.stdout), "identical rendering");
|
|
106
|
+
});
|
package/dist/test/core.test.js
CHANGED
|
@@ -279,3 +279,30 @@ test("diagnose reports holder info and flags long holds without revoking", () =>
|
|
|
279
279
|
assert.equal(stateOf(m, a.requestId), "held", "long hold alone never revokes a running job");
|
|
280
280
|
assert.ok(diags[0].diagnostics.note.includes("pid 333"));
|
|
281
281
|
});
|
|
282
|
+
// ------------------------------------------------------- labelPrefix (issue #29)
|
|
283
|
+
test("labelPrefix composes the holder label; agentId fallback when unlabeled", () => {
|
|
284
|
+
const { m } = mk();
|
|
285
|
+
const a = grant(m, A, "op1", { agentLabel: "issue-42 build", labelPrefix: "omac" });
|
|
286
|
+
assert.equal(a.agentLabel, "omac:issue-42 build");
|
|
287
|
+
// queued entry composes too, falling back to agentId without a label
|
|
288
|
+
const b = m.acquire({ agentId: B, opKey: "op1", reason: "b", labelPrefix: "omac" });
|
|
289
|
+
assert.equal(b.state, "queued");
|
|
290
|
+
assert.equal(b.entry.agentLabel, "omac:" + B);
|
|
291
|
+
// status (dashboard data source) shows the composed labels
|
|
292
|
+
const st = m.status();
|
|
293
|
+
assert.equal(st.pools[0].holders[0].agentLabel, "omac:issue-42 build");
|
|
294
|
+
assert.equal(st.pools[0].queue[0].agentLabel, "omac:" + B);
|
|
295
|
+
// events and journal carry the composed label (restart reconciliation)
|
|
296
|
+
assert.equal(m.eventsSince(0)[1].entry.agentLabel, "omac:issue-42 build");
|
|
297
|
+
assert.equal(m.journalEntries()[0].agentLabel, "omac:issue-42 build");
|
|
298
|
+
// no prefix: unchanged behavior
|
|
299
|
+
const c = m.acquire({ agentId: C, opKey: "op1", reason: "c", agentLabel: "plain" });
|
|
300
|
+
assert.equal(c.entry.agentLabel, "plain");
|
|
301
|
+
});
|
|
302
|
+
test("labelPrefix is trimmed and must be non-empty when present", () => {
|
|
303
|
+
const { m } = mk();
|
|
304
|
+
const a = grant(m, A, "op1", { agentLabel: "build", labelPrefix: " omac " });
|
|
305
|
+
assert.equal(a.agentLabel, "omac:build");
|
|
306
|
+
assert.throws(() => m.acquire({ agentId: A, opKey: "op2", reason: "r", labelPrefix: " " }), (e) => e instanceof ProtocolError && e.code === "invalid");
|
|
307
|
+
assert.throws(() => m.acquire({ agentId: A, opKey: "op3", reason: "r", labelPrefix: "" }), (e) => e instanceof ProtocolError && e.code === "invalid");
|
|
308
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dashboard tests (issue #10): the data plumbing behind `load-lock
|
|
3
|
-
* and the OpenCode TUI plugin panel.
|
|
2
|
+
* Dashboard tests (issue #10): the data plumbing behind `load-lock
|
|
3
|
+
* dashboard` (alias `top`) and the OpenCode TUI plugin panel.
|
|
4
4
|
*
|
|
5
5
|
* - view-model shaping (buildDashboardView) against protocol fixtures,
|
|
6
6
|
* with "now" pinned so countdowns are deterministic
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* - DashboardFeed against a REAL daemon: live view, queue visibility,
|
|
11
11
|
* release, resync idempotence, and reconnect + event-cursor resume
|
|
12
12
|
* across a daemon SIGKILL -> fresh-daemon-on-the-same-socket restart
|
|
13
|
-
* - `load-lock top --once` smoke test (read-only, never acquires
|
|
13
|
+
* - `load-lock top --once` smoke test (read-only, never acquires; `top`
|
|
14
|
+
* is the compat alias of the same `dashboard` command)
|
|
14
15
|
*/
|
|
15
16
|
import test from "node:test";
|
|
16
17
|
import assert from "node:assert/strict";
|
|
@@ -394,7 +395,7 @@ test("load-lock top --once renders one frame and exits clean", async () => {
|
|
|
394
395
|
try {
|
|
395
396
|
const r = await runCli(d, ["top", "--once"]);
|
|
396
397
|
assert.equal(r.code, 0);
|
|
397
|
-
assert.ok(r.stdout.includes("load-lock
|
|
398
|
+
assert.ok(r.stdout.includes("load-lock dashboard"), "banner present");
|
|
398
399
|
assert.ok(r.stdout.includes("POOL heavy"), "pool line present");
|
|
399
400
|
assert.ok(r.stdout.includes("q quit"), "footer present");
|
|
400
401
|
assert.ok(r.stdout.includes("never acquires a permit"), "read-only note present");
|
package/dist/test/mcp.test.js
CHANGED
|
@@ -32,10 +32,11 @@ test("mcp: tool listing + acquire/release/configure round trip", async () => {
|
|
|
32
32
|
for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "wait_for_turn", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started"]) {
|
|
33
33
|
assert.ok(names.includes(expected), `tool ${expected} registered`);
|
|
34
34
|
}
|
|
35
|
-
const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
|
|
35
|
+
const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA", label_prefix: "omac" });
|
|
36
36
|
assert.equal(acq.state, "granted");
|
|
37
37
|
assert.ok(acq.permit_token, "acquire result carries permit_token");
|
|
38
38
|
assert.ok(acq.next_action, "structured next_action present");
|
|
39
|
+
assert.equal(acq.entry.agentLabel, "omac:mcpAgentA", "label_prefix composes the holder label");
|
|
39
40
|
// idempotent acquire with the same op_key
|
|
40
41
|
const acq2 = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
|
|
41
42
|
assert.equal(acq2.request_id, acq.request_id, "same op_key -> same request");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #29: sandboxed thin clients.
|
|
3
|
+
*
|
|
4
|
+
* - Client-only mode (LOAD_LOCK_NO_SPAWN): with the env set and the socket
|
|
5
|
+
* unreachable, the client must NEVER auto-spawn a daemon and must fail
|
|
6
|
+
* loudly, pointing at the Sandboxes section of docs/OPERATIONS.md.
|
|
7
|
+
* - Holder-label prefixing (labelPrefix / LOAD_LOCK_LABEL_PREFIX) so
|
|
8
|
+
* sandboxed clients are identifiable in the dashboard.
|
|
9
|
+
*/
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { LLClient, ensureDaemon } from "../shared/client.js";
|
|
16
|
+
import { ProtocolError } from "../shared/protocol.js";
|
|
17
|
+
import { startDaemon, delay, probe, cliMainPath } from "./testutil.js";
|
|
18
|
+
const ACQ = (agentId, opKey, extra = {}) => ({
|
|
19
|
+
agentId,
|
|
20
|
+
opKey,
|
|
21
|
+
reason: "sandbox test",
|
|
22
|
+
...extra,
|
|
23
|
+
});
|
|
24
|
+
/** Fresh empty state dir + socket path; the dir is NOT created. */
|
|
25
|
+
function freshDir(prefix) {
|
|
26
|
+
const dir = mkdtempSync(join(process.env.LL_TEST_TMP ?? "/tmp", prefix));
|
|
27
|
+
rmSync(dir, { recursive: true, force: true }); // we want it absent
|
|
28
|
+
const sock = join(dir, "daemon.sock");
|
|
29
|
+
return { dir, sock, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
30
|
+
}
|
|
31
|
+
/** Stop a daemon spawned via auto-spawn (pid recorded in daemon.json). */
|
|
32
|
+
async function killSpawnedDaemon(dir) {
|
|
33
|
+
const info = JSON.parse(readFileSync(join(dir, "daemon.json"), "utf8"));
|
|
34
|
+
try {
|
|
35
|
+
process.kill(info.pid, "SIGTERM");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
/* already gone */
|
|
39
|
+
}
|
|
40
|
+
await delay(300);
|
|
41
|
+
}
|
|
42
|
+
test("LOAD_LOCK_NO_SPAWN: no daemon spawned, loud error pointing at the docs", async () => {
|
|
43
|
+
const { dir, sock, cleanup } = freshDir("ll-nospawn-");
|
|
44
|
+
process.env.LOAD_LOCK_NO_SPAWN = "1";
|
|
45
|
+
try {
|
|
46
|
+
const c = new LLClient({ socket: sock, stateDir: dir });
|
|
47
|
+
await assert.rejects(() => c.connect(), (e) => {
|
|
48
|
+
assert.ok(e instanceof ProtocolError);
|
|
49
|
+
assert.equal(e.code, "unavailable");
|
|
50
|
+
assert.ok(e.message.includes("LOAD_LOCK_NO_SPAWN"), "error names the env var");
|
|
51
|
+
assert.ok(e.message.includes("docs/OPERATIONS.md"), "error points at the docs");
|
|
52
|
+
assert.ok(e.message.includes("Sandboxes"), "error points at the Sandboxes section");
|
|
53
|
+
assert.ok(e.message.includes(sock), "error names the unreachable socket");
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
// No daemon process was spawned: the state dir was never even created
|
|
57
|
+
// (ensureDaemon creates it only when spawning) and nothing is listening.
|
|
58
|
+
assert.equal(existsSync(dir), false, "no state dir created");
|
|
59
|
+
assert.equal(await probe(sock), false, "nothing listening on the socket");
|
|
60
|
+
await delay(1_000); // would-be spawner waits up to 10s; give it a beat
|
|
61
|
+
assert.equal(existsSync(dir), false, "still no state dir after a wait");
|
|
62
|
+
assert.equal(await probe(sock), false, "still no daemon after a wait");
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
delete process.env.LOAD_LOCK_NO_SPAWN;
|
|
66
|
+
cleanup();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
test("LOAD_LOCK_NO_SPAWN: ensureDaemon refuses directly too", async () => {
|
|
70
|
+
const { dir, sock, cleanup } = freshDir("ll-nospawn2-");
|
|
71
|
+
process.env.LOAD_LOCK_NO_SPAWN = "true";
|
|
72
|
+
try {
|
|
73
|
+
await assert.rejects(() => ensureDaemon(dir, sock), (e) => e instanceof ProtocolError && e.message.includes("docs/OPERATIONS.md"));
|
|
74
|
+
assert.equal(existsSync(dir), false, "ensureDaemon created nothing");
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
delete process.env.LOAD_LOCK_NO_SPAWN;
|
|
78
|
+
cleanup();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
test("LOAD_LOCK_NO_SPAWN=0 disables client-only mode (daemon auto-spawns)", async () => {
|
|
82
|
+
const { dir, sock, cleanup } = freshDir("ll-nospawn0-");
|
|
83
|
+
process.env.LOAD_LOCK_NO_SPAWN = "0";
|
|
84
|
+
try {
|
|
85
|
+
const c = new LLClient({ socket: sock, stateDir: dir });
|
|
86
|
+
await c.connect();
|
|
87
|
+
const hello = (await c.request("hello", {}));
|
|
88
|
+
assert.ok(hello.pid, "daemon spawned and reachable");
|
|
89
|
+
c.close();
|
|
90
|
+
await killSpawnedDaemon(dir);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
delete process.env.LOAD_LOCK_NO_SPAWN;
|
|
94
|
+
cleanup();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
test("CLI under LOAD_LOCK_NO_SPAWN fails loudly with a docs pointer", async () => {
|
|
98
|
+
const { dir, sock, cleanup } = freshDir("ll-nospawncli-");
|
|
99
|
+
const res = await new Promise((resolve) => {
|
|
100
|
+
const child = spawn(process.execPath, [cliMainPath, "status"], {
|
|
101
|
+
env: {
|
|
102
|
+
...process.env,
|
|
103
|
+
LOAD_LOCK_NO_SPAWN: "1",
|
|
104
|
+
LOAD_LOCK_STATE_DIR: dir,
|
|
105
|
+
LOAD_LOCK_SOCKET: sock,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
let stderr = "";
|
|
109
|
+
child.stderr.on("data", (b) => (stderr += b));
|
|
110
|
+
child.once("exit", (code) => resolve({ code, stderr }));
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
assert.equal(res.code, 70, "unavailable -> exit 70");
|
|
114
|
+
assert.ok(res.stderr.includes("docs/OPERATIONS.md"), "stderr points at the docs");
|
|
115
|
+
assert.ok(res.stderr.includes("Sandboxes"), "stderr points at the Sandboxes section");
|
|
116
|
+
assert.equal(existsSync(dir), false, "CLI spawned no daemon");
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
cleanup();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
test("daemon-side labelPrefix end-to-end: holder shows prefixed label", async () => {
|
|
123
|
+
const d = await startDaemon();
|
|
124
|
+
try {
|
|
125
|
+
const c = d.client();
|
|
126
|
+
const acq = (await c.request("acquire", ACQ("agentSbx", "sbx-1", { agentLabel: "issue-42 build", labelPrefix: "omac" })));
|
|
127
|
+
assert.equal(acq.state, "granted");
|
|
128
|
+
assert.equal(acq.entry.agentLabel, "omac:issue-42 build");
|
|
129
|
+
const st = (await c.request("status", {}));
|
|
130
|
+
assert.equal(st.pools[0].holders[0].agentLabel, "omac:issue-42 build");
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await d.stop();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
test("LOAD_LOCK_LABEL_PREFIX env is injected by the shared client; explicit param wins", async () => {
|
|
137
|
+
const d = await startDaemon();
|
|
138
|
+
process.env.LOAD_LOCK_LABEL_PREFIX = "bwrap";
|
|
139
|
+
try {
|
|
140
|
+
const c = d.client();
|
|
141
|
+
// injected from env when the acquire does not pass labelPrefix itself
|
|
142
|
+
const a = (await c.request("acquire", ACQ("agentEnv", "env-1", { agentLabel: "cargo test" })));
|
|
143
|
+
assert.equal(a.entry.agentLabel, "bwrap:cargo test");
|
|
144
|
+
// an explicit labelPrefix overrides the env-injected one
|
|
145
|
+
const b = (await c.request("acquire", ACQ("agentEnv", "env-2", { agentLabel: "x", labelPrefix: "omac" })));
|
|
146
|
+
assert.equal(b.entry.agentLabel, "omac:x");
|
|
147
|
+
// unprefixable clients (no label) still show the prefix via agentId fallback
|
|
148
|
+
const e2 = (await c.request("acquire", ACQ("agentEnv2", "env-3", { pool: "heavy", weight: 1 })));
|
|
149
|
+
assert.ok(String(e2.entry.agentLabel).startsWith("bwrap:"), `fallback label was ${e2.entry.agentLabel}`);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
delete process.env.LOAD_LOCK_LABEL_PREFIX;
|
|
153
|
+
await d.stop();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI plugin tests (issue #24): the at-a-glance summary behind the
|
|
3
|
+
* home.footer.status indicator and the new clickable sidebar.footer widget.
|
|
4
|
+
*
|
|
5
|
+
* What is testable here without a TUI host:
|
|
6
|
+
* - the compiled plugin module loads headless (its runtime imports are
|
|
7
|
+
* only @opencode/plugin/tui, solid-js, and the shared client/feed —
|
|
8
|
+
* @opentui/* are type-only) and self-describes as "load-lock.tui"
|
|
9
|
+
* - the dashboard opens via the in-window PanelPresentation ("panel",
|
|
10
|
+
* never fullscreen), pinned by the exported PANEL_PRESENTATION constant
|
|
11
|
+
* - poolGlance(): the pure per-pool summary (bits + health) both
|
|
12
|
+
* indicator surfaces share
|
|
13
|
+
*
|
|
14
|
+
* What is NOT testable here: actual slot rendering, mouse delivery in a
|
|
15
|
+
* live terminal, and panel.open behavior — those are live-verified in a
|
|
16
|
+
* real OpenCode session and documented in the PR (see docs/ updates).
|
|
17
|
+
* plugin.setup() is deliberately NOT invoked: it starts a DashboardFeed
|
|
18
|
+
* against a real daemon and would spawn/connect outside test isolation.
|
|
19
|
+
*/
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import tui, { PANEL_PRESENTATION, poolGlance } from "../adapters/opencode/tui.js";
|
|
23
|
+
// ------------------------------------------------------------- fixtures
|
|
24
|
+
function poolView(over = {}) {
|
|
25
|
+
return {
|
|
26
|
+
pool: "heavy",
|
|
27
|
+
capacity: 3,
|
|
28
|
+
used: 1,
|
|
29
|
+
heldWeight: 1,
|
|
30
|
+
offeredWeight: 0,
|
|
31
|
+
free: 2,
|
|
32
|
+
utilization: 1 / 3,
|
|
33
|
+
holders: [],
|
|
34
|
+
offers: [],
|
|
35
|
+
queue: [],
|
|
36
|
+
paused: [],
|
|
37
|
+
...over,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function view(pools, state = "live") {
|
|
41
|
+
return {
|
|
42
|
+
now: 1_760_000_000_000,
|
|
43
|
+
state,
|
|
44
|
+
daemon: null,
|
|
45
|
+
pools,
|
|
46
|
+
events: [],
|
|
47
|
+
latestEventId: 0,
|
|
48
|
+
machine: null,
|
|
49
|
+
lastSyncAt: 1_760_000_000_000,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// ------------------------------------------------------ module contract
|
|
53
|
+
test("tui plugin module loads headless and self-describes", () => {
|
|
54
|
+
assert.ok(tui, "default export exists");
|
|
55
|
+
// @opencode/plugin's define() is a passthrough, so the raw shape is
|
|
56
|
+
// visible without a TUI host.
|
|
57
|
+
const anyTui = tui;
|
|
58
|
+
assert.equal(anyTui.id, "load-lock.tui");
|
|
59
|
+
});
|
|
60
|
+
test("dashboard opens in-window: PANEL_PRESENTATION is \"panel\"", () => {
|
|
61
|
+
// PanelPresentation is exactly "panel" | "fullscreen"; "panel" is the
|
|
62
|
+
// in-window presentation (issue #24 requirement 1).
|
|
63
|
+
assert.equal(PANEL_PRESENTATION, "panel");
|
|
64
|
+
});
|
|
65
|
+
// ------------------------------------------------------------ poolGlance
|
|
66
|
+
test("poolGlance reports bits and health per pool state", () => {
|
|
67
|
+
const healthy = poolGlance(view([poolView({ used: 1, capacity: 3 })]));
|
|
68
|
+
assert.deepEqual(healthy, { bits: ["1/3"], health: "good" });
|
|
69
|
+
const full = poolGlance(view([poolView({ used: 3, capacity: 3, free: 0, utilization: 1 })]));
|
|
70
|
+
assert.deepEqual(full, { bits: ["3/3"], health: "warn" });
|
|
71
|
+
const queued = poolGlance(view([poolView({ used: 1, capacity: 3, queue: [{}, {}] })]));
|
|
72
|
+
assert.deepEqual(queued, { bits: ["1/3", "Q2"], health: "warn" });
|
|
73
|
+
const paused = poolGlance(view([poolView({ used: 1, capacity: 3, paused: [{}] })]));
|
|
74
|
+
// paused shows in bits but does not degrade health on its own
|
|
75
|
+
assert.deepEqual(paused, { bits: ["1/3", "P1"], health: "good" });
|
|
76
|
+
const down = poolGlance(view([poolView({ used: 1, capacity: 3 })], "reconnecting"));
|
|
77
|
+
assert.deepEqual(down, { bits: ["1/3"], health: "down" });
|
|
78
|
+
});
|
|
79
|
+
test("poolGlance prefers the heavy pool and tolerates missing pools", () => {
|
|
80
|
+
const heavy = poolGlance(view([
|
|
81
|
+
poolView({ pool: "gpu-heavy", used: 1, capacity: 1 }),
|
|
82
|
+
poolView({ pool: "heavy", used: 0, capacity: 3, free: 3, utilization: 0 }),
|
|
83
|
+
]));
|
|
84
|
+
assert.deepEqual(heavy, { bits: ["0/3"], health: "good" });
|
|
85
|
+
// custom configs: falls back to the first pool when no "heavy" exists
|
|
86
|
+
const fallback = poolGlance(view([poolView({ pool: "build", used: 2, capacity: 2, free: 0, utilization: 1 })]));
|
|
87
|
+
assert.deepEqual(fallback, { bits: ["2/2"], health: "warn" });
|
|
88
|
+
assert.equal(poolGlance(view([])), null);
|
|
89
|
+
});
|
package/docs/ADAPTERS.md
CHANGED
|
@@ -84,7 +84,7 @@ normally touches:
|
|
|
84
84
|
|
|
85
85
|
| Method | Purpose |
|
|
86
86
|
| --- | --- |
|
|
87
|
-
| `acquire {pool?, agentId, agentLabel?, cwd?, opKey, reason, weight?, waitMs?, subscribeIfBusy?}` | grant / queue (idempotent per agent+opKey) |
|
|
87
|
+
| `acquire {pool?, agentId, agentLabel?, labelPrefix?, cwd?, opKey, reason, weight?, waitMs?, subscribeIfBusy?}` | grant / queue (idempotent per agent+opKey); labelPrefix composes `prefix:label` holder labels for sandboxed clients (env `LOAD_LOCK_LABEL_PREFIX` is injected by the shared client) |
|
|
88
88
|
| `claim {offerToken}` | offer → permit (before starting heavy work) |
|
|
89
89
|
| `release {permitToken, outcome?}` | idempotent |
|
|
90
90
|
| `cancel {requestId}` / `rejoin {requestId}` / `decline {offerToken}` | queue hygiene |
|
package/docs/AGENT_GUIDE.md
CHANGED
|
@@ -137,6 +137,7 @@ load-lock run --permit <permit-token> -- cargo test --quiet # tracks + rel
|
|
|
137
137
|
# or acquire+run in one go:
|
|
138
138
|
load-lock run --reason "nightly build" -- npm run build
|
|
139
139
|
load-lock wait --id <request-id> --timeout 120000 # sleep until your turn → auto-claim
|
|
140
|
+
load-lock help # one-screen usage of every command
|
|
140
141
|
```
|
|
141
142
|
|
|
142
143
|
## 4. Reading state
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -208,7 +208,7 @@ is configuration, not code.
|
|
|
208
208
|
|
|
209
209
|
The read-only status surface also extends without protocol changes: the
|
|
210
210
|
`status` + `events` (durable ids) + `watch` + `inspect` methods are enough
|
|
211
|
-
to build live viewers. `load-lock top` and the OpenCode TUI panel
|
|
211
|
+
to build live viewers. `load-lock dashboard` (alias `top`) and the OpenCode TUI panel
|
|
212
212
|
(`src/dashboard/`) are exactly that — pure view-model shaping over the
|
|
213
213
|
library client (`./shared/client`), never CLI-output scraping, and never
|
|
214
214
|
acquiring a permit themselves. Any future harness UI can reuse the same
|
package/docs/COMPATIBILITY.md
CHANGED
|
@@ -59,11 +59,13 @@ Global plugin (`load-lock install --harness opencode`) registers:
|
|
|
59
59
|
- per-session heartbeats,
|
|
60
60
|
- the package's `./tui` export: a **live lock-status dashboard panel**
|
|
61
61
|
(issue #10) inside the opencode TUI — `/load-lock` opens the
|
|
62
|
-
`load-lock.dashboard` session panel (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
`load-lock.dashboard` session panel in-window (issue #24: side panel,
|
|
63
|
+
not full-screen) with pools, holder cards, offer countdowns, queue,
|
|
64
|
+
paused, event feed, `inspect_machine`; a clickable sidebar widget
|
|
65
|
+
(`load-lock 0/3`, health-colored; `/load-lock` as the mouse-free
|
|
66
|
+
fallback) and a home-footer `lock 1/1·Q2` indicator; and an `x` force-release flow
|
|
67
|
+
gated behind an explicit select + confirm (the panel itself is read-only
|
|
68
|
+
and never acquires a permit).
|
|
67
69
|
|
|
68
70
|
Verified live on opencode v2.0.29: plugin load, policy visible to the model,
|
|
69
71
|
tool round-trip (acquire → queued → wakeup → claim → release), execution-
|
|
@@ -242,19 +244,19 @@ acquire→run→release in one command line.
|
|
|
242
244
|
|
|
243
245
|
## Live dashboard surfaces (issue #10)
|
|
244
246
|
|
|
245
|
-
`load-lock
|
|
247
|
+
`load-lock dashboard` is portable and harness-agnostic: any integration that
|
|
246
248
|
spawns the daemon gets the same live dashboard in any terminal. The table
|
|
247
249
|
below is deliberately honest about what each harness gets **inside its own
|
|
248
250
|
UI** — where no UI surface exists, we say so rather than claim parity.
|
|
249
251
|
|
|
250
252
|
| Harness | In-harness dashboard | Also available |
|
|
251
253
|
| --- | --- | --- |
|
|
252
|
-
| **OpenCode** | Native TUI panel: `/load-lock` opens `load-lock.dashboard` (
|
|
253
|
-
| **Claude Code** | None in-app — Claude Code has no plugin UI surface. The model can report status via the `load_lock_status` MCP tool at tool boundaries, and each `SessionStart` hook injects a one-line machine summary. | `load-lock
|
|
254
|
-
| **Codex CLI** | None in-app — same reason. `load_lock_status` MCP tool; `SessionStart`/`SubagentStart` machine summary. | `load-lock top` |
|
|
255
|
-
| **Gemini CLI** | None in-app (no hooks, no UI plugin surface). `load_lock_status` MCP tool at tool boundaries. | `load-lock top` |
|
|
256
|
-
| **Copilot CLI, Cursor, Cline, Roo Code, Goose** | None in-app. MCP `status` tool where the MCP server is configured. | `load-lock top` |
|
|
257
|
-
| **Aider** | None (no MCP either). | `load-lock top` |
|
|
254
|
+
| **OpenCode** | Native TUI panel: `/load-lock` opens `load-lock.dashboard` **in-window** as a side panel (also opened by clicking the sidebar widget; deliberately **not** bound to any key — issue #28: the old `gl` sequence swallowed plain `g` typing, and the candidate `ctrl+g` is the host's `session.first` default) with pools, holder cards, offer claim-window countdowns, queue, paused, event feed, `inspect_machine`; home-footer `lock 1/1` indicator on the home screen plus a clickable, health-colored `load-lock 0/3` widget in the session sidebar; `x` force-release behind select + confirm. Implemented as the package's `./tui` companion plugin (main plugin unchanged). Live-verified: opencode 2.0.29 (panel render, palette/slash command, confirm-gated force-release) and opencode 2.0.32 in a real pty (sidebar widget render, click-to-open, in-window presentation, plain `g` reaches the composer, `ctrl+g` not intercepted by load-lock). | `load-lock dashboard` (alias `top`) |
|
|
255
|
+
| **Claude Code** | None in-app — Claude Code has no plugin UI surface. The model can report status via the `load_lock_status` MCP tool at tool boundaries, and each `SessionStart` hook injects a one-line machine summary. | `load-lock dashboard` (any terminal) |
|
|
256
|
+
| **Codex CLI** | None in-app — same reason. `load_lock_status` MCP tool; `SessionStart`/`SubagentStart` machine summary. | `load-lock dashboard` (alias `top`) |
|
|
257
|
+
| **Gemini CLI** | None in-app (no hooks, no UI plugin surface). `load_lock_status` MCP tool at tool boundaries. | `load-lock dashboard` (alias `top`) |
|
|
258
|
+
| **Copilot CLI, Cursor, Cline, Roo Code, Goose** | None in-app. MCP `status` tool where the MCP server is configured. | `load-lock dashboard` (alias `top`) |
|
|
259
|
+
| **Aider** | None (no MCP either). | `load-lock dashboard` (alias `top`) |
|
|
258
260
|
|
|
259
261
|
All dashboard surfaces are **read-only viewers that never acquire a
|
|
260
262
|
permit**; the single admin action (force-release) is gated behind an
|
|
@@ -297,7 +299,7 @@ reconnect, view-model shaping, frame rendering) is covered by
|
|
|
297
299
|
- Core daemon/protocol/CLI: 68 automated tests + scripted 3-worktree demo
|
|
298
300
|
(three independent agent processes, real git worktrees, one pool), including
|
|
299
301
|
9 dashboard tests (view-model shaping, event-cursor resume across a daemon
|
|
300
|
-
restart, reconnect, and a `load-lock
|
|
302
|
+
restart, reconnect, and a `load-lock dashboard --once` render smoke test) and
|
|
301
303
|
9 bounded-wait tests (multi-process `wait_for_turn`: FIFO wake with
|
|
302
304
|
auto-claim, timeout/pause/cancel/restart/abort paths, `maxTurnWaitMs` cap,
|
|
303
305
|
MCP snake_case round trip).
|