@zswarm/core 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/host-install.sh +120 -0
- package/assets/host-report.sh +29 -0
- package/assets/slot.py +324 -0
- package/dist/exec.d.ts +2 -1
- package/dist/exec.js +16 -3
- package/dist/host/cli.d.ts +13 -0
- package/dist/host/cli.js +214 -0
- package/dist/host/crew.d.ts +81 -0
- package/dist/host/crew.js +279 -0
- package/dist/host/host.d.ts +55 -0
- package/dist/host/host.js +165 -0
- package/dist/host/relay.d.ts +116 -0
- package/dist/host/relay.js +476 -0
- package/dist/host/serve-service.d.ts +51 -0
- package/dist/host/serve-service.js +150 -0
- package/dist/host/services.d.ts +102 -0
- package/dist/host/services.js +337 -0
- package/dist/host/slot.d.ts +11 -0
- package/dist/host/slot.js +74 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/ops/broadcast.d.ts +6 -0
- package/dist/ops/broadcast.js +15 -11
- package/dist/ops/dispatch.js +25 -2
- package/dist/ops/status.d.ts +4 -2
- package/dist/ops/status.js +15 -12
- package/dist/ops/types.d.ts +3 -0
- package/dist/ops/wait.js +29 -7
- package/dist/schema.js +10 -4
- package/dist/state.d.ts +7 -0
- package/dist/state.js +52 -9
- package/dist/zellij/binary.js +4 -1
- package/package.json +2 -1
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { ZellijError } from "../errors.js";
|
|
5
|
+
import { dispatchZswarm } from "../ops/dispatch.js";
|
|
6
|
+
import { validateSshDestination } from "../zellij/binary.js";
|
|
7
|
+
/**
|
|
8
|
+
* `zswarm host install` puts Node.js, zswarm and Zellij on a Linux or macOS
|
|
9
|
+
* host over SSH (or prints the script, for callers with their own root
|
|
10
|
+
* channel). `zswarm host doctor` checks a host: tools, disk, sessions and
|
|
11
|
+
* services over SSH, then serve, the session and its panes through the
|
|
12
|
+
* normal transport.
|
|
13
|
+
*/
|
|
14
|
+
export function hostScriptPath(name) {
|
|
15
|
+
return fileURLToPath(new URL(`../../assets/${name}`, import.meta.url));
|
|
16
|
+
}
|
|
17
|
+
export const defaultSsh = (dest, remoteCommand, stdin) => {
|
|
18
|
+
const res = spawnSync("ssh", ["-o", "BatchMode=yes", dest, ...remoteCommand], {
|
|
19
|
+
input: stdin,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
timeout: 600_000,
|
|
22
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
23
|
+
});
|
|
24
|
+
return { status: res.status ?? 255, stdout: res.stdout ?? "", stderr: res.stderr ?? (res.error?.message ?? "") };
|
|
25
|
+
};
|
|
26
|
+
/** Single-quote for the remote POSIX shell. */
|
|
27
|
+
function shq(word) {
|
|
28
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, "'\\''")}'`;
|
|
29
|
+
}
|
|
30
|
+
export function hostInstallArgs(input) {
|
|
31
|
+
const args = [];
|
|
32
|
+
if (input.prefix)
|
|
33
|
+
args.push("--prefix", input.prefix);
|
|
34
|
+
if (input.binDir)
|
|
35
|
+
args.push("--bin-dir", input.binDir);
|
|
36
|
+
if (input.node)
|
|
37
|
+
args.push("--node", input.node);
|
|
38
|
+
if (input.zellij)
|
|
39
|
+
args.push("--zellij", input.zellij);
|
|
40
|
+
// Unpinned, the script installs the latest published zswarm.
|
|
41
|
+
if (input.zswarm)
|
|
42
|
+
args.push("--zswarm", input.zswarm);
|
|
43
|
+
return args;
|
|
44
|
+
}
|
|
45
|
+
export function hostInstall(input) {
|
|
46
|
+
if (!input.ssh)
|
|
47
|
+
throw new ZellijError("usage", "host install needs --ssh <destination> (or --script to print the script)");
|
|
48
|
+
const dest = validateSshDestination(input.ssh);
|
|
49
|
+
const script = readFileSync(hostScriptPath("host-install.sh"), "utf8");
|
|
50
|
+
const remote = [...(input.sudo ? ["sudo", "-n"] : []), "sh", "-s", "--", ...hostInstallArgs(input).map(shq)];
|
|
51
|
+
const res = (input.run ?? defaultSsh)(dest, remote, script);
|
|
52
|
+
if (res.status !== 0) {
|
|
53
|
+
throw new ZellijError("failed", `host install on ${dest} failed (exit ${res.status})`, {
|
|
54
|
+
stdout: res.stdout.slice(-2000),
|
|
55
|
+
stderr: res.stderr.slice(-2000),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const steps = {};
|
|
59
|
+
for (const line of res.stdout.split("\n")) {
|
|
60
|
+
const m = /^(\S+)\s+(.+)$/.exec(line.trim());
|
|
61
|
+
if (m)
|
|
62
|
+
steps[m[1]] = m[2];
|
|
63
|
+
}
|
|
64
|
+
return { steps, output: res.stdout };
|
|
65
|
+
}
|
|
66
|
+
/** KEY=VALUE lines from host-report.sh; `service=` lines are collected. */
|
|
67
|
+
export function parseHostReport(text) {
|
|
68
|
+
const facts = {};
|
|
69
|
+
const services = [];
|
|
70
|
+
for (const line of text.split("\n")) {
|
|
71
|
+
const i = line.indexOf("=");
|
|
72
|
+
if (i <= 0)
|
|
73
|
+
continue;
|
|
74
|
+
const key = line.slice(0, i).trim();
|
|
75
|
+
const value = line.slice(i + 1).trim();
|
|
76
|
+
if (key === "service")
|
|
77
|
+
services.push(value);
|
|
78
|
+
else
|
|
79
|
+
facts[key] = value;
|
|
80
|
+
}
|
|
81
|
+
return { facts, services };
|
|
82
|
+
}
|
|
83
|
+
export function hostChecks(facts, services, session) {
|
|
84
|
+
const checks = [];
|
|
85
|
+
const tool = (id, required, hint) => {
|
|
86
|
+
const v = facts[id];
|
|
87
|
+
checks.push(v ? { id, state: "pass", detail: v } : { id, state: required ? "fail" : "warn", detail: `missing; ${hint}` });
|
|
88
|
+
};
|
|
89
|
+
tool("node", true, "zswarm host install");
|
|
90
|
+
tool("zswarm", true, "zswarm host install");
|
|
91
|
+
tool("zellij", true, "zswarm host install");
|
|
92
|
+
tool("python3", false, "zswarm slot needs Python 3.9+");
|
|
93
|
+
for (const id of ["disk_home_gb", "disk_tmp_gb"]) {
|
|
94
|
+
const gb = Number(facts[id]);
|
|
95
|
+
if (!Number.isFinite(gb) || facts[id] === "")
|
|
96
|
+
continue;
|
|
97
|
+
checks.push({ id, state: gb < 5 ? "fail" : gb < 15 ? "warn" : "pass", detail: `${gb} GB free` });
|
|
98
|
+
}
|
|
99
|
+
if (facts.os === "Linux" && services.length > 0) {
|
|
100
|
+
checks.push({
|
|
101
|
+
id: "linger",
|
|
102
|
+
state: facts.linger === "yes" ? "pass" : "fail",
|
|
103
|
+
detail: facts.linger === "yes" ? "on" : `off: services stop at logout (sudo loginctl enable-linger ${facts.user ?? "<user>"})`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
for (const svc of services) {
|
|
107
|
+
// Linux: "<unit> <active> <sub>"; macOS: "<label> pid=<pid|-> last=<code>".
|
|
108
|
+
const failed = /\sfailed\b/.test(svc) || (/last=(\d+)/.exec(svc)?.[1] ?? "0") !== "0";
|
|
109
|
+
checks.push({ id: `service ${svc.split(" ")[0]}`, state: failed ? "fail" : "pass", detail: svc });
|
|
110
|
+
}
|
|
111
|
+
if (session) {
|
|
112
|
+
const live = (facts.sessions ?? "").split(/\s+/).includes(session);
|
|
113
|
+
checks.push({ id: `session ${session}`, state: live ? "pass" : "fail", detail: live ? "live" : "not running (zswarm crew status)" });
|
|
114
|
+
}
|
|
115
|
+
return checks;
|
|
116
|
+
}
|
|
117
|
+
export async function hostDoctor(input) {
|
|
118
|
+
const env = input.env ?? process.env;
|
|
119
|
+
const checks = [];
|
|
120
|
+
let facts;
|
|
121
|
+
if (input.ssh) {
|
|
122
|
+
const dest = validateSshDestination(input.ssh);
|
|
123
|
+
const script = readFileSync(hostScriptPath("host-report.sh"), "utf8");
|
|
124
|
+
const res = (input.run ?? defaultSsh)(dest, ["sh", "-s", "--", ...(input.session ? [shq(input.session)] : [])], script);
|
|
125
|
+
if (res.status !== 0) {
|
|
126
|
+
checks.push({ id: "ssh", state: "fail", detail: `ssh ${dest} failed (exit ${res.status}): ${res.stderr.trim().slice(0, 200)}` });
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
checks.push({ id: "ssh", state: "pass", detail: dest });
|
|
130
|
+
const parsed = parseHostReport(res.stdout);
|
|
131
|
+
facts = parsed.facts;
|
|
132
|
+
checks.push(...hostChecks(parsed.facts, parsed.services, input.session));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
let transport;
|
|
136
|
+
const serve = input.serve?.trim() || env.ZSWARM_SERVE?.trim();
|
|
137
|
+
if (serve) {
|
|
138
|
+
const call = input.call ?? ((args, e) => dispatchZswarm(args, undefined, { env: e }));
|
|
139
|
+
const remote = { ...env, ZSWARM_SERVE: serve, ZSWARM_BUS: "0" };
|
|
140
|
+
const doctorArgs = { op: "doctor" };
|
|
141
|
+
if (input.session)
|
|
142
|
+
doctorArgs.session = input.session;
|
|
143
|
+
const doctor = await call(doctorArgs, remote);
|
|
144
|
+
transport = doctor.ok ? doctor.data : doctor.error;
|
|
145
|
+
checks.push({ id: "serve doctor", state: doctor.ok ? "pass" : "fail", detail: doctor.ok ? serve : `${doctor.error.code}: ${doctor.error.message.slice(0, 200)}` });
|
|
146
|
+
if (doctor.ok && input.session) {
|
|
147
|
+
const listed = await call({ op: "list", session: input.session }, remote);
|
|
148
|
+
if (listed.ok) {
|
|
149
|
+
const panes = listed.data.panes ?? [];
|
|
150
|
+
const dead = panes.filter((p) => p.exited).map((p) => p.title ?? "?");
|
|
151
|
+
checks.push({
|
|
152
|
+
id: "panes",
|
|
153
|
+
state: panes.length === 0 || dead.length > 0 ? "fail" : "pass",
|
|
154
|
+
detail: dead.length ? `exited: ${dead.join(", ")}` : `${panes.length} alive`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
checks.push({ id: "panes", state: "fail", detail: `${listed.error.code}: ${listed.error.message.slice(0, 200)}` });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!input.ssh && !serve)
|
|
163
|
+
throw new ZellijError("usage", "host doctor needs --ssh <destination> and/or --serve <target>");
|
|
164
|
+
return { ok: checks.every((c) => c.state !== "fail"), checks, facts, transport };
|
|
165
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { OpsResult } from "../ops/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* `zswarm relay`: hand a body to a pane on another host and tell a local pane
|
|
4
|
+
* when the work is done, without anyone polling.
|
|
5
|
+
*
|
|
6
|
+
* The calling process sends the body and returns. A detached relay (a launchd
|
|
7
|
+
* job on macOS, a transient systemd user unit on Linux, a detached process
|
|
8
|
+
* elsewhere) then waits over serve for the `--done` regex on the target
|
|
9
|
+
* pane's screen, optionally confirms with a check script, and pastes a
|
|
10
|
+
* message into the `--reply-to` pane, retrying with backoff; a message that
|
|
11
|
+
* never lands is left in relays/undelivered/<id>.txt.
|
|
12
|
+
*
|
|
13
|
+
* A relay runs from its own directory, ~/.zswarm/relays/<id>-<ts>/: config.json
|
|
14
|
+
* (mode 600, with a copy of the serve token that is deleted when the relay
|
|
15
|
+
* ends), a snapshot of the check script and message template, relay.log and
|
|
16
|
+
* message.txt. It reads nothing else, so moving or updating the caller's files
|
|
17
|
+
* while it waits cannot break it.
|
|
18
|
+
*/
|
|
19
|
+
export type RelayStatus = "DONE" | "TIMEOUT" | "PANE_DIED" | "SERVE_DOWN" | "UNCONFIRMED";
|
|
20
|
+
export type RelayConfig = {
|
|
21
|
+
id: string;
|
|
22
|
+
serve: string;
|
|
23
|
+
token?: string;
|
|
24
|
+
session?: string;
|
|
25
|
+
to: string;
|
|
26
|
+
done: string;
|
|
27
|
+
replyTo: string;
|
|
28
|
+
replySession?: string;
|
|
29
|
+
from: string;
|
|
30
|
+
timeoutMin: number;
|
|
31
|
+
check?: string;
|
|
32
|
+
message?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Screen lines that already matched `done` when the relay started (the
|
|
35
|
+
* pasted body, an earlier task's done line). Only a line beyond these counts.
|
|
36
|
+
*/
|
|
37
|
+
seen?: string[];
|
|
38
|
+
};
|
|
39
|
+
export type RelayDeps = {
|
|
40
|
+
/** dispatchZswarm with an env; injected in tests. */
|
|
41
|
+
call?: (args: Record<string, unknown>, env: NodeJS.ProcessEnv) => Promise<OpsResult>;
|
|
42
|
+
/** Runs the check script: exit status and stdout. */
|
|
43
|
+
runCheck?: (script: string, env: NodeJS.ProcessEnv) => {
|
|
44
|
+
status: number;
|
|
45
|
+
stdout: string;
|
|
46
|
+
};
|
|
47
|
+
now?: () => number;
|
|
48
|
+
sleep?: (ms: number) => Promise<void>;
|
|
49
|
+
log?: (line: string) => void;
|
|
50
|
+
env?: NodeJS.ProcessEnv;
|
|
51
|
+
/** Delivery retry delays in seconds (ZSWARM_RELAY_DELAYS). */
|
|
52
|
+
delays?: number[];
|
|
53
|
+
};
|
|
54
|
+
export declare function relaysDir(env?: NodeJS.ProcessEnv): string;
|
|
55
|
+
/** Lines of `screen` that match the done regex, as the relay compares them. */
|
|
56
|
+
export declare function doneLines(screen: string, done: string): string[];
|
|
57
|
+
/**
|
|
58
|
+
* The first done line in `screen` that is not accounted for by `seen`,
|
|
59
|
+
* counting repeats: a line seen once still counts when it shows up twice.
|
|
60
|
+
* A regex that only matches across lines cannot be baselined; its match counts.
|
|
61
|
+
*/
|
|
62
|
+
export declare function freshDoneLine(screen: string, done: string, seen?: readonly string[]): string | null;
|
|
63
|
+
/** Fill {id} {status} {note} {match} {output} {pane} in a message template. */
|
|
64
|
+
export declare function fillMessage(template: string, fields: Record<string, string>): string;
|
|
65
|
+
/** Paste `body` into the reply pane, retrying with backoff; false leaves it in undelivered/. */
|
|
66
|
+
export declare function deliverMessage(cfg: RelayConfig, body: string, dir: string, deps?: RelayDeps): Promise<boolean>;
|
|
67
|
+
/** The waiting loop; returns the final status and the message it delivered or left behind. */
|
|
68
|
+
export declare function relayLoop(dir: string, deps?: RelayDeps): Promise<{
|
|
69
|
+
status: RelayStatus;
|
|
70
|
+
delivered: boolean;
|
|
71
|
+
}>;
|
|
72
|
+
export type RelayStartInput = {
|
|
73
|
+
id?: string;
|
|
74
|
+
serve?: string;
|
|
75
|
+
session?: string;
|
|
76
|
+
to: string;
|
|
77
|
+
bodyFile?: string;
|
|
78
|
+
from?: string;
|
|
79
|
+
done: string;
|
|
80
|
+
replyTo: string;
|
|
81
|
+
replySession?: string;
|
|
82
|
+
check?: string;
|
|
83
|
+
message?: string;
|
|
84
|
+
timeoutMin?: number;
|
|
85
|
+
foreground?: boolean;
|
|
86
|
+
env?: NodeJS.ProcessEnv;
|
|
87
|
+
deps?: RelayDeps;
|
|
88
|
+
/** Starts the detached relay; injected in tests. */
|
|
89
|
+
launch?: (dir: string, env: NodeJS.ProcessEnv) => string;
|
|
90
|
+
};
|
|
91
|
+
/** Write the relay directory: config (600), snapshots of the check and template. */
|
|
92
|
+
export declare function prepareRelay(cfg: RelayConfig, sources: {
|
|
93
|
+
check?: string;
|
|
94
|
+
message?: string;
|
|
95
|
+
}, env: NodeJS.ProcessEnv): string;
|
|
96
|
+
/** Remove the token copy once the relay no longer needs it. */
|
|
97
|
+
export declare function scrubRelayToken(dir: string): void;
|
|
98
|
+
/** Start the detached relay so it outlives the caller (and the caller's process group). */
|
|
99
|
+
export declare function launchRelay(dir: string, env?: NodeJS.ProcessEnv): string;
|
|
100
|
+
/** Send the body now, then leave a relay waiting for the done line. */
|
|
101
|
+
export declare function startRelay(input: RelayStartInput): Promise<Record<string, unknown>>;
|
|
102
|
+
/** What `zswarm relay --run <dir>` does inside the detached job. */
|
|
103
|
+
export declare function runRelayDir(dir: string, deps?: RelayDeps): Promise<number>;
|
|
104
|
+
/**
|
|
105
|
+
* The pull path: block until relay `id` has its final message (delivered or
|
|
106
|
+
* not) and return it. Needs no paste, so a busy Zellij cannot lose it.
|
|
107
|
+
*/
|
|
108
|
+
export declare function waitForRelay(id: string, opts?: {
|
|
109
|
+
timeoutMin?: number;
|
|
110
|
+
pollMs?: number;
|
|
111
|
+
env?: NodeJS.ProcessEnv;
|
|
112
|
+
sleep?: (ms: number) => Promise<void>;
|
|
113
|
+
now?: () => number;
|
|
114
|
+
}): Promise<string | null>;
|
|
115
|
+
/** Remove relay directories and undelivered messages older than `days`. */
|
|
116
|
+
export declare function pruneRelays(days?: number, env?: NodeJS.ProcessEnv): string[];
|