@zswarm/core 0.2.3 → 0.2.5
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/slot.py +35 -1
- package/dist/harness.d.ts +10 -0
- package/dist/harness.js +34 -3
- package/dist/host/cli.js +41 -6
- package/dist/host/crew-windows.d.ts +155 -0
- package/dist/host/crew-windows.js +556 -0
- package/dist/host/crew.d.ts +32 -2
- package/dist/host/crew.js +212 -23
- package/dist/host/lease.d.ts +68 -0
- package/dist/host/lease.js +138 -0
- package/dist/host/relay.d.ts +17 -0
- package/dist/host/relay.js +52 -7
- package/dist/host/services.d.ts +7 -4
- package/dist/host/services.js +18 -4
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/ops/dispatch.js +69 -5
- package/dist/ops/util.d.ts +9 -0
- package/dist/ops/util.js +24 -0
- package/dist/schema.js +6 -0
- 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 +1 -0
- package/dist/zellij/client.js +1 -1
- package/dist/zellij/panes.d.ts +1 -0
- package/dist/zellij/panes.js +3 -0
- package/package.json +1 -1
package/dist/host/crew.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { posix } from "node:path";
|
|
3
|
+
import { dirname, posix, win32 } 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
7
|
import { defaultStateDir } from "../state.js";
|
|
8
8
|
import { resolveZellijBinary } from "../zellij/binary.js";
|
|
9
|
+
import { closeCrewClient, crewClientCommand, crewClientMarker, crewTaskCommand, defaultPowerShellExec, ensureCrewClient, inspectCrewTasks, installCrewTasks, removeCrewTasks, } from "./crew-windows.js";
|
|
9
10
|
import { defaultRunner, ensureAccountDir, installServices, removeServices, serviceAccount, serviceStates, writeAccountFile, writeSecretFile, } from "./services.js";
|
|
10
|
-
//
|
|
11
|
-
//
|
|
11
|
+
// Linux and macOS accounts carry POSIX paths even when this code runs (in
|
|
12
|
+
// tests) on Windows; a Windows crew account carries native Windows paths.
|
|
12
13
|
const { join } = posix;
|
|
13
14
|
/**
|
|
14
15
|
* `zswarm crew`: keep a Zellij session built from a layout running on a host.
|
|
@@ -23,12 +24,37 @@ const { join } = posix;
|
|
|
23
24
|
* status services, session and panes
|
|
24
25
|
* down remove the services and kill the session
|
|
25
26
|
*
|
|
27
|
+
* On Windows there is no systemd or launchd: `up` registers two Scheduled
|
|
28
|
+
* Tasks under `\zswarm\` (a start task and a two-minute watchdog running
|
|
29
|
+
* `crew check`) and the start path keeps an attached client in a minimized
|
|
30
|
+
* console window, because a background-only Windows session renders empty
|
|
31
|
+
* panes and drops pasted input.
|
|
32
|
+
*
|
|
26
33
|
* Zellij now and then starts `attach --create-background --layout` as a
|
|
27
34
|
* plain one-pane session; the first-pane check is what catches that.
|
|
28
35
|
*/
|
|
29
36
|
const SESSION = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
30
37
|
const FOREIGN_PATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin";
|
|
31
38
|
export const CREW_WATCH_SEC = 120;
|
|
39
|
+
/** The Windows client window: real PowerShell, or the test seam. */
|
|
40
|
+
function windowsClient(deps, env) {
|
|
41
|
+
if (deps.client)
|
|
42
|
+
return deps.client;
|
|
43
|
+
const runPowerShell = deps.runPowerShell ?? defaultPowerShellExec;
|
|
44
|
+
const launch = deps.launch ?? resolveServeCliLaunch({ env });
|
|
45
|
+
return {
|
|
46
|
+
ensure: (session) => ensureCrewClient({
|
|
47
|
+
session,
|
|
48
|
+
clientCommand: crewClientCommand({ session, launch }),
|
|
49
|
+
runPowerShell,
|
|
50
|
+
}),
|
|
51
|
+
close: async (session) => (await closeCrewClient({ session, runPowerShell })).closed,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** A task row shaped like the systemd/launchd `services` entries. */
|
|
55
|
+
function taskStateView(task) {
|
|
56
|
+
return { name: task.name, unit: task.path, state: task.state, active: task.active };
|
|
57
|
+
}
|
|
32
58
|
/** Env the Linux watchdog unit carries: restart this unit instead of starting Zellij itself. */
|
|
33
59
|
export const CREW_UNIT_ENV = "ZSWARM_CREW_UNIT";
|
|
34
60
|
export function checkCrewSession(session) {
|
|
@@ -38,8 +64,13 @@ export function checkCrewSession(session) {
|
|
|
38
64
|
return s;
|
|
39
65
|
}
|
|
40
66
|
export function crewDir(account, session, env = process.env) {
|
|
41
|
-
|
|
42
|
-
|
|
67
|
+
// A Windows account's home and state dir are native paths; joining them with
|
|
68
|
+
// posix.join would leave a mixed `C:\Users\x/.zswarm` for Zellij to open.
|
|
69
|
+
const path = account.platform === "win32" ? win32 : posix;
|
|
70
|
+
const base = !account.foreign && env.ZSWARM_STATE_DIR?.trim()
|
|
71
|
+
? defaultStateDir(env)
|
|
72
|
+
: path.join(account.home, ".zswarm");
|
|
73
|
+
return path.join(base, "crews", session);
|
|
43
74
|
}
|
|
44
75
|
/** The first `pane ... name="X"` in a KDL layout: the pane whose presence proves the layout took. */
|
|
45
76
|
export function firstLayoutPane(kdl) {
|
|
@@ -72,7 +103,7 @@ export function parseEnvFile(text) {
|
|
|
72
103
|
return out;
|
|
73
104
|
}
|
|
74
105
|
function readCrewFiles(dir) {
|
|
75
|
-
const meta =
|
|
106
|
+
const meta = crewFile(dir, "crew.json");
|
|
76
107
|
if (!existsSync(meta))
|
|
77
108
|
throw new ZellijError("usage", `no crew in ${dir}; run zswarm crew up first`);
|
|
78
109
|
return JSON.parse(readFileSync(meta, "utf8"));
|
|
@@ -102,15 +133,25 @@ function defaultZellij(args, env) {
|
|
|
102
133
|
const res = spawnSync(resolveZellijBinary(env), args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 30_000 });
|
|
103
134
|
return { status: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
104
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Zellij, sharing this process's console. `attach --create` needs a real
|
|
138
|
+
* terminal — with a pipe on stdin it exits 0 having created nothing, which is
|
|
139
|
+
* exactly the "session that never renders" a Windows crew must avoid.
|
|
140
|
+
*/
|
|
141
|
+
function defaultZellijInteractive(args, env) {
|
|
142
|
+
const res = spawnSync(resolveZellijBinary(env), args, { env, stdio: "inherit" });
|
|
143
|
+
return { status: res.status ?? 1, stdout: "", stderr: res.error?.message ?? "" };
|
|
144
|
+
}
|
|
105
145
|
function crewEnv(dir, files, env) {
|
|
106
|
-
const extra = files.env ? parseEnvFile(readFileSync(
|
|
146
|
+
const extra = files.env ? parseEnvFile(readFileSync(crewFile(dir, files.env), "utf8")) : {};
|
|
107
147
|
// No bus inside the start/check helpers: they list panes once and exit.
|
|
108
148
|
return { ...env, TERM: env.TERM || "xterm-256color", ...extra, ZSWARM_BUS: "0" };
|
|
109
149
|
}
|
|
110
150
|
/** Start the session from its layout and prove the first pane exists; up to three tries. */
|
|
111
151
|
export async function crewStart(session, deps = {}) {
|
|
112
152
|
const env0 = deps.env ?? process.env;
|
|
113
|
-
const
|
|
153
|
+
const account = deps.account ?? serviceAccount(undefined);
|
|
154
|
+
const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
|
|
114
155
|
const files = readCrewFiles(dir);
|
|
115
156
|
const env = crewEnv(dir, files, env0);
|
|
116
157
|
const zellij = deps.zellij ?? defaultZellij;
|
|
@@ -118,10 +159,37 @@ export async function crewStart(session, deps = {}) {
|
|
|
118
159
|
const panes = deps.panes ?? defaultPanes;
|
|
119
160
|
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
120
161
|
const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
|
|
121
|
-
const config = files.config ? ["--config",
|
|
162
|
+
const config = files.config ? ["--config", crewFile(dir, files.config)] : [];
|
|
163
|
+
if (account.platform === "win32") {
|
|
164
|
+
// Windows: a session created with `attach --create-background` renders
|
|
165
|
+
// empty panes and drops pasted input, so the session is created BY an
|
|
166
|
+
// attached client instead — a minimized console window running
|
|
167
|
+
// `zellij --layout ... attach --create`, which `crew client` starts. That
|
|
168
|
+
// window is also what the watchdog reopens when it dies.
|
|
169
|
+
const client = windowsClient(deps, env);
|
|
170
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
171
|
+
zellij(["delete-session", session], env); // clears only a dead session of this name
|
|
172
|
+
await client.close(session); // a window left over from the previous attempt
|
|
173
|
+
const opened = await client.ensure(session);
|
|
174
|
+
log(`client window ${opened.opened ? "opened" : "already open"} for ${session}`);
|
|
175
|
+
for (let i = 0; i < 30 && !(await sessions(env)).includes(session); i++)
|
|
176
|
+
await sleep(1_000);
|
|
177
|
+
await sleep(3_000);
|
|
178
|
+
if ((await panes(session, env)).some((p) => p.title === files.first)) {
|
|
179
|
+
log(`session ${session} up in window ${crewClientMarker(session)} (attempt ${attempt})`);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
log(`session ${session} came up without its layout (attempt ${attempt}); recreating`);
|
|
183
|
+
zellij(["kill-session", session], env);
|
|
184
|
+
await client.close(session);
|
|
185
|
+
await sleep(1_000);
|
|
186
|
+
}
|
|
187
|
+
log(`session ${session} never took its layout`);
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
122
190
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
123
191
|
zellij(["delete-session", session], env); // clears only a dead session of this name
|
|
124
|
-
zellij([...config, "--layout",
|
|
192
|
+
zellij([...config, "--layout", crewFile(dir, files.layout), "attach", "--create-background", session], env);
|
|
125
193
|
for (let i = 0; i < 20 && !(await sessions(env)).includes(session); i++)
|
|
126
194
|
await sleep(500);
|
|
127
195
|
await sleep(2_000);
|
|
@@ -136,16 +204,63 @@ export async function crewStart(session, deps = {}) {
|
|
|
136
204
|
log(`session ${session} never took its layout`);
|
|
137
205
|
return 1;
|
|
138
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Zellij's own client variables. A client started from inside a pane inherits
|
|
209
|
+
* `ZELLIJ=0`, `ZELLIJ_SESSION_NAME`, ...; a nested client then exits without
|
|
210
|
+
* creating anything. The console window is opened from whatever process asked
|
|
211
|
+
* for the crew (an agent pane, a scheduled task), so strip them all.
|
|
212
|
+
*/
|
|
213
|
+
const ZELLIJ_CLIENT_ENV = /^ZELLIJ(_|$)/;
|
|
214
|
+
function withoutZellijClientEnv(env) {
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const [key, value] of Object.entries(env)) {
|
|
217
|
+
if (ZELLIJ_CLIENT_ENV.test(key))
|
|
218
|
+
continue;
|
|
219
|
+
out[key] = value;
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* `crew client`: what the minimized console window runs. It reads the crew dir
|
|
225
|
+
* (so an env file's variables reach Zellij without being written into a task or
|
|
226
|
+
* a command line) and attaches with `--create`, which is what makes a Windows
|
|
227
|
+
* session render — a background-only session leaves pane grids empty.
|
|
228
|
+
*/
|
|
229
|
+
export function crewClient(session, deps = {}) {
|
|
230
|
+
const env0 = deps.env ?? process.env;
|
|
231
|
+
const account = deps.account ?? serviceAccount(undefined);
|
|
232
|
+
const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
|
|
233
|
+
const files = readCrewFiles(dir);
|
|
234
|
+
const env = withoutZellijClientEnv(crewEnv(dir, files, env0));
|
|
235
|
+
const args = [
|
|
236
|
+
...(files.config ? ["--config", crewFile(dir, files.config)] : []),
|
|
237
|
+
"--layout", crewFile(dir, files.layout),
|
|
238
|
+
"attach", "--create", session,
|
|
239
|
+
];
|
|
240
|
+
// OSC 0 titles the console window; the marker is how status and down find it.
|
|
241
|
+
// Only on a real console — a piped run (tests, logs) has no window to title.
|
|
242
|
+
if (process.stdout.isTTY)
|
|
243
|
+
process.stdout.write(`\u001b]0;${crewClientMarker(session)}\u0007`);
|
|
244
|
+
return (deps.zellij ?? defaultZellijInteractive)(args, env).status;
|
|
245
|
+
}
|
|
139
246
|
/** Watchdog: restart the session if its server died or its first pane is gone. */
|
|
140
247
|
export async function crewCheck(session, deps = {}) {
|
|
141
248
|
const env0 = deps.env ?? process.env;
|
|
142
|
-
const
|
|
249
|
+
const account = deps.account ?? serviceAccount(undefined);
|
|
250
|
+
const dir = deps.dir ?? crewDir(account, checkCrewSession(session), env0);
|
|
143
251
|
const files = readCrewFiles(dir);
|
|
144
252
|
const env = crewEnv(dir, files, env0);
|
|
145
253
|
const sessions = deps.sessions ?? defaultSessions;
|
|
146
254
|
const panes = deps.panes ?? defaultPanes;
|
|
147
255
|
const log = deps.log ?? ((line) => process.stdout.write(`${new Date().toISOString()} ${line}\n`));
|
|
148
256
|
if ((await sessions(env)).includes(session) && (await panes(session, env)).some((p) => p.title === files.first)) {
|
|
257
|
+
if (account.platform === "win32") {
|
|
258
|
+
// The session surviving is not enough on Windows: without the attached
|
|
259
|
+
// client window its panes render empty, so the watchdog reopens it.
|
|
260
|
+
const opened = await windowsClient(deps, env).ensure(session);
|
|
261
|
+
if (opened.opened)
|
|
262
|
+
log(`client window reopened for ${session}`);
|
|
263
|
+
}
|
|
149
264
|
return 0;
|
|
150
265
|
}
|
|
151
266
|
log(`session ${session} is gone or lost its layout; restarting`);
|
|
@@ -159,8 +274,26 @@ export async function crewCheck(session, deps = {}) {
|
|
|
159
274
|
}
|
|
160
275
|
return await crewStart(session, deps);
|
|
161
276
|
}
|
|
277
|
+
/** A file inside a crew dir, which is a Windows path for a win32 account. */
|
|
278
|
+
function crewFile(dir, name) {
|
|
279
|
+
return dir.includes("\\") ? win32.join(dir, name) : join(dir, name);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* The env a Windows crew task action reproduces. Not the whole caller env (it
|
|
283
|
+
* can hold tokens) and not PATH (the interactive task inherits the user's, and
|
|
284
|
+
* a host PATH would not fit in a Windows command line).
|
|
285
|
+
*/
|
|
286
|
+
function crewTaskEnv(env) {
|
|
287
|
+
const out = {};
|
|
288
|
+
for (const key of ["ZSWARM_BIN", "ZSWARM_STATE_DIR"]) {
|
|
289
|
+
const value = env[key]?.trim();
|
|
290
|
+
if (value)
|
|
291
|
+
out[key] = value;
|
|
292
|
+
}
|
|
293
|
+
return out;
|
|
294
|
+
}
|
|
162
295
|
function copyForAccount(account, from, to, secret) {
|
|
163
|
-
ensureAccountDir(account,
|
|
296
|
+
ensureAccountDir(account, dirname(to));
|
|
164
297
|
if (secret) {
|
|
165
298
|
writeSecretFile(account, to, readFileSync(from, "utf8"));
|
|
166
299
|
return;
|
|
@@ -203,7 +336,7 @@ export function crewServiceSpecs(input) {
|
|
|
203
336
|
},
|
|
204
337
|
];
|
|
205
338
|
}
|
|
206
|
-
export function crewUp(input) {
|
|
339
|
+
export async function crewUp(input) {
|
|
207
340
|
const env = input.env ?? process.env;
|
|
208
341
|
const run = input.run ?? defaultRunner;
|
|
209
342
|
const account = input.account ?? serviceAccount(input.runAs, run);
|
|
@@ -219,17 +352,42 @@ export function crewUp(input) {
|
|
|
219
352
|
const dir = crewDir(account, session, env);
|
|
220
353
|
const copy = input.copy ?? copyForAccount;
|
|
221
354
|
const files = { layout: "layout.kdl", first };
|
|
222
|
-
copy(account, input.layout,
|
|
355
|
+
copy(account, input.layout, crewFile(dir, "layout.kdl"), false);
|
|
223
356
|
if (input.config) {
|
|
224
|
-
copy(account, input.config,
|
|
357
|
+
copy(account, input.config, crewFile(dir, "config.kdl"), false);
|
|
225
358
|
files.config = "config.kdl";
|
|
226
359
|
}
|
|
227
360
|
if (input.envFile) {
|
|
228
|
-
copy(account, input.envFile,
|
|
361
|
+
copy(account, input.envFile, crewFile(dir, "env"), true);
|
|
229
362
|
files.env = "env";
|
|
230
363
|
}
|
|
231
364
|
// crew.json is written last, so a half-copied crew never starts.
|
|
232
|
-
(input.writeMeta ?? writeSecretFile)(account,
|
|
365
|
+
(input.writeMeta ?? writeSecretFile)(account, crewFile(dir, "crew.json"), `${JSON.stringify(files, null, 1)}\n`);
|
|
366
|
+
if (account.platform === "win32") {
|
|
367
|
+
// Windows has no systemd/launchd: two Scheduled Tasks (a start task and a
|
|
368
|
+
// two-minute watchdog) plus the minimized client window the start task
|
|
369
|
+
// opens, because a background-only session renders empty panes.
|
|
370
|
+
const taskEnv = crewTaskEnv(env);
|
|
371
|
+
const installed = await installCrewTasks({
|
|
372
|
+
session,
|
|
373
|
+
startCommand: crewTaskCommand({ launch, mode: "start", session, env: taskEnv }),
|
|
374
|
+
watchCommand: crewTaskCommand({ launch, mode: "check", session, env: taskEnv }),
|
|
375
|
+
runPowerShell: input.runPowerShell ?? defaultPowerShellExec,
|
|
376
|
+
});
|
|
377
|
+
const states = installed.tasks.map(taskStateView);
|
|
378
|
+
return {
|
|
379
|
+
session,
|
|
380
|
+
user: account.user,
|
|
381
|
+
dir,
|
|
382
|
+
firstPane: first,
|
|
383
|
+
services: states,
|
|
384
|
+
ready: states.every((s) => s.active),
|
|
385
|
+
tasks: installed.tasks,
|
|
386
|
+
clientWindow: installed.client,
|
|
387
|
+
actions: installed.lines,
|
|
388
|
+
notes: installed.notes,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
233
391
|
const specs = crewServiceSpecs({ account, session, launch, env });
|
|
234
392
|
const done = installServices({ account, run, fs: input.fs }, specs);
|
|
235
393
|
const states = serviceStates({ account, run }, specs);
|
|
@@ -249,14 +407,28 @@ export async function crewStatus(input) {
|
|
|
249
407
|
const run = input.run ?? defaultRunner;
|
|
250
408
|
const account = input.account ?? serviceAccount(input.runAs, run);
|
|
251
409
|
const session = checkCrewSession(input.session);
|
|
252
|
-
const
|
|
253
|
-
const services = serviceStates({ account, run }, specs);
|
|
254
|
-
const out = { session, user: account.user, services };
|
|
410
|
+
const out = { session, user: account.user };
|
|
255
411
|
if (account.foreign) {
|
|
412
|
+
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
413
|
+
out.services = serviceStates({ account, run }, specs);
|
|
256
414
|
out.note = `session and panes belong to ${account.user}: run zswarm crew status ${session} as that user`;
|
|
257
415
|
return out;
|
|
258
416
|
}
|
|
259
|
-
|
|
417
|
+
let client = null;
|
|
418
|
+
if (account.platform === "win32") {
|
|
419
|
+
const inspected = await inspectCrewTasks({
|
|
420
|
+
session,
|
|
421
|
+
runPowerShell: input.runPowerShell ?? defaultPowerShellExec,
|
|
422
|
+
});
|
|
423
|
+
out.services = inspected.tasks.map(taskStateView);
|
|
424
|
+
client = inspected.client;
|
|
425
|
+
out.clientWindow = inspected.client;
|
|
426
|
+
}
|
|
427
|
+
else {
|
|
428
|
+
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
429
|
+
out.services = serviceStates({ account, run }, specs);
|
|
430
|
+
}
|
|
431
|
+
const files = existsSync(crewFile(crewDir(account, session, env), "crew.json"))
|
|
260
432
|
? readCrewFiles(crewDir(account, session, env))
|
|
261
433
|
: undefined;
|
|
262
434
|
const live = (await (input.deps?.sessions ?? defaultSessions)(env)).includes(session);
|
|
@@ -267,17 +439,34 @@ export async function crewStatus(input) {
|
|
|
267
439
|
out.firstPane = files?.first ?? null;
|
|
268
440
|
out.layoutTook = files ? panes.some((p) => p.title === files.first) : null;
|
|
269
441
|
out.panes = panes;
|
|
270
|
-
out.healthy =
|
|
442
|
+
out.healthy =
|
|
443
|
+
out.services.every((s) => s.active) &&
|
|
444
|
+
live &&
|
|
445
|
+
out.layoutTook !== false &&
|
|
446
|
+
panes.every((p) => !p.exited) &&
|
|
447
|
+
(client ? client.present : true);
|
|
271
448
|
if (live && exited) {
|
|
272
449
|
out.note = `an exited session named ${session} is also listed (resurrectable); remove it with zswarm sessions --prune-exited`;
|
|
273
450
|
}
|
|
274
451
|
return out;
|
|
275
452
|
}
|
|
276
|
-
export function crewDown(input) {
|
|
453
|
+
export async function crewDown(input) {
|
|
277
454
|
const env = input.env ?? process.env;
|
|
278
455
|
const run = input.run ?? defaultRunner;
|
|
279
456
|
const account = input.account ?? serviceAccount(input.runAs, run);
|
|
280
457
|
const session = checkCrewSession(input.session);
|
|
458
|
+
if (account.platform === "win32") {
|
|
459
|
+
const runPowerShell = input.runPowerShell ?? defaultPowerShellExec;
|
|
460
|
+
const removed = await removeCrewTasks({ session, runPowerShell });
|
|
461
|
+
const lines = [...removed.lines];
|
|
462
|
+
const closed = await closeCrewClient({ session, runPowerShell });
|
|
463
|
+
lines.push(closed.closed.length > 0
|
|
464
|
+
? `client window closed (pids ${closed.closed.join(", ")})`
|
|
465
|
+
: "no client window was open");
|
|
466
|
+
const kill = run(resolveZellijBinary(env), ["kill-session", session]);
|
|
467
|
+
lines.push(kill.status === 0 ? `session ${session} killed` : `session ${session} was not running`);
|
|
468
|
+
return { session, user: account.user, actions: lines };
|
|
469
|
+
}
|
|
281
470
|
const specs = crewServiceSpecs({ account, session, launch: { execPath: "", scriptPath: "" }, env });
|
|
282
471
|
const lines = removeServices({ account, run, fs: input.fs }, specs);
|
|
283
472
|
// The Zellij server belongs to the account: kill it as that user.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { LeaseRecord, StateStore } from "../state.js";
|
|
2
|
+
/**
|
|
3
|
+
* Pane leases: a relay holds one on its target pane from before it sends until
|
|
4
|
+
* it ends, so a second relay or `send --if-idle` can refuse (or wait) instead
|
|
5
|
+
* of interleaving two tasks in one agent pane.
|
|
6
|
+
*
|
|
7
|
+
* The record lives in the zswarm state dir and is only ever read or written
|
|
8
|
+
* inside `StateStore.withLeases`, whose file lock serializes check-then-take
|
|
9
|
+
* across processes. A lease whose relay directory is gone or has finished is
|
|
10
|
+
* stale and is taken over.
|
|
11
|
+
*/
|
|
12
|
+
export type LeaseTarget = {
|
|
13
|
+
/** The serve endpoint that owns the pane, or undefined for a local pane. */
|
|
14
|
+
serve?: string;
|
|
15
|
+
session?: string;
|
|
16
|
+
/** The pane address as `--to` gives it (id, title or command). */
|
|
17
|
+
pane: string;
|
|
18
|
+
};
|
|
19
|
+
export type LeaseTakeInput = {
|
|
20
|
+
relayId: string;
|
|
21
|
+
from: string;
|
|
22
|
+
at: number;
|
|
23
|
+
dir: string;
|
|
24
|
+
target: LeaseTarget;
|
|
25
|
+
/** Send anyway: add this relay to the holder's shared list. */
|
|
26
|
+
force?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type LeaseTakeResult = {
|
|
29
|
+
ok: true;
|
|
30
|
+
lease: LeaseRecord;
|
|
31
|
+
shared: boolean;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
holder: LeaseRecord;
|
|
35
|
+
};
|
|
36
|
+
/** The target identity: serve endpoint (or `local`), session and pane address. */
|
|
37
|
+
export declare function leaseKey(target: LeaseTarget): string;
|
|
38
|
+
type LeaseJob = {
|
|
39
|
+
kind?: unknown;
|
|
40
|
+
pid?: unknown;
|
|
41
|
+
label?: unknown;
|
|
42
|
+
unit?: unknown;
|
|
43
|
+
};
|
|
44
|
+
export type LeaseStaleDeps = {
|
|
45
|
+
now?: () => number;
|
|
46
|
+
/** Whether a launchd/systemd relay job is still loaded; injected in tests. */
|
|
47
|
+
jobRunning?: (job: LeaseJob) => boolean | undefined;
|
|
48
|
+
};
|
|
49
|
+
export declare function leaseStale(rec: LeaseRecord, deps?: LeaseStaleDeps): boolean;
|
|
50
|
+
/** Live leases only; stale records are dropped from the store as they are seen. */
|
|
51
|
+
export declare function listLeases(store: StateStore, deps?: LeaseStaleDeps): LeaseRecord[];
|
|
52
|
+
/** The live lease for a target, if any. */
|
|
53
|
+
export declare function findLease(store: StateStore, target: LeaseTarget, deps?: LeaseStaleDeps): LeaseRecord | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Take the lease for a target. Returns the holder when a live lease is already
|
|
56
|
+
* held (caller decides: refuse, `--queue`, or `--force`). `force` shares the
|
|
57
|
+
* holder's lease by recording the new relay id.
|
|
58
|
+
*/
|
|
59
|
+
export declare function takeLease(store: StateStore, input: LeaseTakeInput): LeaseTakeResult;
|
|
60
|
+
/**
|
|
61
|
+
* Release a target's lease. With `relayId`, the lease is dropped only when that
|
|
62
|
+
* relay is the holder; a forced relay that ends is removed from `sharedBy` and
|
|
63
|
+
* the original holder keeps the lease.
|
|
64
|
+
*/
|
|
65
|
+
export declare function releaseLease(store: StateStore, target: LeaseTarget, relayId?: string): boolean;
|
|
66
|
+
/** `releaseLease` by the already-recorded key (the relay config stores it). */
|
|
67
|
+
export declare function releaseLeaseKey(store: StateStore, key: string, relayId?: string): boolean;
|
|
68
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** The target identity: serve endpoint (or `local`), session and pane address. */
|
|
5
|
+
export function leaseKey(target) {
|
|
6
|
+
return [target.serve?.trim() || "local", target.session?.trim() ?? "", target.pane.trim()].join("|");
|
|
7
|
+
}
|
|
8
|
+
function pidAlive(pid) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
// ESRCH: gone. EPERM: exists, just unsignalable — still a live holder.
|
|
15
|
+
return err.code === "EPERM";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A lease is stale when its relay no longer runs: the run directory is gone,
|
|
20
|
+
* the relay recorded a final status or was cancelled, its message is written,
|
|
21
|
+
* or its plain detached pid is dead. (launchd/systemd jobs write a status on
|
|
22
|
+
* every final path; a killed job without one is recovered by `--cancel`.)
|
|
23
|
+
*/
|
|
24
|
+
/** Minutes past a relay's max age before its lease is dropped regardless. */
|
|
25
|
+
const LEASE_GRACE_MIN = 5;
|
|
26
|
+
/** True while a launchd label or systemd user unit is still loaded; undefined for other jobs. */
|
|
27
|
+
function serviceJobRunning(job) {
|
|
28
|
+
if (job.kind === "launchd" && typeof job.label === "string") {
|
|
29
|
+
return spawnSync("launchctl", ["list", job.label], { stdio: "ignore" }).status === 0;
|
|
30
|
+
}
|
|
31
|
+
if (job.kind === "systemd" && typeof job.unit === "string") {
|
|
32
|
+
const res = spawnSync("systemctl", ["--user", "is-active", job.unit], { encoding: "utf8" });
|
|
33
|
+
return res.status === 0 && /^(active|activating|reloading)$/.test((res.stdout ?? "").trim());
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
export function leaseStale(rec, deps = {}) {
|
|
38
|
+
if (!rec.dir || !existsSync(rec.dir))
|
|
39
|
+
return true;
|
|
40
|
+
let cfg;
|
|
41
|
+
try {
|
|
42
|
+
cfg = JSON.parse(readFileSync(join(rec.dir, "config.json"), "utf8"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (cfg.cancelled === true || typeof cfg.status === "string")
|
|
48
|
+
return true;
|
|
49
|
+
if (existsSync(join(rec.dir, "message.txt")))
|
|
50
|
+
return true;
|
|
51
|
+
const job = cfg.job;
|
|
52
|
+
if (job && job.kind === "pid" && typeof job.pid === "number" && !pidAlive(job.pid))
|
|
53
|
+
return true;
|
|
54
|
+
// A launchd/systemd relay that is no longer loaded ended without a final
|
|
55
|
+
// status (removed by hand, killed, or the machine restarted).
|
|
56
|
+
if (job && (deps.jobRunning ?? serviceJobRunning)(job) === false)
|
|
57
|
+
return true;
|
|
58
|
+
// Backstop: a relay always ends by its max age, so its lease cannot outlive
|
|
59
|
+
// it (this also covers a pid that was reused by another process).
|
|
60
|
+
const maxAgeMin = typeof cfg.maxAgeMin === "number" ? cfg.maxAgeMin
|
|
61
|
+
: typeof cfg.timeoutMin === "number" ? cfg.timeoutMin + 60
|
|
62
|
+
: undefined;
|
|
63
|
+
if (maxAgeMin !== undefined && (deps.now ?? Date.now)() > rec.at + (maxAgeMin + LEASE_GRACE_MIN) * 60_000)
|
|
64
|
+
return true;
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/** Live leases only; stale records are dropped from the store as they are seen. */
|
|
68
|
+
export function listLeases(store, deps = {}) {
|
|
69
|
+
return store.withLeases((leases) => {
|
|
70
|
+
for (const [key, rec] of Object.entries(leases)) {
|
|
71
|
+
if (leaseStale(rec, deps))
|
|
72
|
+
delete leases[key];
|
|
73
|
+
}
|
|
74
|
+
return Object.values(leases);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** The live lease for a target, if any. */
|
|
78
|
+
export function findLease(store, target, deps = {}) {
|
|
79
|
+
const rec = store.readLeases()[leaseKey(target)];
|
|
80
|
+
return rec && !leaseStale(rec, deps) ? rec : undefined;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Take the lease for a target. Returns the holder when a live lease is already
|
|
84
|
+
* held (caller decides: refuse, `--queue`, or `--force`). `force` shares the
|
|
85
|
+
* holder's lease by recording the new relay id.
|
|
86
|
+
*/
|
|
87
|
+
export function takeLease(store, input) {
|
|
88
|
+
const key = leaseKey(input.target);
|
|
89
|
+
return store.withLeases((leases) => {
|
|
90
|
+
const held = leases[key];
|
|
91
|
+
// Staleness on the taker's clock: the new lease's `at` is its now.
|
|
92
|
+
if (held && !leaseStale(held, { now: () => input.at })) {
|
|
93
|
+
if (!input.force)
|
|
94
|
+
return { ok: false, holder: held };
|
|
95
|
+
const sharedBy = [...new Set([...(held.sharedBy ?? []), input.relayId])];
|
|
96
|
+
const shared = { ...held, sharedBy };
|
|
97
|
+
leases[key] = shared;
|
|
98
|
+
return { ok: true, lease: shared, shared: true };
|
|
99
|
+
}
|
|
100
|
+
const lease = {
|
|
101
|
+
key,
|
|
102
|
+
relayId: input.relayId,
|
|
103
|
+
from: input.from,
|
|
104
|
+
at: input.at,
|
|
105
|
+
dir: input.dir,
|
|
106
|
+
serve: input.target.serve,
|
|
107
|
+
session: input.target.session,
|
|
108
|
+
pane: input.target.pane,
|
|
109
|
+
};
|
|
110
|
+
leases[key] = lease;
|
|
111
|
+
return { ok: true, lease, shared: false };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Release a target's lease. With `relayId`, the lease is dropped only when that
|
|
116
|
+
* relay is the holder; a forced relay that ends is removed from `sharedBy` and
|
|
117
|
+
* the original holder keeps the lease.
|
|
118
|
+
*/
|
|
119
|
+
export function releaseLease(store, target, relayId) {
|
|
120
|
+
return releaseLeaseKey(store, leaseKey(target), relayId);
|
|
121
|
+
}
|
|
122
|
+
/** `releaseLease` by the already-recorded key (the relay config stores it). */
|
|
123
|
+
export function releaseLeaseKey(store, key, relayId) {
|
|
124
|
+
return store.withLeases((leases) => {
|
|
125
|
+
const held = leases[key];
|
|
126
|
+
if (!held)
|
|
127
|
+
return false;
|
|
128
|
+
if (!relayId || held.relayId === relayId) {
|
|
129
|
+
delete leases[key];
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
if (held.sharedBy?.includes(relayId)) {
|
|
133
|
+
leases[key] = { ...held, sharedBy: held.sharedBy.filter((id) => id !== relayId) };
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
});
|
|
138
|
+
}
|
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.
|
|
@@ -51,6 +52,8 @@ export type RelayConfig = {
|
|
|
51
52
|
/** Set by `--cancel`; a late loop must not overwrite it. */
|
|
52
53
|
cancelled?: boolean;
|
|
53
54
|
cancelledAt?: number;
|
|
55
|
+
/** The pane lease this relay holds; released on every final status. */
|
|
56
|
+
leaseKey?: string;
|
|
54
57
|
};
|
|
55
58
|
export type RelayDeps = {
|
|
56
59
|
/** dispatchZswarm with an env; injected in tests. */
|
|
@@ -101,8 +104,14 @@ export type RelayStartInput = {
|
|
|
101
104
|
/** Hard cap on the relay's age; defaults to `timeoutMin` plus 60 minutes. */
|
|
102
105
|
maxAgeMin?: number;
|
|
103
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;
|
|
104
111
|
env?: NodeJS.ProcessEnv;
|
|
105
112
|
deps?: RelayDeps;
|
|
113
|
+
/** State store for the pane lease; injected in tests. */
|
|
114
|
+
store?: StateStore;
|
|
106
115
|
/** Starts the detached relay; injected in tests. */
|
|
107
116
|
launch?: (dir: string, env: NodeJS.ProcessEnv) => string;
|
|
108
117
|
};
|
|
@@ -138,6 +147,8 @@ export type RelayJobRunner = (cmd: string, args: string[]) => {
|
|
|
138
147
|
export type RelayAdminDeps = {
|
|
139
148
|
env?: NodeJS.ProcessEnv;
|
|
140
149
|
now?: () => number;
|
|
150
|
+
/** State store for the pane leases; injected in tests. */
|
|
151
|
+
store?: StateStore;
|
|
141
152
|
/** Runs launchctl/systemctl; injected in tests. */
|
|
142
153
|
run?: RelayJobRunner;
|
|
143
154
|
/** Liveness probe for a plain detached pid; injected in tests. */
|
|
@@ -160,6 +171,12 @@ export type RelayRow = {
|
|
|
160
171
|
lastCheck?: number;
|
|
161
172
|
/** The launchd label / systemd unit / pid, when the job is still loaded. */
|
|
162
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
|
+
};
|
|
163
180
|
};
|
|
164
181
|
export type RelayCancelResult = {
|
|
165
182
|
id: string;
|