@zswarm/core 0.1.7 → 0.1.9
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 +8 -0
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +4 -1
- package/dist/exec.d.ts +10 -1
- package/dist/exec.js +28 -15
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/ops/broadcast.d.ts +6 -0
- package/dist/ops/broadcast.js +15 -11
- package/dist/ops/bus.js +7 -0
- package/dist/ops/dispatch.js +39 -5
- package/dist/ops/doctor.d.ts +80 -0
- package/dist/ops/doctor.js +1377 -0
- package/dist/ops/routing.js +2 -1
- package/dist/ops/serve-bind.d.ts +86 -0
- package/dist/ops/serve-bind.js +354 -0
- package/dist/ops/serve-install.d.ts +88 -0
- package/dist/ops/serve-install.js +1171 -0
- package/dist/ops/serve-tunnel.d.ts +109 -0
- package/dist/ops/serve-tunnel.js +1179 -0
- package/dist/ops/serve.d.ts +89 -21
- package/dist/ops/serve.js +622 -156
- package/dist/ops/status.d.ts +4 -2
- package/dist/ops/status.js +15 -12
- package/dist/ops/types.d.ts +49 -0
- package/dist/ops/util.js +8 -1
- package/dist/ops/wait.js +29 -7
- package/dist/schema.d.ts +1 -1
- package/dist/schema.js +8 -7
- package/dist/state.d.ts +7 -0
- package/dist/state.js +209 -46
- package/dist/zellij/binary.d.ts +7 -0
- package/dist/zellij/binary.js +20 -7
- package/dist/zellij/client.js +7 -7
- package/package.json +1 -1
package/dist/ops/status.d.ts
CHANGED
|
@@ -40,12 +40,13 @@ export declare function statusTabs(peers: Record<string, unknown>[]): {
|
|
|
40
40
|
panes: number;
|
|
41
41
|
states: Record<string, number>;
|
|
42
42
|
}[];
|
|
43
|
-
|
|
43
|
+
type ScreenPair = {
|
|
44
44
|
exited: boolean;
|
|
45
45
|
before: string;
|
|
46
46
|
after: string;
|
|
47
47
|
profile?: HarnessProfile | null;
|
|
48
|
-
}
|
|
48
|
+
};
|
|
49
|
+
export declare function classify(input: ScreenPair): PeerState;
|
|
49
50
|
/** Run `fn` over items with at most `limit` in flight. */
|
|
50
51
|
export declare function mapPool<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
|
|
51
52
|
/**
|
|
@@ -59,3 +60,4 @@ export declare function peerStatus(client: ZellijClient, args: Record<string, un
|
|
|
59
60
|
deadlineAt?: number;
|
|
60
61
|
signal?: AbortSignal;
|
|
61
62
|
}): Promise<OpsResult>;
|
|
63
|
+
export {};
|
package/dist/ops/status.js
CHANGED
|
@@ -53,9 +53,6 @@ export function waitingPrompt(screen, profile) {
|
|
|
53
53
|
const last = lines[lines.length - 1] ?? "";
|
|
54
54
|
return QUESTION.test(last) ? { reason: "prompt", evidence: last.slice(0, 320), source: "screen" } : null;
|
|
55
55
|
}
|
|
56
|
-
function promptHolds(screen, profile) {
|
|
57
|
-
return waitingPrompt(screen, profile) !== null;
|
|
58
|
-
}
|
|
59
56
|
/** Compact summary of the selected terminal peers, including inactive tabs. */
|
|
60
57
|
export function statusTabs(peers) {
|
|
61
58
|
const tabs = new Map();
|
|
@@ -69,12 +66,17 @@ export function statusTabs(peers) {
|
|
|
69
66
|
}
|
|
70
67
|
return [...tabs.values()];
|
|
71
68
|
}
|
|
72
|
-
|
|
69
|
+
/** The verdict and, for `waiting`, the prompt behind it — one screen scan. */
|
|
70
|
+
function classifyWithEvidence(input) {
|
|
73
71
|
if (input.exited)
|
|
74
|
-
return "exited";
|
|
72
|
+
return { state: "exited", waiting: null };
|
|
75
73
|
if (input.before !== input.after)
|
|
76
|
-
return "busy";
|
|
77
|
-
|
|
74
|
+
return { state: "busy", waiting: null };
|
|
75
|
+
const waiting = waitingPrompt(input.after, input.profile);
|
|
76
|
+
return { state: waiting ? "waiting" : "idle", waiting };
|
|
77
|
+
}
|
|
78
|
+
export function classify(input) {
|
|
79
|
+
return classifyWithEvidence(input).state;
|
|
78
80
|
}
|
|
79
81
|
/** Run `fn` over items with at most `limit` in flight. */
|
|
80
82
|
export async function mapPool(items, limit, fn) {
|
|
@@ -96,6 +98,7 @@ function isCancelledError(err) {
|
|
|
96
98
|
return ((err instanceof ZellijError && err.code === "cancelled") ||
|
|
97
99
|
(err instanceof Error && /cancelled/i.test(err.message)));
|
|
98
100
|
}
|
|
101
|
+
/** A `waiting` peer carries its evidence in `extra`, found when it was classified. */
|
|
99
102
|
function peerEntry(pane, state, screen, verbose, extra) {
|
|
100
103
|
const entry = {
|
|
101
104
|
id: pane.id,
|
|
@@ -106,8 +109,6 @@ function peerEntry(pane, state, screen, verbose, extra) {
|
|
|
106
109
|
lastLine: lastLine(screen).slice(0, 160),
|
|
107
110
|
...extra,
|
|
108
111
|
};
|
|
109
|
-
if (state === "waiting")
|
|
110
|
-
entry.waiting = waitingPrompt(screen, resolveHarness(pane));
|
|
111
112
|
if (verbose) {
|
|
112
113
|
entry.command = pane.command ?? null;
|
|
113
114
|
entry.cwd = pane.cwd ?? null;
|
|
@@ -185,11 +186,13 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
|
|
|
185
186
|
return peerEntry(pane, "exited", "", verbose);
|
|
186
187
|
}
|
|
187
188
|
const row = changed.get(pane.id);
|
|
189
|
+
const waiting = row ? waitingPrompt(row.screen, resolveHarness(pane)) : null;
|
|
188
190
|
const state = !row ? "unknown"
|
|
189
|
-
:
|
|
191
|
+
: waiting ? "waiting"
|
|
190
192
|
: row.first ? "unknown" : row.changed ? "busy" : "idle";
|
|
191
193
|
return peerEntry(pane, state, row?.screen ?? "", verbose, {
|
|
192
194
|
...(row?.first ? { first: true } : {}),
|
|
195
|
+
...(waiting ? { waiting } : {}),
|
|
193
196
|
});
|
|
194
197
|
})
|
|
195
198
|
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
@@ -315,13 +318,13 @@ export async function peerStatus(client, args, clock, supplied, opts = {}) {
|
|
|
315
318
|
peers.push(peerEntry(pane, "unknown", after ?? first ?? "", verbose));
|
|
316
319
|
continue;
|
|
317
320
|
}
|
|
318
|
-
const state =
|
|
321
|
+
const { state, waiting } = classifyWithEvidence({
|
|
319
322
|
exited: false,
|
|
320
323
|
before: first,
|
|
321
324
|
after,
|
|
322
325
|
profile: resolveHarness(pane),
|
|
323
326
|
});
|
|
324
|
-
peers.push(peerEntry(pane, state, after, verbose));
|
|
327
|
+
peers.push(peerEntry(pane, state, after, verbose, waiting ? { waiting } : undefined));
|
|
325
328
|
}
|
|
326
329
|
peers.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
327
330
|
const free = peers.filter((p) => p.state === "idle").map((p) => p.id);
|
package/dist/ops/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RoutingContext } from "./routing.js";
|
|
2
|
+
import type { ServeTunnelManager } from "./serve-tunnel.js";
|
|
2
3
|
export type OpsResult = ({
|
|
3
4
|
ok: true;
|
|
4
5
|
data: unknown;
|
|
@@ -25,6 +26,54 @@ export type DispatchDeps = {
|
|
|
25
26
|
env?: NodeJS.ProcessEnv;
|
|
26
27
|
/** MCP cancellation; aborted waits stop instead of running to timeout. */
|
|
27
28
|
signal?: AbortSignal;
|
|
29
|
+
/** Process-owned ssh:// LocalForward manager. CLI disposes; MCP reuses. */
|
|
30
|
+
serveTunnels?: ServeTunnelManager;
|
|
31
|
+
/**
|
|
32
|
+
* Test seam for optional Tailscale `status --json`. Default runs the
|
|
33
|
+
* `tailscale` CLI with a bounded timeout and output cap.
|
|
34
|
+
* Used by doctor (peer mapping) and by serve bind verification.
|
|
35
|
+
*/
|
|
36
|
+
tailscaleStatus?: (input: {
|
|
37
|
+
timeoutMs: number;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
env: NodeJS.ProcessEnv;
|
|
40
|
+
}) => Promise<{
|
|
41
|
+
code: number;
|
|
42
|
+
stdout: string;
|
|
43
|
+
stderr: string;
|
|
44
|
+
}>;
|
|
45
|
+
/** Test seam for OS interface address ownership during Tailscale serve bind. */
|
|
46
|
+
networkInterfaces?: () => NodeJS.Dict<import("node:os").NetworkInterfaceInfo[]>;
|
|
47
|
+
/** Narrow injectables for Windows `serve --install` / `--clear`. */
|
|
48
|
+
serveInstall?: ServeInstallDeps;
|
|
49
|
+
};
|
|
50
|
+
export type ServePowerShellResult = {
|
|
51
|
+
code: number;
|
|
52
|
+
stdout: string;
|
|
53
|
+
stderr: string;
|
|
54
|
+
aborted?: boolean;
|
|
55
|
+
timedOut?: boolean;
|
|
56
|
+
};
|
|
57
|
+
export type ServeInstallDeps = {
|
|
58
|
+
platform?: NodeJS.Platform;
|
|
59
|
+
runPowerShell?: (script: string, options: {
|
|
60
|
+
timeoutMs: number;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
}) => Promise<ServePowerShellResult>;
|
|
63
|
+
probeServe?: (target: string, options?: {
|
|
64
|
+
token?: string;
|
|
65
|
+
timeoutMs?: number;
|
|
66
|
+
signal?: AbortSignal;
|
|
67
|
+
}) => Promise<OpsResult>;
|
|
68
|
+
callServe?: (target: string, args: Record<string, unknown>, options: {
|
|
69
|
+
timeoutMs: number;
|
|
70
|
+
token?: string;
|
|
71
|
+
signal?: AbortSignal;
|
|
72
|
+
}) => Promise<OpsResult>;
|
|
73
|
+
execPath?: string;
|
|
74
|
+
scriptPath?: string;
|
|
75
|
+
argv?: string[];
|
|
76
|
+
launchId?: string;
|
|
28
77
|
};
|
|
29
78
|
export type Clock = {
|
|
30
79
|
now: () => number;
|
package/dist/ops/util.js
CHANGED
|
@@ -5,7 +5,14 @@ export const DEFAULT_DUMP_MAX_CHARS = 8_000;
|
|
|
5
5
|
export const DEFAULT_WAIT_MAX_CHARS = 2_000;
|
|
6
6
|
export function fail(err) {
|
|
7
7
|
if (err instanceof ZellijError) {
|
|
8
|
-
return {
|
|
8
|
+
return {
|
|
9
|
+
ok: false,
|
|
10
|
+
error: {
|
|
11
|
+
code: err.code,
|
|
12
|
+
message: err.message,
|
|
13
|
+
...(err.details ? { details: err.details } : {}),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
9
16
|
}
|
|
10
17
|
const message = err instanceof Error ? err.message : String(err);
|
|
11
18
|
return { ok: false, error: { code: "failed", message } };
|
package/dist/ops/wait.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ZellijError } from "../errors.js";
|
|
2
|
+
import { DEFAULT_TIMEOUT_MS } from "../zellij/binary.js";
|
|
2
3
|
import { DEFAULT_WAIT_MAX_CHARS, dumpMaxChars, isTrue, normalizeScreen, numberArg, optionalString, throwIfAborted, truncateDumpText, } from "./util.js";
|
|
3
4
|
const WAIT_DEFAULTS = {
|
|
4
5
|
idleMs: 2_000,
|
|
@@ -10,6 +11,11 @@ const WAIT_LIMITS = {
|
|
|
10
11
|
pollMs: { min: 50, max: 30_000 },
|
|
11
12
|
timeoutMs: { min: 1_000, max: 900_000 },
|
|
12
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* A poll at the deadline still gets this long, so `timeout` reports the screen
|
|
16
|
+
* at the deadline; a hung dump there overruns the wait by at most this much.
|
|
17
|
+
*/
|
|
18
|
+
const FINAL_DUMP_MS = 1_000;
|
|
13
19
|
export function buildMatcher(args) {
|
|
14
20
|
const match = optionalString(args.match);
|
|
15
21
|
if (!match)
|
|
@@ -98,15 +104,29 @@ export async function waitForPane(client, target, args, clock, waitViaBus, signa
|
|
|
98
104
|
let lastChangeAt = started;
|
|
99
105
|
let polls = 0;
|
|
100
106
|
let changes = 0;
|
|
107
|
+
let last = null;
|
|
101
108
|
for (;;) {
|
|
102
109
|
throwIfAborted(signal);
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
110
|
+
const left = timeoutMs - (clock.now() - started);
|
|
111
|
+
let text;
|
|
112
|
+
try {
|
|
113
|
+
const dumped = await client.dumpPane({
|
|
114
|
+
session,
|
|
115
|
+
paneId: pane.id,
|
|
116
|
+
full,
|
|
117
|
+
timeoutMs: Math.max(FINAL_DUMP_MS, Math.min(left, DEFAULT_TIMEOUT_MS)),
|
|
118
|
+
});
|
|
119
|
+
text = dumped.text;
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
throwIfAborted(signal);
|
|
123
|
+
// Out of time with a screen in hand: the wait timed out, it did not fail.
|
|
124
|
+
if (last && clock.now() - started >= timeoutMs) {
|
|
125
|
+
return waitResult("timeout", { ...last, at: clock.now() });
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
108
129
|
polls++;
|
|
109
|
-
const text = dumped.text;
|
|
110
130
|
const screen = normalizeScreen(text);
|
|
111
131
|
const at = clock.now();
|
|
112
132
|
const ctx = {
|
|
@@ -137,6 +157,8 @@ export async function waitForPane(client, target, args, clock, waitViaBus, signa
|
|
|
137
157
|
}
|
|
138
158
|
if (at - started >= timeoutMs)
|
|
139
159
|
return waitResult("timeout", ctx);
|
|
140
|
-
|
|
160
|
+
last = ctx;
|
|
161
|
+
// The last poll lands on the deadline, not up to a whole pollMs past it.
|
|
162
|
+
await clock.sleep(Math.min(pollMs, timeoutMs - (at - started)));
|
|
141
163
|
}
|
|
142
164
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* One description of the zswarm surface. The MCP tool schema, the CLI flags,
|
|
3
3
|
* and the CLI help text are all generated from it, so they cannot drift apart.
|
|
4
4
|
*/
|
|
5
|
-
export declare const OP_NAMES: readonly ["list", "sessions", "send", "broadcast", "dump", "tail", "wait", "status", "keys", "interrupt", "spawn", "close", "worktrees", "unworktree", "signal", "signals", "await", "log", "rename", "focus", "tabs", "layout", "stack", "diff", "checkpoint", "bus", "serve"];
|
|
5
|
+
export declare const OP_NAMES: readonly ["list", "sessions", "send", "broadcast", "dump", "tail", "wait", "status", "keys", "interrupt", "spawn", "close", "worktrees", "unworktree", "signal", "signals", "await", "log", "rename", "focus", "tabs", "layout", "stack", "diff", "checkpoint", "bus", "serve", "doctor"];
|
|
6
6
|
export type OpName = (typeof OP_NAMES)[number];
|
|
7
7
|
/** Ops that address an existing pane through `to`. */
|
|
8
8
|
export declare const TARGET_OPS: readonly OpName[];
|
package/dist/schema.js
CHANGED
|
@@ -31,6 +31,7 @@ export const OP_NAMES = [
|
|
|
31
31
|
"checkpoint",
|
|
32
32
|
"bus",
|
|
33
33
|
"serve",
|
|
34
|
+
"doctor",
|
|
34
35
|
];
|
|
35
36
|
/** Ops that address an existing pane through `to`. */
|
|
36
37
|
export const TARGET_OPS = [
|
|
@@ -51,7 +52,7 @@ export const PARAMS = [
|
|
|
51
52
|
name: "session",
|
|
52
53
|
type: "string",
|
|
53
54
|
flags: ["--session", "-s"],
|
|
54
|
-
description: "Zellij session name (optional if sole live session or ZSWARM_SESSION / ZELLIJ_SESSION_NAME)",
|
|
55
|
+
description: "Zellij session name (optional if sole live session or ZSWARM_SESSION / ZELLIJ_SESSION_NAME). serve --install: readiness target only — does not change the task's default routing",
|
|
55
56
|
},
|
|
56
57
|
{
|
|
57
58
|
name: "to",
|
|
@@ -111,13 +112,13 @@ export const PARAMS = [
|
|
|
111
112
|
name: "clear",
|
|
112
113
|
type: "boolean",
|
|
113
114
|
flags: ["--clear"],
|
|
114
|
-
description: "signal: reset the channel (all channels when none is given); bus: forget the installed plugin; serve: unregister the Windows logon task",
|
|
115
|
+
description: "signal: reset the channel (all channels when none is given); bus: forget the installed plugin; serve: stop and unregister the owned Windows zswarm-serve logon task if present",
|
|
115
116
|
},
|
|
116
117
|
{
|
|
117
118
|
name: "install",
|
|
118
119
|
type: "boolean",
|
|
119
120
|
flags: ["--install"],
|
|
120
|
-
description: "bus: load the event-bus plugin in a pane so its permission prompt can be answered, then remember it; serve: register
|
|
121
|
+
description: "bus: load the event-bus plugin in a pane so its permission prompt can be answered, then remember it; serve: register the current-user Windows Interactive logon task and wait for authenticated hello plus host session visibility",
|
|
121
122
|
},
|
|
122
123
|
{
|
|
123
124
|
name: "reset",
|
|
@@ -147,7 +148,7 @@ export const PARAMS = [
|
|
|
147
148
|
name: "serveAddress",
|
|
148
149
|
type: "string",
|
|
149
150
|
flags: ["--serve"],
|
|
150
|
-
description: "
|
|
151
|
+
description: "existing serve endpoint: host:port, tcp://host:port, or ssh://user@host[:sshPort]?servePort=9419. ssh:// opens a process-owned SSH LocalForward and probes hello before ops (desktop serve must already be running). Omit sshPort to use ssh_config Port. tcp:// also names a private Tailscale Serve frontend over loopback (docs/tailscale.md). Uses ZSWARM_SERVE_TOKEN",
|
|
151
152
|
},
|
|
152
153
|
{
|
|
153
154
|
name: "limit",
|
|
@@ -257,7 +258,7 @@ export const PARAMS = [
|
|
|
257
258
|
name: "timeoutMs",
|
|
258
259
|
type: "number",
|
|
259
260
|
flags: ["--timeout-ms"],
|
|
260
|
-
description: "wait: timeout (default 60000); status/spawn: overall deadline (default 30000), including
|
|
261
|
+
description: "wait: timeout (default 60000); status/spawn: overall deadline (default 30000); doctor: overall deadline (default 10000), including Tailscale/SSH/hello/host checks; serve --install: overall install/readiness deadline (default 30000)",
|
|
261
262
|
},
|
|
262
263
|
{
|
|
263
264
|
name: "keys",
|
|
@@ -434,7 +435,7 @@ export const PARAMS = [
|
|
|
434
435
|
name: "listen",
|
|
435
436
|
type: "string",
|
|
436
437
|
flags: ["--listen"],
|
|
437
|
-
description: "serve: bind address (default 127.0.0.1:9419).
|
|
438
|
+
description: "serve: bind address (default 127.0.0.1:9419). Loopback needs no Tailscale; a non-loopback literal must be a verified local Tailscale IP (see docs/tailscale.md). Reach via ZSWARM_SERVE / --serve (direct host:port, private Tailscale Serve tcp:// frontend, or ssh:// to remote loopback)",
|
|
438
439
|
},
|
|
439
440
|
{
|
|
440
441
|
name: "verbose",
|
|
@@ -495,7 +496,7 @@ export function cliUsage() {
|
|
|
495
496
|
const value = param.type === "boolean" ? "" : param.type === "number" ? " N" : " VALUE";
|
|
496
497
|
lines.push(` ${param.flags.join(", ").padEnd(28)}${value.trim().padEnd(6)}${param.description}`);
|
|
497
498
|
}
|
|
498
|
-
lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE (
|
|
499
|
+
lines.push("", "Guards: writes refuse zswarm's own pane (--allow-self) and exited panes (--force). --expect requires the screen to contain a substring first.", "Bus: `zswarm bus --install` once per Zellij session. `--force` closes orphan bus panes and reloads; do not use it as a retry.", "Remote: ZSWARM_SSH (+ ZSWARM_TMP=auto or ZSWARM_SSH_MODE=interactive on Windows). Or run `zswarm serve --listen` next to Zellij and set ZSWARM_SERVE / --serve (host:port, tcp://, or ssh://user@host?servePort=9419) plus ZSWARM_SERVE_TOKEN. Serve defaults to loopback and always requires a token; an explicit local Tailscale IP is allowed only after host verification. Private raw TCP Tailscale Serve keeps the backend on 127.0.0.1 behind `tailscale serve --tcp=…` (docs/tailscale.md). ssh:// does not start remote serve. Windows default recipe: `zswarm serve --install` (verified readiness).", "Doctor: `zswarm doctor --session crew` inspects local, --ssh, and --serve routes without installs, pane changes, or plugin launch. See docs/doctor.md and docs/tailscale.md.", "Env: ZSWARM_BIN, ZSWARM_PATH, ZSWARM_SESSION, ZSWARM_SELF_PANE, ZSWARM_FROM, ZELLIJ_PANE_ID, ZELLIJ_SESSION_NAME, ZSWARM_BUS, ZSWARM_BUS_PLUGIN, ZSWARM_SSH, ZSWARM_SSH_BIN, ZSWARM_SSH_OPTS, ZSWARM_TMP, ZSWARM_SSH_MODE, ZSWARM_SERVE, ZSWARM_SERVE_TOKEN, ZSWARM_TAILSCALE_BIN, ZSWARM_CACHE_TTL_MS", "");
|
|
499
500
|
return lines.join("\n");
|
|
500
501
|
}
|
|
501
502
|
/** Turn argv (without the op) into dispatch args, driven by PARAMS. */
|
package/dist/state.d.ts
CHANGED
|
@@ -29,6 +29,13 @@ export type BusMarkerRecord = {
|
|
|
29
29
|
configKey: string;
|
|
30
30
|
installedAt: number;
|
|
31
31
|
};
|
|
32
|
+
/** How much of the end of the log `readLog` reads; older entries are not shown. */
|
|
33
|
+
export declare const LOG_TAIL_BYTES: number;
|
|
34
|
+
/**
|
|
35
|
+
* Past this, the append that crossed it trims the file back to its readable
|
|
36
|
+
* tail. Keeps the log bounded without needing a rotation daemon.
|
|
37
|
+
*/
|
|
38
|
+
export declare const LOG_MAX_BYTES: number;
|
|
32
39
|
export declare function defaultStateDir(env?: NodeJS.ProcessEnv): string;
|
|
33
40
|
export type StateStoreOptions = {
|
|
34
41
|
dir?: string;
|
package/dist/state.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const LOG_FILE = "log.jsonl";
|
|
@@ -7,11 +7,59 @@ const SIGNALS_LOCK = "signals.lock";
|
|
|
7
7
|
const CURSORS_FILE = "cursors.json";
|
|
8
8
|
const CURSORS_LOCK = "cursors.lock";
|
|
9
9
|
const BUS_FILE = "bus.json";
|
|
10
|
-
/**
|
|
11
|
-
const LOG_TAIL_BYTES = 512 * 1024;
|
|
10
|
+
/** How much of the end of the log `readLog` reads; older entries are not shown. */
|
|
11
|
+
export const LOG_TAIL_BYTES = 512 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Past this, the append that crossed it trims the file back to its readable
|
|
14
|
+
* tail. Keeps the log bounded without needing a rotation daemon.
|
|
15
|
+
*/
|
|
16
|
+
export const LOG_MAX_BYTES = 4 * 1024 * 1024;
|
|
17
|
+
/** Bound for waiting on a *live* lock holder before failing. */
|
|
12
18
|
const LOCK_WAIT_MS = 5_000;
|
|
13
|
-
/**
|
|
14
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Fresh empty/malformed lock files this young are treated as an in-flight
|
|
21
|
+
* exclusive create (wait), not as abandoned debris (refuse).
|
|
22
|
+
*/
|
|
23
|
+
const LOCK_PENDING_MS = LOCK_WAIT_MS;
|
|
24
|
+
/**
|
|
25
|
+
* The last `bytes` of a file, starting at its first whole line. Reads only
|
|
26
|
+
* that window, so a long-lived log does not cost a full read per `log` call.
|
|
27
|
+
*/
|
|
28
|
+
function readFileTail(path, bytes) {
|
|
29
|
+
const fd = openSync(path, "r");
|
|
30
|
+
try {
|
|
31
|
+
const size = fstatSync(fd).size;
|
|
32
|
+
// One byte of lead-in says whether the window already starts a line.
|
|
33
|
+
const start = Math.max(0, size - bytes - 1);
|
|
34
|
+
const buf = Buffer.alloc(size - start);
|
|
35
|
+
let read = 0;
|
|
36
|
+
while (read < buf.length) {
|
|
37
|
+
const n = readSync(fd, buf, read, buf.length - read, start + read);
|
|
38
|
+
if (n === 0)
|
|
39
|
+
break;
|
|
40
|
+
read += n;
|
|
41
|
+
}
|
|
42
|
+
const text = buf.toString("utf8", 0, read);
|
|
43
|
+
return start === 0 ? text : text.slice(text.indexOf("\n") + 1);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Keep only a file's readable tail. Everything before it is already invisible
|
|
51
|
+
* to `readLog`; an append racing the rename can be lost, and the log is an aid.
|
|
52
|
+
*/
|
|
53
|
+
function trimToTail(path, bytes) {
|
|
54
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
55
|
+
try {
|
|
56
|
+
writeFileSync(tmp, readFileTail(path, bytes), "utf8");
|
|
57
|
+
renameSync(tmp, path);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
rmSync(tmp, { force: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
15
63
|
function sleepSync(ms) {
|
|
16
64
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
17
65
|
}
|
|
@@ -49,7 +97,10 @@ export function createStateStore(options = {}) {
|
|
|
49
97
|
return;
|
|
50
98
|
try {
|
|
51
99
|
ensureDir();
|
|
52
|
-
|
|
100
|
+
const path = join(dir, LOG_FILE);
|
|
101
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8");
|
|
102
|
+
if (statSync(path).size > LOG_MAX_BYTES)
|
|
103
|
+
trimToTail(path, LOG_TAIL_BYTES);
|
|
53
104
|
}
|
|
54
105
|
catch {
|
|
55
106
|
// The log is an aid, never a reason to fail an op.
|
|
@@ -58,15 +109,11 @@ export function createStateStore(options = {}) {
|
|
|
58
109
|
function readLog() {
|
|
59
110
|
let raw;
|
|
60
111
|
try {
|
|
61
|
-
raw =
|
|
112
|
+
raw = readFileTail(join(dir, LOG_FILE), LOG_TAIL_BYTES);
|
|
62
113
|
}
|
|
63
114
|
catch {
|
|
64
115
|
return [];
|
|
65
116
|
}
|
|
66
|
-
if (raw.length > LOG_TAIL_BYTES) {
|
|
67
|
-
raw = raw.slice(raw.length - LOG_TAIL_BYTES);
|
|
68
|
-
raw = raw.slice(raw.indexOf("\n") + 1);
|
|
69
|
-
}
|
|
70
117
|
const out = [];
|
|
71
118
|
for (const line of raw.split("\n")) {
|
|
72
119
|
if (!line.trim())
|
|
@@ -103,32 +150,6 @@ export function createStateStore(options = {}) {
|
|
|
103
150
|
return null;
|
|
104
151
|
}
|
|
105
152
|
}
|
|
106
|
-
/**
|
|
107
|
-
* Dead pid → steal now. This process's own leftover (unlink failed, or a
|
|
108
|
-
* non-reentrant re-entry) → steal now. A live pid whose `at` is older than
|
|
109
|
-
* LOCK_STALE_MS is a recycled pid, not a holder still inside fn().
|
|
110
|
-
* Empty leftover from older writers (wx with no owner bytes) → steal once
|
|
111
|
-
* mtime is older than the wait, so an in-flight create is not yanked out
|
|
112
|
-
* from under the holder.
|
|
113
|
-
* Owner is read once: a second read can see a pid that appeared after an
|
|
114
|
-
* empty snapshot and would steal a live lock.
|
|
115
|
-
*/
|
|
116
|
-
function lockIsStale(lockPath) {
|
|
117
|
-
const owner = readLockOwner(lockPath);
|
|
118
|
-
if (owner) {
|
|
119
|
-
if (owner.pid === process.pid)
|
|
120
|
-
return true;
|
|
121
|
-
if (!pidAlive(owner.pid))
|
|
122
|
-
return true;
|
|
123
|
-
return Date.now() - owner.at >= LOCK_STALE_MS;
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
return Date.now() - statSync(lockPath).mtimeMs >= LOCK_WAIT_MS;
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
153
|
function lockBusy(code) {
|
|
133
154
|
// Unix: O_EXCL on an existing file is EEXIST. Windows: a holder that still
|
|
134
155
|
// has the handle open (or a delete-pending name) is EPERM / EACCES / EBUSY.
|
|
@@ -137,43 +158,185 @@ export function createStateStore(options = {}) {
|
|
|
137
158
|
code === "EACCES" ||
|
|
138
159
|
code === "EBUSY");
|
|
139
160
|
}
|
|
140
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Cursor/signal locks: exclusive create (`wx`) + owner stamp; release by
|
|
163
|
+
* generation-matched unlink of the stamp this process published.
|
|
164
|
+
*
|
|
165
|
+
* Foreign / abandoned / empty-old lock files are NOT auto-reclaimed.
|
|
166
|
+
* Portable check-then-rename/unlink reclaim can move a live holder's shared
|
|
167
|
+
* name aside and admit another writer into that critical section (demonstrated
|
|
168
|
+
* with real cooperating writeCursor processes). Prefer bounded fail-closed
|
|
169
|
+
* refusal over unsafe automatic recovery.
|
|
170
|
+
*
|
|
171
|
+
* A dead-looking owner observation is only refused when that same generation
|
|
172
|
+
* is still present. If normal release removed the path or another writer
|
|
173
|
+
* published a new generation between observation and the liveness check,
|
|
174
|
+
* contenders retry exclusive create within the original LOCK_WAIT_MS budget
|
|
175
|
+
* (generation churn does not reset the deadline).
|
|
176
|
+
*
|
|
177
|
+
* Self-pid leftovers (same process, prior unlink failed) may be removed when
|
|
178
|
+
* the on-disk generation still matches — this process is not inside `fn()`.
|
|
179
|
+
*
|
|
180
|
+
* Operator recovery: if acquisition refuses an abandoned lock, confirm the
|
|
181
|
+
* recorded pid is gone and no writer holds the file, then remove the lock
|
|
182
|
+
* path manually and retry.
|
|
183
|
+
*/
|
|
184
|
+
function unlinkIfMatchingOwner(lockPath, expected) {
|
|
141
185
|
const until = Date.now() + 500;
|
|
142
186
|
while (true) {
|
|
187
|
+
const current = readLockOwner(lockPath);
|
|
188
|
+
if (!current ||
|
|
189
|
+
current.pid !== expected.pid ||
|
|
190
|
+
current.at !== expected.at) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
143
193
|
try {
|
|
144
194
|
rmSync(lockPath, { force: true });
|
|
145
|
-
return;
|
|
195
|
+
return true;
|
|
146
196
|
}
|
|
147
197
|
catch (err) {
|
|
148
198
|
if (!lockBusy(err.code) || Date.now() >= until) {
|
|
149
|
-
return;
|
|
199
|
+
return false;
|
|
150
200
|
}
|
|
151
201
|
sleepSync(10);
|
|
152
202
|
}
|
|
153
203
|
}
|
|
154
204
|
}
|
|
205
|
+
/** Only this process may clear its own leftover generation. */
|
|
206
|
+
function tryReclaimSelfLock(lockPath) {
|
|
207
|
+
const owner = readLockOwner(lockPath);
|
|
208
|
+
if (!owner || owner.pid !== process.pid)
|
|
209
|
+
return false;
|
|
210
|
+
return unlinkIfMatchingOwner(lockPath, owner);
|
|
211
|
+
}
|
|
212
|
+
function abandonedLockError(lockName, lockPath, observed) {
|
|
213
|
+
// Use the generation that justified refusal — do not re-read the path and
|
|
214
|
+
// accidentally describe a live successor as needing operator recovery.
|
|
215
|
+
if (observed) {
|
|
216
|
+
return new Error(`refusing automatic reclaim of ${lockName} (dead-or-abandoned owner pid=${observed.pid} at=${observed.at}); ` +
|
|
217
|
+
`remove ${lockPath} only after confirming that process is gone and no writer holds the file, then retry`);
|
|
218
|
+
}
|
|
219
|
+
return new Error(`refusing automatic reclaim of ${lockName} (empty or malformed lock without a safe owner record); ` +
|
|
220
|
+
`remove ${lockPath} only when no writer is using it, then retry`);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Classify a blocking lock against the *current* generation.
|
|
224
|
+
*
|
|
225
|
+
* `wait` = live holder or fresh in-flight create.
|
|
226
|
+
* `refuse` = unchanged abandoned foreign/empty debris — fail closed.
|
|
227
|
+
* `self` = reclaimable same-pid leftover.
|
|
228
|
+
* `retry` = observation went stale during the check (normal release removed
|
|
229
|
+
* the path, or a new generation superseded the departed owner). Contender
|
|
230
|
+
* must retry exclusive create within the original wait budget — not refuse
|
|
231
|
+
* a lock that is no longer abandoned, and not unlink/rename anything.
|
|
232
|
+
*/
|
|
233
|
+
function classifyBlockingLock(lockPath) {
|
|
234
|
+
const owner = readLockOwner(lockPath);
|
|
235
|
+
if (owner) {
|
|
236
|
+
if (owner.pid === process.pid)
|
|
237
|
+
return { kind: "self", observed: owner };
|
|
238
|
+
if (pidAlive(owner.pid))
|
|
239
|
+
return { kind: "wait", observed: owner };
|
|
240
|
+
// Dead-looking foreign owner: only refuse if THIS generation is still
|
|
241
|
+
// current. Normal release+exit between read and liveness check leaves
|
|
242
|
+
// the path absent or replaced by a live successor — that is not
|
|
243
|
+
// abandoned debris.
|
|
244
|
+
const still = readLockOwner(lockPath);
|
|
245
|
+
if (!still)
|
|
246
|
+
return { kind: "retry", observed: null };
|
|
247
|
+
if (still.pid !== owner.pid || still.at !== owner.at) {
|
|
248
|
+
return { kind: "retry", observed: still };
|
|
249
|
+
}
|
|
250
|
+
return { kind: "refuse", observed: owner };
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
if (Date.now() - statSync(lockPath).mtimeMs < LOCK_PENDING_MS) {
|
|
254
|
+
return { kind: "wait", observed: null };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// Path disappeared between the busy create and this check.
|
|
259
|
+
return { kind: "retry", observed: null };
|
|
260
|
+
}
|
|
261
|
+
// Old empty/malformed: confirm it is still present and still ownerless
|
|
262
|
+
// before refusing — a successor may have published while we inspected.
|
|
263
|
+
const again = readLockOwner(lockPath);
|
|
264
|
+
if (again) {
|
|
265
|
+
if (again.pid === process.pid)
|
|
266
|
+
return { kind: "self", observed: again };
|
|
267
|
+
if (pidAlive(again.pid))
|
|
268
|
+
return { kind: "wait", observed: again };
|
|
269
|
+
const confirm = readLockOwner(lockPath);
|
|
270
|
+
if (!confirm)
|
|
271
|
+
return { kind: "retry", observed: null };
|
|
272
|
+
if (confirm.pid !== again.pid || confirm.at !== again.at) {
|
|
273
|
+
return { kind: "retry", observed: confirm };
|
|
274
|
+
}
|
|
275
|
+
return { kind: "refuse", observed: again };
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
statSync(lockPath);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return { kind: "retry", observed: null };
|
|
282
|
+
}
|
|
283
|
+
return { kind: "refuse", observed: null };
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Drop this process's lock only when the generation we published is still at
|
|
287
|
+
* the well-known path. Under fail-closed foreign reclaim, generations are not
|
|
288
|
+
* stolen while we hold the critical section, so a matching unlink cannot
|
|
289
|
+
* remove a successor published by another cooperating writer.
|
|
290
|
+
*/
|
|
291
|
+
function unlinkOwnedLock(lockPath, stamp) {
|
|
292
|
+
unlinkIfMatchingOwner(lockPath, stamp);
|
|
293
|
+
}
|
|
155
294
|
function withFileLock(lockName, fn) {
|
|
156
295
|
ensureDir();
|
|
157
296
|
const lockPath = join(dir, lockName);
|
|
158
297
|
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
159
298
|
while (true) {
|
|
299
|
+
const stamp = { pid: process.pid, at: Date.now() };
|
|
160
300
|
try {
|
|
161
|
-
|
|
301
|
+
// Exclusive create (`wx`) plus a write of the owner record. That is
|
|
302
|
+
// not one atomic publish of populated bytes — create and write still
|
|
303
|
+
// have an interval. The UTF-8 fast path keeps that interval in one
|
|
304
|
+
// native writeFileSync rather than two JS turns (openSync then write).
|
|
305
|
+
// Abandoned foreign/empty locks are refused (not renamed or unlinked).
|
|
306
|
+
writeFileSync(lockPath, JSON.stringify(stamp), {
|
|
307
|
+
encoding: "utf8",
|
|
308
|
+
flag: "wx",
|
|
309
|
+
});
|
|
162
310
|
try {
|
|
163
|
-
writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
164
311
|
return fn();
|
|
165
312
|
}
|
|
166
313
|
finally {
|
|
167
|
-
|
|
168
|
-
unlinkLock(lockPath);
|
|
314
|
+
unlinkOwnedLock(lockPath, stamp);
|
|
169
315
|
}
|
|
170
316
|
}
|
|
171
317
|
catch (err) {
|
|
172
318
|
const code = err.code;
|
|
173
319
|
if (!lockBusy(code))
|
|
174
320
|
throw err;
|
|
175
|
-
if (
|
|
176
|
-
|
|
321
|
+
if (tryReclaimSelfLock(lockPath)) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const { kind, observed } = classifyBlockingLock(lockPath);
|
|
325
|
+
if (kind === "self") {
|
|
326
|
+
if (tryReclaimSelfLock(lockPath))
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (kind === "refuse") {
|
|
330
|
+
throw abandonedLockError(lockName, lockPath, observed);
|
|
331
|
+
}
|
|
332
|
+
if (kind === "retry") {
|
|
333
|
+
// Observation changed (released or superseded). Retry exclusive
|
|
334
|
+
// create within the original budget — do not reset the deadline on
|
|
335
|
+
// generation churn (unbounded handoffs become a timeout).
|
|
336
|
+
if (Date.now() >= deadline) {
|
|
337
|
+
throw new Error(`timed out waiting for ${lockName}`);
|
|
338
|
+
}
|
|
339
|
+
sleepSync(10);
|
|
177
340
|
continue;
|
|
178
341
|
}
|
|
179
342
|
if (Date.now() >= deadline) {
|