@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.
Files changed (53) hide show
  1. package/README.md +40 -12
  2. package/demo/demo.sh +1 -1
  3. package/dist/adapters/opencode/plugin.js +39 -18
  4. package/dist/adapters/opencode/tui.d.ts +61 -0
  5. package/dist/adapters/opencode/tui.js +358 -0
  6. package/dist/cli/hook.js +35 -1
  7. package/dist/cli/install.js +708 -24
  8. package/dist/cli/main.js +130 -61
  9. package/dist/cli/top.d.ts +7 -0
  10. package/dist/cli/top.js +99 -0
  11. package/dist/core/daemon.d.ts +25 -4
  12. package/dist/core/daemon.js +67 -30
  13. package/dist/core/persist.js +5 -12
  14. package/dist/core/state.js +7 -0
  15. package/dist/dashboard/feed.d.ts +74 -0
  16. package/dist/dashboard/feed.js +256 -0
  17. package/dist/dashboard/frame.d.ts +13 -0
  18. package/dist/dashboard/frame.js +95 -0
  19. package/dist/dashboard/view.d.ts +149 -0
  20. package/dist/dashboard/view.js +214 -0
  21. package/dist/mcp/server.d.ts +2 -1
  22. package/dist/mcp/server.js +54 -19
  23. package/dist/policy/text.d.ts +1 -1
  24. package/dist/policy/text.js +3 -0
  25. package/dist/shared/client.d.ts +10 -0
  26. package/dist/shared/client.js +41 -14
  27. package/dist/shared/protocol.d.ts +29 -0
  28. package/dist/shared/transport.d.ts +40 -0
  29. package/dist/shared/transport.js +136 -0
  30. package/dist/shared/wait-turn.d.ts +29 -0
  31. package/dist/shared/wait-turn.js +315 -0
  32. package/dist/test/cli.test.d.ts +1 -0
  33. package/dist/test/cli.test.js +99 -0
  34. package/dist/test/dashboard.test.d.ts +1 -0
  35. package/dist/test/dashboard.test.js +406 -0
  36. package/dist/test/hook.test.js +53 -3
  37. package/dist/test/install.test.d.ts +1 -0
  38. package/dist/test/install.test.js +658 -0
  39. package/dist/test/mcp.test.js +36 -1
  40. package/dist/test/platform.test.d.ts +1 -0
  41. package/dist/test/platform.test.js +193 -0
  42. package/dist/test/testutil.js +4 -1
  43. package/dist/test/tui.test.d.ts +1 -0
  44. package/dist/test/tui.test.js +89 -0
  45. package/dist/test/wait-turn.test.d.ts +1 -0
  46. package/dist/test/wait-turn.test.js +376 -0
  47. package/docs/ADAPTERS.md +5 -2
  48. package/docs/AGENT_GUIDE.md +34 -1
  49. package/docs/ARCHITECTURE.md +25 -5
  50. package/docs/COMPATIBILITY.md +217 -26
  51. package/docs/OPERATIONS.md +335 -8
  52. package/package.json +25 -1
  53. package/skills/load-lock/SKILL.md +4 -0
@@ -29,7 +29,7 @@ test("mcp: tool listing + acquire/release/configure round trip", async () => {
29
29
  try {
30
30
  const tools = await c.listTools();
31
31
  const names = tools.tools.map((t) => t.name);
32
- for (const expected of ["acquire", "claim", "release", "cancel", "rejoin", "pending", "status", "inspect_machine", "get_config", "configure", "run_with_permit", "job_status", "job_stop", "attach_pid", "mark_started"]) {
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
35
  const acq = await callJson(c, "acquire", { reason: "build", op_key: "mcp-rt", agent_id: "mcpAgentA" });
@@ -77,6 +77,41 @@ test("mcp: tool listing + acquire/release/configure round trip", async () => {
77
77
  await d.stop();
78
78
  }
79
79
  });
80
+ test("mcp: wait_for_turn sleeps bounded and returns structured snake_case results", async () => {
81
+ const d = await startDaemon();
82
+ const c = await connectMcp(d);
83
+ try {
84
+ const a = await callJson(c, "acquire", { reason: "build", op_key: "wft-a", agent_id: "mcpWaitA" });
85
+ assert.equal(a.state, "granted");
86
+ const b = await callJson(c, "acquire", { reason: "tests", op_key: "wft-b", agent_id: "mcpWaitB" });
87
+ assert.equal(b.state, "queued");
88
+ // Timeout is a structured still_queued result — never a tool error.
89
+ const t0 = Date.now();
90
+ const out1 = await callJson(c, "wait_for_turn", { agent_id: "mcpWaitB", timeout_ms: 700 });
91
+ assert.equal(out1.state, "still_queued");
92
+ assert.equal(out1.request_id, b.request_id);
93
+ assert.equal(out1.queue_position, 1);
94
+ assert.equal(out1.queue_length, 1);
95
+ assert.ok(Array.isArray(out1.holders) && out1.holders.length >= 1, "holder info included");
96
+ assert.equal(out1.holders[0].agentId, "mcpWaitA");
97
+ assert.ok(out1.waited_ms >= 600 && Date.now() - t0 < 5_000, "wait respected the bound");
98
+ // The cap is discoverable via configure + get_config (snake_case surface).
99
+ const cfg = await callJson(c, "get_config", {});
100
+ assert.equal(cfg.config.maxTurnWaitMs, 600_000, "default turn-wait cap present in config");
101
+ // Wake path: release, sleep again, wake holding an auto-claimed permit.
102
+ await callJson(c, "release", { permit_token: a.permit_token });
103
+ const out2 = await callJson(c, "wait_for_turn", { agent_id: "mcpWaitB", timeout_ms: 10_000 });
104
+ assert.equal(out2.state, "granted");
105
+ assert.equal(out2.auto_claimed, true, "auto-claim is explicit in the response");
106
+ assert.ok(out2.permit_token, "granted result carries a top-level permit_token");
107
+ assert.equal(out2.request_id, b.request_id);
108
+ await callJson(c, "release", { permit_token: out2.permit_token });
109
+ }
110
+ finally {
111
+ await c.close();
112
+ await d.stop();
113
+ }
114
+ });
80
115
  test("mcp: run_with_permit tracks a background job and auto-releases", async () => {
81
116
  const d = await startDaemon();
82
117
  const c = await connectMcp(d);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Unit tests for the cross-platform transport seam (src/shared/transport.ts)
3
+ * and its integration into state-dir/socket-path resolution (persist.ts).
4
+ *
5
+ * These tests run on Linux: the Windows branches are exercised by
6
+ * explicitly injecting platform = "win32" into the pure functions. No
7
+ * Windows machine was available for live verification — see
8
+ * docs/COMPATIBILITY.md (platform matrix).
9
+ */
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync, mkdirSync, existsSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { createServer } from "node:net";
16
+ import { spawn } from "node:child_process";
17
+ import { DEFAULT_PIPE_NAME, isWindows, namedPipePath, resolveStateDir, resolveSocketPath, probeTransport, removeStaleEndpoint, killProcessGroup, } from "../shared/transport.js";
18
+ import { stateDir, socketPath } from "../core/persist.js";
19
+ import { isProcessAlive, jobAlive, aliveBySignalZero, processStartTime } from "../core/daemon.js";
20
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
21
+ mkdirSync(TMP_BASE, { recursive: true });
22
+ const tmp = mkdtempSync(join(TMP_BASE, "ll-platform-"));
23
+ test("namedPipePath builds \\\\.\\pipe\\load-lock by default", () => {
24
+ assert.equal(namedPipePath(), "\\\\.\\pipe\\load-lock");
25
+ assert.equal(namedPipePath(), `\\\\.\\pipe\\${DEFAULT_PIPE_NAME}`);
26
+ assert.equal(namedPipePath("other"), "\\\\.\\pipe\\other");
27
+ });
28
+ test("isWindows only matches win32", () => {
29
+ assert.equal(isWindows("win32"), true);
30
+ assert.equal(isWindows("linux"), false);
31
+ assert.equal(isWindows("darwin"), false);
32
+ });
33
+ test("resolveStateDir: override wins over env and default", () => {
34
+ const env = { LOAD_LOCK_STATE_DIR: "/from-env", HOME: "/home/u" };
35
+ assert.equal(resolveStateDir("win32", env, "/explicit"), "/explicit");
36
+ assert.equal(resolveStateDir("linux", env, "/explicit"), "/explicit");
37
+ assert.equal(resolveStateDir("win32", env), "/from-env");
38
+ });
39
+ test("resolveStateDir: win32 uses LOCALAPPDATA, then USERPROFILE fallback", () => {
40
+ const env = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", USERPROFILE: "C:\\Users\\u" };
41
+ assert.equal(resolveStateDir("win32", env), "C:\\Users\\u\\AppData\\Local\\load-lock");
42
+ const env2 = { USERPROFILE: "C:\\Users\\u" };
43
+ assert.equal(resolveStateDir("win32", env2), "C:\\Users\\u\\AppData\\Local\\load-lock");
44
+ });
45
+ test("resolveStateDir: linux uses XDG_STATE_HOME, then HOME fallback", () => {
46
+ const env = { XDG_STATE_HOME: "/xdg/state", HOME: "/home/u" };
47
+ assert.equal(resolveStateDir("linux", env), "/xdg/state/load-lock");
48
+ const env2 = { HOME: "/home/u" };
49
+ assert.equal(resolveStateDir("linux", env2), "/home/u/.local/state/load-lock");
50
+ });
51
+ test("resolveSocketPath: win32 default is the named pipe", () => {
52
+ const env = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local" };
53
+ assert.equal(resolveSocketPath("win32", env), "\\\\.\\pipe\\load-lock");
54
+ });
55
+ test("resolveSocketPath: LOAD_LOCK_SOCKET override wins on every platform", () => {
56
+ const env = { LOAD_LOCK_SOCKET: "C:\\somewhere\\other.sock" };
57
+ assert.equal(resolveSocketPath("win32", env), "C:\\somewhere\\other.sock");
58
+ assert.equal(resolveSocketPath("linux", env), "C:\\somewhere\\other.sock");
59
+ });
60
+ test("resolveSocketPath: linux default is <state dir>/daemon.sock", () => {
61
+ const env = { HOME: "/home/u" };
62
+ assert.equal(resolveSocketPath("linux", env), "/home/u/.local/state/load-lock/daemon.sock");
63
+ });
64
+ test("resolveSocketPath: explicit override wins over everything", () => {
65
+ const env = { LOAD_LOCK_SOCKET: "/from-env", LOAD_LOCK_STATE_DIR: "/s" };
66
+ assert.equal(resolveSocketPath("linux", env, "/explicit"), "/explicit");
67
+ });
68
+ test("persist stateDir()/socketPath() delegate with the real platform", () => {
69
+ // On Linux (the only platform these tests run on) the defaults must keep
70
+ // their current shape, and env overrides must flow through.
71
+ const savedState = process.env.LOAD_LOCK_STATE_DIR;
72
+ const savedSock = process.env.LOAD_LOCK_SOCKET;
73
+ try {
74
+ delete process.env.LOAD_LOCK_STATE_DIR;
75
+ delete process.env.LOAD_LOCK_SOCKET;
76
+ assert.equal(socketPath(), resolveSocketPath(process.platform, process.env));
77
+ assert.equal(stateDir(), resolveStateDir(process.platform, process.env));
78
+ process.env.LOAD_LOCK_SOCKET = "/tmp/opencode/ll-platform-override.sock";
79
+ assert.equal(socketPath(), "/tmp/opencode/ll-platform-override.sock");
80
+ assert.equal(socketPath("/cli-flag.sock"), "/cli-flag.sock");
81
+ }
82
+ finally {
83
+ if (savedState === undefined)
84
+ delete process.env.LOAD_LOCK_STATE_DIR;
85
+ else
86
+ process.env.LOAD_LOCK_STATE_DIR = savedState;
87
+ if (savedSock === undefined)
88
+ delete process.env.LOAD_LOCK_SOCKET;
89
+ else
90
+ process.env.LOAD_LOCK_SOCKET = savedSock;
91
+ }
92
+ });
93
+ async function withUdsServer(fn) {
94
+ const sockPath = join(tmp, "probe.sock");
95
+ const server = createServer();
96
+ await new Promise((resolve) => server.listen(sockPath, resolve));
97
+ try {
98
+ await fn(sockPath, server);
99
+ }
100
+ finally {
101
+ await new Promise((resolve) => server.close(() => resolve()));
102
+ rmSync(sockPath, { force: true });
103
+ }
104
+ }
105
+ test("probeTransport: true for a live server, false after close", async () => {
106
+ await withUdsServer(async (sockPath, server) => {
107
+ assert.equal(await probeTransport(sockPath), true);
108
+ await new Promise((resolve) => server.close(() => resolve()));
109
+ assert.equal(await probeTransport(sockPath), false);
110
+ });
111
+ });
112
+ test("probeTransport: false for a nonexistent endpoint (never throws)", async () => {
113
+ assert.equal(await probeTransport(join(tmp, "no-such.sock")), false);
114
+ });
115
+ test("removeStaleEndpoint: POSIX unlinks, win32 is a no-op", () => {
116
+ const stale = join(tmp, "stale.sock");
117
+ writeFileSync(stale, "");
118
+ removeStaleEndpoint(stale, "linux");
119
+ assert.equal(existsSync(stale), false);
120
+ const stale2 = join(tmp, "stale2.sock");
121
+ writeFileSync(stale2, "");
122
+ removeStaleEndpoint(stale2, "win32");
123
+ assert.equal(existsSync(stale2), true); // untouched on Windows
124
+ rmSync(stale2, { force: true });
125
+ // Missing file: must not throw.
126
+ removeStaleEndpoint(join(tmp, "never-there.sock"), "linux");
127
+ });
128
+ test("killProcessGroup: POSIX path kills a detached process group", async () => {
129
+ const child = spawn("sleep", ["30"], { detached: true, stdio: "ignore" });
130
+ child.unref();
131
+ const pid = child.pid;
132
+ // Give the process a moment to be scheduled.
133
+ await new Promise((r) => setTimeout(r, 100));
134
+ killProcessGroup(pid, "SIGKILL", "linux");
135
+ const exited = await new Promise((resolve) => {
136
+ const t = setTimeout(() => resolve(false), 5000);
137
+ child.once("exit", () => {
138
+ clearTimeout(t);
139
+ resolve(true);
140
+ });
141
+ });
142
+ assert.equal(exited, true);
143
+ });
144
+ test("killProcessGroup: win32 branch on Linux swallows taskkill failure", () => {
145
+ // taskkill does not exist on Linux; the spawn must not throw.
146
+ assert.doesNotThrow(() => killProcessGroup(12345, "SIGKILL", "win32"));
147
+ assert.doesNotThrow(() => killProcessGroup(12345, "SIGTERM", "win32"));
148
+ });
149
+ test("killProcessGroup: POSIX path tolerates a dead pgid", () => {
150
+ assert.doesNotThrow(() => killProcessGroup(999_999, "SIGKILL", "linux"));
151
+ });
152
+ // ------------------------------------------------------------- liveness (win32)
153
+ test("isProcessAlive: win32 branch uses signal-0 semantics (injected platform)", () => {
154
+ assert.equal(isProcessAlive(process.pid, undefined, "win32"), true);
155
+ // A start time can't be verified without /proc — pid must still count as
156
+ // alive (conservative; the pid-reuse defense is documented as absent).
157
+ assert.equal(isProcessAlive(process.pid, "9999", "win32"), true);
158
+ // 4194303 is the Windows ceiling; a pid that high is never in use here.
159
+ assert.equal(isProcessAlive(4_194_303, undefined, "win32"), false);
160
+ });
161
+ test("aliveBySignalZero: own pid alive, pid 1 alive (ours or EPERM), huge pid dead", () => {
162
+ assert.equal(aliveBySignalZero(process.pid), true);
163
+ // On Linux init is owned by root: signal 0 yields EPERM, which must count
164
+ // as alive. On a container where we are root, it trivially succeeds.
165
+ assert.equal(aliveBySignalZero(1), true);
166
+ assert.equal(aliveBySignalZero(4_194_303), false);
167
+ });
168
+ test("isProcessAlive: Linux branch still checks /proc start time", () => {
169
+ const st = processStartTime(process.pid);
170
+ assert.ok(st !== undefined, "/proc start time must be readable on Linux");
171
+ assert.equal(isProcessAlive(process.pid, st, "linux"), true);
172
+ assert.equal(isProcessAlive(process.pid, "9999", "linux"), false); // mismatch = reuse
173
+ });
174
+ test("jobAlive: win32 skips the process-group scan", () => {
175
+ assert.equal(jobAlive(process.pid, undefined, undefined, "win32"), true);
176
+ // Dead main pid: released even though a group scan would have been
177
+ // possible on Linux — Windows has no process groups.
178
+ assert.equal(jobAlive(4_194_303, process.pid, undefined, "win32"), false);
179
+ // pgid-only on win32: unknown => conservative keep.
180
+ assert.equal(jobAlive(undefined, 12345, undefined, "win32"), null);
181
+ });
182
+ test("jobAlive: Linux still scans process groups", () => {
183
+ assert.equal(jobAlive(process.pid, undefined, undefined, "linux"), true);
184
+ // pgid 1 exists but a dead pid + live foreign pgid keeps the job "alive"
185
+ // via the group scan — semantics unchanged on Linux.
186
+ assert.equal(jobAlive(4_194_303, 1, undefined, "linux"), true);
187
+ // Both dead => false on Linux (group 999999 has no members).
188
+ assert.equal(jobAlive(4_194_303, 999_999, undefined, "linux"), false);
189
+ });
190
+ // Clean up once everything finished.
191
+ test("cleanup tmp dir", () => {
192
+ rmSync(tmp, { recursive: true, force: true });
193
+ });
@@ -8,7 +8,10 @@ import { join, dirname } from "node:path";
8
8
  import { connect } from "node:net";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { LLClient } from "../shared/client.js";
11
- const TMP_BASE = join(tmpdir(), "opencode");
11
+ // Tests must never touch the user's real state dir. LL_TEST_TMP lets the
12
+ // suite run on machines where /tmp/opencode is not writable (same override
13
+ // as daemon.test.ts).
14
+ const TMP_BASE = process.env.LL_TEST_TMP ?? join(tmpdir(), "opencode");
12
15
  mkdirSync(TMP_BASE, { recursive: true });
13
16
  export const daemonMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "core", "daemon-main.js");
14
17
  export const cliMainPath = join(dirname(fileURLToPath(import.meta.url)), "..", "cli", "main.js");
@@ -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
+ });
@@ -0,0 +1 @@
1
+ export {};