@zswarm/core 0.2.2 → 0.2.4
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/assets/host-install.sh +36 -2
- package/assets/host-report.sh +15 -1
- package/assets/slot.py +35 -1
- package/dist/harness.d.ts +20 -0
- package/dist/harness.js +64 -1
- package/dist/host/cli.js +78 -4
- package/dist/host/crew.d.ts +2 -0
- package/dist/host/crew.js +13 -0
- package/dist/host/host.js +13 -0
- package/dist/host/lease.d.ts +68 -0
- package/dist/host/lease.js +138 -0
- package/dist/host/relay.d.ts +80 -1
- package/dist/host/relay.js +291 -9
- package/dist/host/services.d.ts +9 -2
- package/dist/host/services.js +105 -9
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/ops/delivery.d.ts +51 -2
- package/dist/ops/delivery.js +162 -31
- package/dist/ops/dispatch.js +151 -14
- package/dist/ops/util.d.ts +9 -0
- package/dist/ops/util.js +24 -0
- package/dist/schema.js +50 -3
- package/dist/state.d.ts +20 -0
- package/dist/state.js +20 -0
- package/dist/zellij/args.d.ts +1 -1
- package/dist/zellij/args.js +3 -1
- package/dist/zellij/bus.js +1 -0
- package/dist/zellij/client.d.ts +2 -0
- package/dist/zellij/client.js +17 -1
- package/dist/zellij/panes.d.ts +1 -0
- package/dist/zellij/panes.js +3 -0
- package/dist/zellij/session.d.ts +11 -0
- package/dist/zellij/session.js +35 -3
- package/package.json +1 -1
package/dist/host/relay.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OpsResult } from "../ops/types.js";
|
|
2
|
+
import { type StateStore } from "../state.js";
|
|
2
3
|
/**
|
|
3
4
|
* `zswarm relay`: hand a body to a pane on another host and tell a local pane
|
|
4
5
|
* when the work is done, without anyone polling.
|
|
@@ -16,7 +17,14 @@ import type { OpsResult } from "../ops/types.js";
|
|
|
16
17
|
* message.txt. It reads nothing else, so moving or updating the caller's files
|
|
17
18
|
* while it waits cannot break it.
|
|
18
19
|
*/
|
|
19
|
-
export type RelayStatus = "DONE" | "TIMEOUT" | "PANE_DIED" | "SERVE_DOWN" | "UNCONFIRMED";
|
|
20
|
+
export type RelayStatus = "DONE" | "TIMEOUT" | "MAX_AGE" | "CHECK_BROKEN" | "CANCELLED" | "PANE_DIED" | "SERVE_DOWN" | "UNCONFIRMED";
|
|
21
|
+
/** How a detached relay can be stopped: the launchd label, systemd unit, or pid. */
|
|
22
|
+
export type RelayJob = {
|
|
23
|
+
kind: "launchd" | "systemd" | "pid";
|
|
24
|
+
label?: string;
|
|
25
|
+
unit?: string;
|
|
26
|
+
pid?: number;
|
|
27
|
+
};
|
|
20
28
|
export type RelayConfig = {
|
|
21
29
|
id: string;
|
|
22
30
|
serve: string;
|
|
@@ -28,6 +36,8 @@ export type RelayConfig = {
|
|
|
28
36
|
replySession?: string;
|
|
29
37
|
from: string;
|
|
30
38
|
timeoutMin: number;
|
|
39
|
+
/** Hard cap on the relay's age; defaults to `timeoutMin` plus 60 minutes. */
|
|
40
|
+
maxAgeMin?: number;
|
|
31
41
|
check?: string;
|
|
32
42
|
message?: string;
|
|
33
43
|
/**
|
|
@@ -35,6 +45,15 @@ export type RelayConfig = {
|
|
|
35
45
|
* pasted body, an earlier task's done line). Only a line beyond these counts.
|
|
36
46
|
*/
|
|
37
47
|
seen?: string[];
|
|
48
|
+
/** Recorded by launchRelay so `--cancel` can stop the detached job. */
|
|
49
|
+
job?: RelayJob;
|
|
50
|
+
/** Final status once the loop ends; absent while the relay runs. */
|
|
51
|
+
status?: RelayStatus;
|
|
52
|
+
/** Set by `--cancel`; a late loop must not overwrite it. */
|
|
53
|
+
cancelled?: boolean;
|
|
54
|
+
cancelledAt?: number;
|
|
55
|
+
/** The pane lease this relay holds; released on every final status. */
|
|
56
|
+
leaseKey?: string;
|
|
38
57
|
};
|
|
39
58
|
export type RelayDeps = {
|
|
40
59
|
/** dispatchZswarm with an env; injected in tests. */
|
|
@@ -82,9 +101,17 @@ export type RelayStartInput = {
|
|
|
82
101
|
check?: string;
|
|
83
102
|
message?: string;
|
|
84
103
|
timeoutMin?: number;
|
|
104
|
+
/** Hard cap on the relay's age; defaults to `timeoutMin` plus 60 minutes. */
|
|
105
|
+
maxAgeMin?: number;
|
|
85
106
|
foreground?: boolean;
|
|
107
|
+
/** Wait for a leased pane to free instead of failing. */
|
|
108
|
+
queue?: boolean;
|
|
109
|
+
/** Send to a leased pane anyway; the lease is shared and noted. */
|
|
110
|
+
force?: boolean;
|
|
86
111
|
env?: NodeJS.ProcessEnv;
|
|
87
112
|
deps?: RelayDeps;
|
|
113
|
+
/** State store for the pane lease; injected in tests. */
|
|
114
|
+
store?: StateStore;
|
|
88
115
|
/** Starts the detached relay; injected in tests. */
|
|
89
116
|
launch?: (dir: string, env: NodeJS.ProcessEnv) => string;
|
|
90
117
|
};
|
|
@@ -112,5 +139,57 @@ export declare function waitForRelay(id: string, opts?: {
|
|
|
112
139
|
sleep?: (ms: number) => Promise<void>;
|
|
113
140
|
now?: () => number;
|
|
114
141
|
}): Promise<string | null>;
|
|
142
|
+
export type RelayJobRunner = (cmd: string, args: string[]) => {
|
|
143
|
+
status: number;
|
|
144
|
+
stdout: string;
|
|
145
|
+
stderr?: string;
|
|
146
|
+
};
|
|
147
|
+
export type RelayAdminDeps = {
|
|
148
|
+
env?: NodeJS.ProcessEnv;
|
|
149
|
+
now?: () => number;
|
|
150
|
+
/** State store for the pane leases; injected in tests. */
|
|
151
|
+
store?: StateStore;
|
|
152
|
+
/** Runs launchctl/systemctl; injected in tests. */
|
|
153
|
+
run?: RelayJobRunner;
|
|
154
|
+
/** Liveness probe for a plain detached pid; injected in tests. */
|
|
155
|
+
alive?: (pid: number) => boolean;
|
|
156
|
+
/** Sends SIGTERM to a plain detached pid; injected in tests. */
|
|
157
|
+
kill?: (pid: number) => void;
|
|
158
|
+
/** A pid's command line (undefined when unknown); injected in tests. */
|
|
159
|
+
commandLine?: (pid: number) => string | undefined;
|
|
160
|
+
};
|
|
161
|
+
export type RelayRow = {
|
|
162
|
+
id: string;
|
|
163
|
+
dir: string;
|
|
164
|
+
to: string;
|
|
165
|
+
session?: string;
|
|
166
|
+
/** Epoch milliseconds the run started (the `<ts>` in its directory name). */
|
|
167
|
+
started: number;
|
|
168
|
+
/** Seconds since it started. */
|
|
169
|
+
age: number;
|
|
170
|
+
state: string;
|
|
171
|
+
lastCheck?: number;
|
|
172
|
+
/** The launchd label / systemd unit / pid, when the job is still loaded. */
|
|
173
|
+
job?: string;
|
|
174
|
+
/** The live pane lease held for this relay's target, if any. */
|
|
175
|
+
lease?: {
|
|
176
|
+
relayId: string;
|
|
177
|
+
from: string;
|
|
178
|
+
at: number;
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
export type RelayCancelResult = {
|
|
182
|
+
id: string;
|
|
183
|
+
cancelled: boolean;
|
|
184
|
+
state: string;
|
|
185
|
+
reason: string;
|
|
186
|
+
actions: string[];
|
|
187
|
+
};
|
|
188
|
+
/** One row per relay run directory; newest first. */
|
|
189
|
+
export declare function listRelays(deps?: RelayAdminDeps): RelayRow[];
|
|
190
|
+
/** Stop a running relay's job, mark its directory CANCELLED and scrub its token. */
|
|
191
|
+
export declare function cancelRelay(id: string, deps?: RelayAdminDeps): RelayCancelResult;
|
|
192
|
+
/** Cancel every running relay that targets `to`. */
|
|
193
|
+
export declare function cancelAllTo(to: string, deps?: RelayAdminDeps): RelayCancelResult[];
|
|
115
194
|
/** Remove relay directories and undelivered messages older than `days`. */
|
|
116
195
|
export declare function pruneRelays(days?: number, env?: NodeJS.ProcessEnv): string[];
|
package/dist/host/relay.js
CHANGED
|
@@ -4,15 +4,22 @@ import { basename, join } from "node:path";
|
|
|
4
4
|
import { ZellijError } from "../errors.js";
|
|
5
5
|
import { dispatchZswarm } from "../ops/dispatch.js";
|
|
6
6
|
import { resolveServeCliLaunch } from "../ops/serve-install.js";
|
|
7
|
-
import { defaultStateDir } from "../state.js";
|
|
7
|
+
import { createStateStore, defaultStateDir } from "../state.js";
|
|
8
|
+
import { findLease, leaseKey, releaseLeaseKey, takeLease } from "./lease.js";
|
|
8
9
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
9
10
|
/** Spread over about an hour: Zellij on a loaded machine can stall for minutes. */
|
|
10
11
|
const DEFAULT_DELAYS = [0, 30, 60, 120, 300, 600, 900, 1500];
|
|
11
12
|
/** Consecutive matches of the done regex without a passing check before UNCONFIRMED. */
|
|
12
13
|
const CONFIRM_TRIES = 3;
|
|
13
14
|
const SERVE_DOWN_TRIES = 3;
|
|
15
|
+
/** Consecutive identical failing checks before CHECK_BROKEN: a check that can never pass. */
|
|
16
|
+
const CHECK_BROKEN_TRIES = 10;
|
|
17
|
+
/** How long a relay may outlive `--timeout-min` when `--max-age` is not given. */
|
|
18
|
+
const DEFAULT_MAX_AGE_EXTRA_MIN = 60;
|
|
14
19
|
/** A wait that only finds `seen` lines returns at once; poll no faster than this. */
|
|
15
20
|
const STALE_MATCH_POLL_MS = 15_000;
|
|
21
|
+
/** `relay --queue` re-checks a leased pane this often. */
|
|
22
|
+
const LEASE_QUEUE_POLL_MS = 5_000;
|
|
16
23
|
export function relaysDir(env = process.env) {
|
|
17
24
|
return join(defaultStateDir(env), "relays");
|
|
18
25
|
}
|
|
@@ -78,6 +85,9 @@ export function fillMessage(template, fields) {
|
|
|
78
85
|
const DEFAULT_TEMPLATE = "RELAY {id} STATUS {status} pane {pane}\n{output}{note}\n";
|
|
79
86
|
const NOTES = {
|
|
80
87
|
TIMEOUT: "no done line or passing check before the deadline; dump the pane once, do not resend blindly.",
|
|
88
|
+
MAX_AGE: "the relay reached --max-age without its done line; check the target pane and --done before resending.",
|
|
89
|
+
CHECK_BROKEN: "the check script failed the same way ten times in a row; fix the check (or --done) before resending.",
|
|
90
|
+
CANCELLED: "the relay was cancelled before it finished; nothing was pasted.",
|
|
81
91
|
PANE_DIED: "the target pane is gone or exited; check the crew (zswarm crew status) before resending.",
|
|
82
92
|
SERVE_DOWN: "serve was unreachable for three checks in a row; run zswarm doctor against it.",
|
|
83
93
|
UNCONFIRMED: "the pane printed the done line but the check never passed; dump the pane once for the error.",
|
|
@@ -130,12 +140,20 @@ export async function relayLoop(dir, deps = {}) {
|
|
|
130
140
|
ZSWARM_RELAY_PANE: cfg.to,
|
|
131
141
|
ZSWARM_RELAY_DIR: dir,
|
|
132
142
|
});
|
|
133
|
-
|
|
143
|
+
// The timeout is the normal end; --max-age (default timeout + 60 min) is the
|
|
144
|
+
// hard cap that still ends the relay when a wait or the serve-down backoff
|
|
145
|
+
// overruns the timeout.
|
|
146
|
+
const startedAtMs = now();
|
|
147
|
+
const timeoutAt = startedAtMs + cfg.timeoutMin * 60_000;
|
|
148
|
+
const maxAgeAt = startedAtMs + (cfg.maxAgeMin ?? cfg.timeoutMin + DEFAULT_MAX_AGE_EXTRA_MIN) * 60_000;
|
|
149
|
+
const deadline = Math.min(timeoutAt, maxAgeAt);
|
|
134
150
|
let status = "TIMEOUT";
|
|
135
151
|
let output = "";
|
|
136
152
|
let match = "";
|
|
137
153
|
let confirmTries = 0;
|
|
138
154
|
let serveDown = 0;
|
|
155
|
+
let lastCheckFail;
|
|
156
|
+
let sameCheckFails = 0;
|
|
139
157
|
log(`relay start id=${cfg.id} to=${cfg.to} serve=${cfg.serve} done=${cfg.done}`);
|
|
140
158
|
while (now() < deadline) {
|
|
141
159
|
const started = now();
|
|
@@ -163,6 +181,20 @@ export async function relayLoop(dir, deps = {}) {
|
|
|
163
181
|
output = r.stdout;
|
|
164
182
|
break;
|
|
165
183
|
}
|
|
184
|
+
// The same failure over and over means the check can never pass for this
|
|
185
|
+
// pane: end as CHECK_BROKEN rather than polling to the timeout.
|
|
186
|
+
const sig = `${r.status}:${r.stdout}`;
|
|
187
|
+
if (sig === lastCheckFail)
|
|
188
|
+
sameCheckFails += 1;
|
|
189
|
+
else {
|
|
190
|
+
lastCheckFail = sig;
|
|
191
|
+
sameCheckFails = 1;
|
|
192
|
+
}
|
|
193
|
+
if (sameCheckFails >= CHECK_BROKEN_TRIES) {
|
|
194
|
+
status = "CHECK_BROKEN";
|
|
195
|
+
output = r.stdout;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
166
198
|
}
|
|
167
199
|
else {
|
|
168
200
|
log(`wait ok=${res.ok} matched=${matched}${stale ? " (seen before start)" : ""}`);
|
|
@@ -207,6 +239,9 @@ export async function relayLoop(dir, deps = {}) {
|
|
|
207
239
|
if (matched)
|
|
208
240
|
await sleep(30_000); // the done line stays on screen; give the check time
|
|
209
241
|
}
|
|
242
|
+
if (status === "TIMEOUT" && maxAgeAt <= timeoutAt && now() >= maxAgeAt)
|
|
243
|
+
status = "MAX_AGE";
|
|
244
|
+
writeFinalStatus(dir, status);
|
|
210
245
|
let body;
|
|
211
246
|
if (status !== "DONE" && check) {
|
|
212
247
|
const r = runCheck(check, checkEnv(status, match));
|
|
@@ -269,6 +304,18 @@ function updateConfig(dir, patch) {
|
|
|
269
304
|
const cfg = { ...JSON.parse(readFileSync(path, "utf8")), ...patch };
|
|
270
305
|
writePrivate(path, `${JSON.stringify(cfg, null, 1)}\n`);
|
|
271
306
|
}
|
|
307
|
+
/** Persist the final status unless `--cancel` already marked the relay. */
|
|
308
|
+
function writeFinalStatus(dir, status) {
|
|
309
|
+
try {
|
|
310
|
+
const cfg = JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
|
|
311
|
+
if (cfg.cancelled || cfg.status === "CANCELLED")
|
|
312
|
+
return;
|
|
313
|
+
updateConfig(dir, { status });
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
// the directory is gone; nothing to record
|
|
317
|
+
}
|
|
318
|
+
}
|
|
272
319
|
/** Remove the token copy once the relay no longer needs it. */
|
|
273
320
|
export function scrubRelayToken(dir) {
|
|
274
321
|
const path = join(dir, "config.json");
|
|
@@ -298,6 +345,7 @@ export function launchRelay(dir, env = process.env) {
|
|
|
298
345
|
], { encoding: "utf8" });
|
|
299
346
|
if (res.status !== 0)
|
|
300
347
|
throw new ZellijError("failed", `launchctl submit failed: ${(res.stderr ?? "").trim().slice(0, 200)}`);
|
|
348
|
+
updateConfig(dir, { job: { kind: "launchd", label } });
|
|
301
349
|
return `launchd job ${label}`;
|
|
302
350
|
}
|
|
303
351
|
if (process.platform === "linux" && spawnSync("systemctl", ["--user", "is-system-running"], { encoding: "utf8" }).status !== null) {
|
|
@@ -307,12 +355,16 @@ export function launchRelay(dir, env = process.env) {
|
|
|
307
355
|
...(env.ZSWARM_STATE_DIR ? [`--setenv=ZSWARM_STATE_DIR=${env.ZSWARM_STATE_DIR}`] : []),
|
|
308
356
|
...(env.ZSWARM_RELAY_DELAYS ? [`--setenv=ZSWARM_RELAY_DELAYS=${env.ZSWARM_RELAY_DELAYS}`] : []),
|
|
309
357
|
...argv], { encoding: "utf8" });
|
|
310
|
-
if (res.status === 0)
|
|
358
|
+
if (res.status === 0) {
|
|
359
|
+
updateConfig(dir, { job: { kind: "systemd", unit } });
|
|
311
360
|
return `systemd unit ${unit}`;
|
|
361
|
+
}
|
|
312
362
|
}
|
|
313
363
|
const out = openLog(log);
|
|
314
364
|
const child = spawn(argv[0], argv.slice(1), { detached: true, stdio: ["ignore", out, out], env, windowsHide: true });
|
|
315
365
|
child.unref();
|
|
366
|
+
if (child.pid)
|
|
367
|
+
updateConfig(dir, { job: { kind: "pid", pid: child.pid } });
|
|
316
368
|
return `pid ${child.pid}`;
|
|
317
369
|
}
|
|
318
370
|
/** A file descriptor the detached child can inherit. */
|
|
@@ -340,16 +392,40 @@ export async function startRelay(input) {
|
|
|
340
392
|
replySession: input.replySession?.trim() || env.ZSWARM_SESSION?.trim() || env.ZELLIJ_SESSION_NAME?.trim() || undefined,
|
|
341
393
|
from: input.from?.trim() || "zswarm-relay",
|
|
342
394
|
timeoutMin: input.timeoutMin ?? 240,
|
|
395
|
+
maxAgeMin: input.maxAgeMin ?? (input.timeoutMin ?? 240) + DEFAULT_MAX_AGE_EXTRA_MIN,
|
|
343
396
|
};
|
|
344
397
|
// Read the body before the relay directory (and its token copy) exists.
|
|
345
398
|
const body = input.bodyFile ? readFileSync(input.bodyFile, "utf8") : undefined;
|
|
346
399
|
const dir = prepareRelay(cfg, { check: input.check, message: input.message }, env);
|
|
347
|
-
const
|
|
348
|
-
const
|
|
349
|
-
const target = (args) => cfg.session ? { ...args, session: cfg.session } : args;
|
|
400
|
+
const store = input.store ?? createStateStore({ env });
|
|
401
|
+
const leaseTarget = { serve, session: cfg.session, pane: cfg.to };
|
|
350
402
|
let sent;
|
|
351
403
|
let warning;
|
|
352
404
|
let how;
|
|
405
|
+
const leaseNow = input.deps?.now ?? Date.now;
|
|
406
|
+
const leaseSleep = input.deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
407
|
+
const take = () => takeLease(store, { relayId: id, from: cfg.from, at: leaseNow(), dir, target: leaseTarget, force: input.force });
|
|
408
|
+
const leaseKeyValue = leaseKey(leaseTarget);
|
|
409
|
+
let taken = take();
|
|
410
|
+
if (!taken.ok && input.queue) {
|
|
411
|
+
const deadline = leaseNow() + cfg.timeoutMin * 60_000;
|
|
412
|
+
while (!taken.ok && leaseNow() < deadline) {
|
|
413
|
+
await leaseSleep(Math.min(LEASE_QUEUE_POLL_MS, Math.max(1, deadline - leaseNow())));
|
|
414
|
+
taken = take();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!taken.ok) {
|
|
418
|
+
rmSync(dir, { recursive: true, force: true });
|
|
419
|
+
const holder = taken.holder;
|
|
420
|
+
throw new ZellijError("pane_leased", `pane ${cfg.to} is leased by relay ${holder.relayId} (from ${holder.from}, since ${new Date(holder.at).toISOString()})${input.queue ? " and did not free before --timeout-min" : "; use --queue to wait or --force to share it"}`, { relayId: holder.relayId, from: holder.from, at: holder.at, dir: holder.dir });
|
|
421
|
+
}
|
|
422
|
+
updateConfig(dir, { leaseKey: leaseKeyValue });
|
|
423
|
+
if (taken.shared) {
|
|
424
|
+
warning = `pane ${cfg.to} is already leased by relay ${taken.lease.relayId}; --force shares its lease`;
|
|
425
|
+
}
|
|
426
|
+
const call = input.deps?.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
|
|
427
|
+
const remote = serveEnv(cfg, env);
|
|
428
|
+
const target = (args) => cfg.session ? { ...args, session: cfg.session } : args;
|
|
353
429
|
try {
|
|
354
430
|
if (body !== undefined) {
|
|
355
431
|
const res = await call(target({ op: "send", to: cfg.to, body, from: cfg.from, settleMs: 800 }), remote);
|
|
@@ -368,7 +444,8 @@ export async function startRelay(input) {
|
|
|
368
444
|
}
|
|
369
445
|
}
|
|
370
446
|
if (doneLines(body, cfg.done).length > 0) {
|
|
371
|
-
|
|
447
|
+
const note = "--done matches a line of the body; that line is ignored while it stays on screen, so an identical done line is only seen once it scrolls off";
|
|
448
|
+
warning = warning ? `${warning}; ${note}` : note;
|
|
372
449
|
}
|
|
373
450
|
}
|
|
374
451
|
// Whatever already matches --done (the echoed body, an earlier task's done
|
|
@@ -381,21 +458,32 @@ export async function startRelay(input) {
|
|
|
381
458
|
how = (input.launch ?? launchRelay)(dir, env);
|
|
382
459
|
}
|
|
383
460
|
catch (err) {
|
|
384
|
-
// Nothing will run from this directory: take the token copy with it.
|
|
461
|
+
// Nothing will run from this directory: take the lease and token copy with it.
|
|
462
|
+
releaseLeaseKey(store, leaseKeyValue, id);
|
|
385
463
|
rmSync(dir, { recursive: true, force: true });
|
|
386
464
|
throw err;
|
|
387
465
|
}
|
|
388
466
|
const extra = warning ? { warning } : {};
|
|
389
467
|
if (input.foreground) {
|
|
390
|
-
const result = await relayLoop(dir, input.deps).finally(() =>
|
|
468
|
+
const result = await relayLoop(dir, input.deps).finally(() => {
|
|
469
|
+
releaseLeaseKey(store, leaseKeyValue, id);
|
|
470
|
+
scrubRelayToken(dir);
|
|
471
|
+
});
|
|
391
472
|
return { id, dir, sent: sent ?? null, ...extra, ...result };
|
|
392
473
|
}
|
|
393
474
|
return { id, dir, sent: sent ?? null, ...extra, relay: how };
|
|
394
475
|
}
|
|
395
476
|
/** What `zswarm relay --run <dir>` does inside the detached job. */
|
|
396
477
|
export async function runRelayDir(dir, deps = {}) {
|
|
478
|
+
const store = createStateStore({ env: deps.env ?? process.env });
|
|
479
|
+
const releaseLease = () => {
|
|
480
|
+
const cfg = readRelayConfig(dir);
|
|
481
|
+
if (cfg?.leaseKey && cfg.id)
|
|
482
|
+
releaseLeaseKey(store, cfg.leaseKey, cfg.id);
|
|
483
|
+
};
|
|
397
484
|
const stop = (sig) => () => {
|
|
398
485
|
appendFileSync(join(dir, "relay.log"), `${new Date().toISOString()} relay stopped by ${sig}\n`);
|
|
486
|
+
releaseLease();
|
|
399
487
|
scrubRelayToken(dir);
|
|
400
488
|
process.exit(143);
|
|
401
489
|
};
|
|
@@ -409,6 +497,7 @@ export async function runRelayDir(dir, deps = {}) {
|
|
|
409
497
|
code = res.delivered ? 0 : 3;
|
|
410
498
|
}
|
|
411
499
|
finally {
|
|
500
|
+
releaseLease();
|
|
412
501
|
scrubRelayToken(dir);
|
|
413
502
|
process.off("SIGTERM", onTerm);
|
|
414
503
|
process.off("SIGHUP", onHup);
|
|
@@ -454,6 +543,199 @@ export async function waitForRelay(id, opts = {}) {
|
|
|
454
543
|
await sleep(opts.pollMs ?? 15_000);
|
|
455
544
|
}
|
|
456
545
|
}
|
|
546
|
+
/** A pid's command line: `ps` on POSIX, CIM on Windows. */
|
|
547
|
+
function defaultCommandLine(pid) {
|
|
548
|
+
const res = process.platform === "win32"
|
|
549
|
+
? spawnSync("powershell.exe", ["-NoProfile", "-Command",
|
|
550
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`], { encoding: "utf8", windowsHide: true })
|
|
551
|
+
: spawnSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8" });
|
|
552
|
+
const text = res.status === 0 ? (res.stdout ?? "").trim() : "";
|
|
553
|
+
return text || undefined;
|
|
554
|
+
}
|
|
555
|
+
const defaultJobRunner = (cmd, args) => {
|
|
556
|
+
const res = spawnSync(cmd, args, { encoding: "utf8" });
|
|
557
|
+
return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? (res.error?.message ?? "") };
|
|
558
|
+
};
|
|
559
|
+
/** The run directories under relaysDir, with the start time from each name. */
|
|
560
|
+
function runDirs(root) {
|
|
561
|
+
if (!existsSync(root))
|
|
562
|
+
return [];
|
|
563
|
+
const out = [];
|
|
564
|
+
for (const name of readdirSync(root)) {
|
|
565
|
+
if (name === "undelivered")
|
|
566
|
+
continue;
|
|
567
|
+
const path = join(root, name);
|
|
568
|
+
let isDir = false;
|
|
569
|
+
try {
|
|
570
|
+
isDir = statSync(path).isDirectory();
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (!isDir)
|
|
576
|
+
continue;
|
|
577
|
+
const m = /^(.*)-(\d+)$/.exec(name);
|
|
578
|
+
out.push({
|
|
579
|
+
id: m ? m[1] : name,
|
|
580
|
+
dir: path,
|
|
581
|
+
started: (m ? Number(m[2]) : Math.floor(statSync(path).mtimeMs / 1000)) * 1000,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
return out;
|
|
585
|
+
}
|
|
586
|
+
function readRelayConfig(dir) {
|
|
587
|
+
try {
|
|
588
|
+
return JSON.parse(readFileSync(join(dir, "config.json"), "utf8"));
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
/** The state a relay dir already records: a cancel marker, a final status, a message, else running. */
|
|
595
|
+
function relayState(dir, cfg) {
|
|
596
|
+
if (cfg?.cancelled || cfg?.status === "CANCELLED")
|
|
597
|
+
return "cancelled";
|
|
598
|
+
if (cfg?.status)
|
|
599
|
+
return cfg.status.toLowerCase();
|
|
600
|
+
const message = join(dir, "message.txt");
|
|
601
|
+
if (existsSync(message)) {
|
|
602
|
+
const m = /^\s*RELAY \S+ STATUS (\S+)/m.exec(readFileSync(message, "utf8"));
|
|
603
|
+
return (m?.[1] ?? "done").toLowerCase();
|
|
604
|
+
}
|
|
605
|
+
return "running";
|
|
606
|
+
}
|
|
607
|
+
/** The last `check=<n>` a relay logged, if any. */
|
|
608
|
+
function lastCheckResult(dir) {
|
|
609
|
+
try {
|
|
610
|
+
const matches = [...readFileSync(join(dir, "relay.log"), "utf8").matchAll(/check=(-?\d+)/g)];
|
|
611
|
+
const last = matches.at(-1)?.[1];
|
|
612
|
+
return last === undefined ? undefined : Number(last);
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function jobLabelText(job) {
|
|
619
|
+
if (job.kind === "launchd")
|
|
620
|
+
return `launchd job ${job.label}`;
|
|
621
|
+
if (job.kind === "systemd")
|
|
622
|
+
return `systemd unit ${job.unit}`;
|
|
623
|
+
return `pid ${job.pid}`;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* True while the recorded job is still loaded (launchd/systemd), or while the
|
|
627
|
+
* pid is alive and its command line still names this relay's directory (a
|
|
628
|
+
* relay that died without a final status leaves a pid another process may reuse).
|
|
629
|
+
*/
|
|
630
|
+
function jobLoaded(job, deps, dir) {
|
|
631
|
+
const run = deps.run ?? defaultJobRunner;
|
|
632
|
+
if (job.kind === "launchd" && job.label)
|
|
633
|
+
return run("launchctl", ["list", job.label]).status === 0;
|
|
634
|
+
if (job.kind === "systemd" && job.unit) {
|
|
635
|
+
const res = run("systemctl", ["--user", "is-active", job.unit]);
|
|
636
|
+
return res.status === 0 && /^(active|activating|reloading)$/.test(res.stdout.trim());
|
|
637
|
+
}
|
|
638
|
+
if (job.kind === "pid" && job.pid) {
|
|
639
|
+
const alive = deps.alive ?? ((pid) => (process.kill(pid, 0), true));
|
|
640
|
+
try {
|
|
641
|
+
if (!alive(job.pid))
|
|
642
|
+
return false;
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return false;
|
|
646
|
+
}
|
|
647
|
+
return (deps.commandLine ?? defaultCommandLine)(job.pid)?.includes(dir) ?? false;
|
|
648
|
+
}
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
/** One row per relay run directory; newest first. */
|
|
652
|
+
export function listRelays(deps = {}) {
|
|
653
|
+
const env = deps.env ?? process.env;
|
|
654
|
+
const now = deps.now ?? Date.now;
|
|
655
|
+
const store = deps.store ?? createStateStore({ env });
|
|
656
|
+
const rows = [];
|
|
657
|
+
for (const run of runDirs(relaysDir(env))) {
|
|
658
|
+
const cfg = readRelayConfig(run.dir);
|
|
659
|
+
if (!cfg)
|
|
660
|
+
continue;
|
|
661
|
+
const loaded = cfg.job ? jobLoaded(cfg.job, deps, run.dir) : false;
|
|
662
|
+
const held = findLease(store, { serve: cfg.serve, session: cfg.session, pane: cfg.to }, { now });
|
|
663
|
+
rows.push({
|
|
664
|
+
id: cfg.id || run.id,
|
|
665
|
+
dir: run.dir,
|
|
666
|
+
to: cfg.to,
|
|
667
|
+
session: cfg.session,
|
|
668
|
+
started: run.started,
|
|
669
|
+
age: Math.max(0, Math.floor((now() - run.started) / 1000)),
|
|
670
|
+
state: relayState(run.dir, cfg),
|
|
671
|
+
lastCheck: lastCheckResult(run.dir),
|
|
672
|
+
...(loaded && cfg.job ? { job: jobLabelText(cfg.job) } : {}),
|
|
673
|
+
...(held ? { lease: { relayId: held.relayId, from: held.from, at: held.at } } : {}),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
return rows.sort((a, b) => b.started - a.started);
|
|
677
|
+
}
|
|
678
|
+
function stopJob(job, deps) {
|
|
679
|
+
const run = deps.run ?? defaultJobRunner;
|
|
680
|
+
if (job.kind === "launchd" && job.label) {
|
|
681
|
+
const res = run("launchctl", ["remove", job.label]);
|
|
682
|
+
if (res.status !== 0)
|
|
683
|
+
throw new ZellijError("failed", `launchctl remove ${job.label} failed: ${(res.stderr || res.stdout).trim().slice(0, 200)}`);
|
|
684
|
+
}
|
|
685
|
+
else if (job.kind === "systemd" && job.unit) {
|
|
686
|
+
const res = run("systemctl", ["--user", "stop", job.unit]);
|
|
687
|
+
if (res.status !== 0)
|
|
688
|
+
throw new ZellijError("failed", `systemctl --user stop ${job.unit} failed: ${(res.stderr || res.stdout).trim().slice(0, 200)}`);
|
|
689
|
+
}
|
|
690
|
+
else if (job.kind === "pid" && job.pid) {
|
|
691
|
+
const kill = deps.kill ?? ((pid) => { process.kill(pid, "SIGTERM"); });
|
|
692
|
+
kill(job.pid);
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
return "no job recorded; nothing to stop";
|
|
696
|
+
}
|
|
697
|
+
return `stopped ${jobLabelText(job)}`;
|
|
698
|
+
}
|
|
699
|
+
/** Stop a running relay's job, mark its directory CANCELLED and scrub its token. */
|
|
700
|
+
export function cancelRelay(id, deps = {}) {
|
|
701
|
+
const env = deps.env ?? process.env;
|
|
702
|
+
const now = deps.now ?? Date.now;
|
|
703
|
+
const match = runDirs(relaysDir(env))
|
|
704
|
+
.filter((r) => r.id === id)
|
|
705
|
+
.sort((a, b) => b.started - a.started)[0];
|
|
706
|
+
if (!match)
|
|
707
|
+
throw new ZellijError("usage", `no relay ${id}`);
|
|
708
|
+
const cfg = readRelayConfig(match.dir);
|
|
709
|
+
if (!cfg)
|
|
710
|
+
throw new ZellijError("failed", `relay ${id} has no config.json`);
|
|
711
|
+
const state = relayState(match.dir, cfg);
|
|
712
|
+
if (state !== "running") {
|
|
713
|
+
return { id, cancelled: false, state, reason: `relay ${id} is already ${state}; nothing to cancel`, actions: [] };
|
|
714
|
+
}
|
|
715
|
+
// Stop only a job that is still this relay's: a crashed relay's label may be
|
|
716
|
+
// gone and its pid reused. Either way the directory is marked cancelled.
|
|
717
|
+
const actions = [
|
|
718
|
+
!cfg.job
|
|
719
|
+
? "no job recorded; nothing to stop"
|
|
720
|
+
: jobLoaded(cfg.job, deps, match.dir)
|
|
721
|
+
? stopJob(cfg.job, deps)
|
|
722
|
+
: `${jobLabelText(cfg.job)} is no longer running this relay; nothing to stop`,
|
|
723
|
+
];
|
|
724
|
+
updateConfig(match.dir, { cancelled: true, status: "CANCELLED", cancelledAt: now() });
|
|
725
|
+
if (cfg.leaseKey && releaseLeaseKey(deps.store ?? createStateStore({ env }), cfg.leaseKey, cfg.id)) {
|
|
726
|
+
actions.push(`released the pane lease for ${cfg.to}`);
|
|
727
|
+
}
|
|
728
|
+
scrubRelayToken(match.dir);
|
|
729
|
+
actions.push(`${id} marked CANCELLED (nothing pasted)`);
|
|
730
|
+
return { id, cancelled: true, state: "cancelled", reason: `cancelled relay ${id}`, actions };
|
|
731
|
+
}
|
|
732
|
+
/** Cancel every running relay that targets `to`. */
|
|
733
|
+
export function cancelAllTo(to, deps = {}) {
|
|
734
|
+
const env = deps.env ?? process.env;
|
|
735
|
+
return listRelays({ ...deps, env })
|
|
736
|
+
.filter((row) => row.to === to && row.state === "running")
|
|
737
|
+
.map((row) => cancelRelay(row.id, { ...deps, env }));
|
|
738
|
+
}
|
|
457
739
|
/** Remove relay directories and undelivered messages older than `days`. */
|
|
458
740
|
export function pruneRelays(days = 14, env = process.env) {
|
|
459
741
|
const root = relaysDir(env);
|
package/dist/host/services.d.ts
CHANGED
|
@@ -48,8 +48,13 @@ export type Runner = (cmd: string, args: string[]) => RunResult;
|
|
|
48
48
|
export type ServiceState = {
|
|
49
49
|
name: string;
|
|
50
50
|
unit: string;
|
|
51
|
+
/** Human summary: `running (pid 42)`, `timer every 120 s, loaded`, `not loaded`. */
|
|
51
52
|
state: string;
|
|
52
53
|
active: boolean;
|
|
54
|
+
/** Main pid when the platform reports one (daemons). */
|
|
55
|
+
pid?: number;
|
|
56
|
+
/** Last exit status when the platform reports one. */
|
|
57
|
+
lastExit?: number;
|
|
53
58
|
};
|
|
54
59
|
export type ServiceHost = {
|
|
55
60
|
account: ServiceAccount;
|
|
@@ -61,6 +66,8 @@ export type ServiceHost = {
|
|
|
61
66
|
exists(path: string): boolean;
|
|
62
67
|
mkdir(path: string): void;
|
|
63
68
|
};
|
|
69
|
+
/** Synchronous sleep; injected in tests so the launchd wait is instant. */
|
|
70
|
+
sleep?: (ms: number) => void;
|
|
64
71
|
};
|
|
65
72
|
export declare const defaultRunner: Runner;
|
|
66
73
|
/** The account services are installed for: the current user, or `runAs` when root. */
|
|
@@ -87,7 +94,7 @@ export declare function ensureLinger(host: ServiceHost): {
|
|
|
87
94
|
note?: string;
|
|
88
95
|
};
|
|
89
96
|
export declare function launchdLabel(account: ServiceAccount, name: string): string;
|
|
90
|
-
export declare function launchdPlist(account: ServiceAccount, spec: ServiceSpec): string;
|
|
97
|
+
export declare function launchdPlist(account: ServiceAccount, spec: ServiceSpec, run?: Runner): string;
|
|
91
98
|
/** Write, (re)load and start the services. Returns what it did, one line per unit. */
|
|
92
99
|
export declare function installServices(host: ServiceHost, specs: ServiceSpec[]): {
|
|
93
100
|
lines: string[];
|
|
@@ -95,7 +102,7 @@ export declare function installServices(host: ServiceHost, specs: ServiceSpec[])
|
|
|
95
102
|
};
|
|
96
103
|
/** Stop and delete the services; missing ones are fine. */
|
|
97
104
|
export declare function removeServices(host: ServiceHost, specs: Array<Pick<ServiceSpec, "name" | "kind">>): string[];
|
|
98
|
-
export declare function serviceStates(host: ServiceHost, specs: Array<Pick<ServiceSpec, "name" | "kind">>): ServiceState[];
|
|
105
|
+
export declare function serviceStates(host: ServiceHost, specs: Array<Pick<ServiceSpec, "name" | "kind" | "intervalSec">>): ServiceState[];
|
|
99
106
|
/** Create dir and any missing parents, owned by the account when installing for it. */
|
|
100
107
|
export declare function ensureAccountDir(account: ServiceAccount, dir: string, mode?: number): void;
|
|
101
108
|
/** Write a mode-600 file for the account, creating its directory (700) as needed. */
|