@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,120 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# zswarm host install: Node.js, zswarm and Zellij on a Linux or macOS host,
|
|
3
|
+
# as pinned upstream binaries under one prefix, linked into a bin dir.
|
|
4
|
+
# Idempotent; prints one line per step. Run as root (or with write access to
|
|
5
|
+
# the prefix and bin dir). Never touches other users or their services.
|
|
6
|
+
#
|
|
7
|
+
# host-install.sh [--prefix /usr/local/lib/zswarm] [--bin-dir /usr/local/bin]
|
|
8
|
+
# [--node V] [--zellij V] [--zswarm V]
|
|
9
|
+
set -eu
|
|
10
|
+
|
|
11
|
+
PREFIX=/usr/local/lib/zswarm
|
|
12
|
+
BIN=/usr/local/bin
|
|
13
|
+
NODE_VERSION=24.21.0
|
|
14
|
+
ZELLIJ_VERSION=0.45.1
|
|
15
|
+
ZSWARM_VERSION=latest
|
|
16
|
+
|
|
17
|
+
while [ $# -gt 0 ]; do
|
|
18
|
+
case "$1" in
|
|
19
|
+
--prefix) PREFIX="$2"; shift 2 ;;
|
|
20
|
+
--bin-dir) BIN="$2"; shift 2 ;;
|
|
21
|
+
--node) NODE_VERSION="$2"; shift 2 ;;
|
|
22
|
+
--zellij) ZELLIJ_VERSION="$2"; shift 2 ;;
|
|
23
|
+
--zswarm) ZSWARM_VERSION="$2"; shift 2 ;;
|
|
24
|
+
*) echo "host-install: unknown argument $1" >&2; exit 2 ;;
|
|
25
|
+
esac
|
|
26
|
+
done
|
|
27
|
+
|
|
28
|
+
step() { printf '%-10s %s\n' "$1" "$2"; }
|
|
29
|
+
fetch() { curl -fsSL --retry 3 -o "$2" "$1"; }
|
|
30
|
+
sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d' ' -f1; }
|
|
31
|
+
# verify NAME FILE EXPECTED: stop on a mismatch; no expected sum means a
|
|
32
|
+
# version we have no pinned sum for, which installs but says so.
|
|
33
|
+
verify() {
|
|
34
|
+
if [ -z "$3" ]; then
|
|
35
|
+
step "$1" "unverified: no pinned sha256 for this version"
|
|
36
|
+
elif [ "$(sha256 "$2")" != "$3" ]; then
|
|
37
|
+
echo "host-install: $1 sha256 mismatch for $2 (expected $3)" >&2
|
|
38
|
+
exit 1
|
|
39
|
+
fi
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
os="$(uname -s)"; arch="$(uname -m)"
|
|
43
|
+
case "$os/$arch" in
|
|
44
|
+
Linux/x86_64) node_os=linux; node_arch=x64; zellij_target=x86_64-unknown-linux-musl ;;
|
|
45
|
+
Linux/aarch64|Linux/arm64) node_os=linux; node_arch=arm64; zellij_target=aarch64-unknown-linux-musl ;;
|
|
46
|
+
Darwin/arm64) node_os=darwin; node_arch=arm64; zellij_target=aarch64-apple-darwin ;;
|
|
47
|
+
Darwin/x86_64) node_os=darwin; node_arch=x64; zellij_target=x86_64-apple-darwin ;;
|
|
48
|
+
*) echo "host-install: unsupported platform $os/$arch" >&2; exit 2 ;;
|
|
49
|
+
esac
|
|
50
|
+
# Pinned sums for the default versions. Node: the release tarball
|
|
51
|
+
# (nodejs.org SHASUMS256.txt). Zellij: the extracted binary (the release's
|
|
52
|
+
# .sha256sum files hash the binary, not the tarball).
|
|
53
|
+
node_sha=; zellij_sha=
|
|
54
|
+
if [ "$NODE_VERSION" = 24.21.0 ]; then
|
|
55
|
+
case "$node_os-$node_arch" in
|
|
56
|
+
linux-x64) node_sha=6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff ;;
|
|
57
|
+
linux-arm64) node_sha=724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5 ;;
|
|
58
|
+
darwin-arm64) node_sha=bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057 ;;
|
|
59
|
+
darwin-x64) node_sha=1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097 ;;
|
|
60
|
+
esac
|
|
61
|
+
fi
|
|
62
|
+
if [ "$ZELLIJ_VERSION" = 0.45.1 ]; then
|
|
63
|
+
case "$zellij_target" in
|
|
64
|
+
x86_64-unknown-linux-musl) zellij_sha=d006c521dcb475a6005d741e9dd7c5758e5a23b28dd60a5c10cebfa4876319dd ;;
|
|
65
|
+
aarch64-unknown-linux-musl) zellij_sha=2a2c0621e6f3b11ecbb05d66939a48000a2508604620aa20727a5fd61c49f451 ;;
|
|
66
|
+
aarch64-apple-darwin) zellij_sha=ca5f9333735bdbc59a621f1d8ed8e24798845302a28ff175d253d4793d5a4a2c ;;
|
|
67
|
+
x86_64-apple-darwin) zellij_sha=e3afe876c04cb83ca3f68cb939113ec7b1abe5fb46c13727c102a64afcb4b7d4 ;;
|
|
68
|
+
esac
|
|
69
|
+
fi
|
|
70
|
+
command -v curl >/dev/null 2>&1 || { echo "host-install: needs curl" >&2; exit 2; }
|
|
71
|
+
command -v tar >/dev/null 2>&1 || { echo "host-install: needs tar" >&2; exit 2; }
|
|
72
|
+
mkdir -p "$PREFIX" "$BIN"
|
|
73
|
+
|
|
74
|
+
# --- Node.js ---------------------------------------------------------------
|
|
75
|
+
node_dir="$PREFIX/node-$NODE_VERSION"
|
|
76
|
+
if [ ! -x "$node_dir/bin/node" ]; then
|
|
77
|
+
tmp="$(mktemp -d)"
|
|
78
|
+
fetch "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-$node_os-$node_arch.tar.gz" "$tmp/node.tgz"
|
|
79
|
+
verify node "$tmp/node.tgz" "$node_sha"
|
|
80
|
+
mkdir -p "$node_dir" && tar -xzf "$tmp/node.tgz" -C "$node_dir" --strip-components 1
|
|
81
|
+
rm -rf "$tmp"
|
|
82
|
+
step node "installed $NODE_VERSION"
|
|
83
|
+
else
|
|
84
|
+
step node "present $NODE_VERSION"
|
|
85
|
+
fi
|
|
86
|
+
ln -sfn "$node_dir/bin/node" "$BIN/node"
|
|
87
|
+
|
|
88
|
+
# --- zswarm (npm, own prefix) ------------------------------------------------
|
|
89
|
+
npm_prefix="$PREFIX/npm"
|
|
90
|
+
have="$(PATH="$node_dir/bin:$PATH" npm ls -g --prefix "$npm_prefix" zswarm --depth=0 2>/dev/null | sed -n 's/.*zswarm@\([0-9][0-9A-Za-z.+-]*\).*/\1/p' || true)"
|
|
91
|
+
if [ "$ZSWARM_VERSION" = latest ] || [ "$have" != "$ZSWARM_VERSION" ]; then
|
|
92
|
+
PATH="$node_dir/bin:$PATH" npm install -g --prefix "$npm_prefix" "zswarm@$ZSWARM_VERSION" >/dev/null 2>&1
|
|
93
|
+
now="$(PATH="$node_dir/bin:$PATH" npm ls -g --prefix "$npm_prefix" zswarm --depth=0 2>/dev/null | sed -n 's/.*zswarm@\([0-9][0-9A-Za-z.+-]*\).*/\1/p' || true)"
|
|
94
|
+
if [ "$now" = "$have" ]; then step zswarm "present $now"; else step zswarm "installed $now (was ${have:-none})"; fi
|
|
95
|
+
else
|
|
96
|
+
step zswarm "present $have"
|
|
97
|
+
fi
|
|
98
|
+
ln -sfn "$npm_prefix/bin/zswarm" "$BIN/zswarm"
|
|
99
|
+
ln -sfn "$npm_prefix/bin/zswarm-mcp" "$BIN/zswarm-mcp"
|
|
100
|
+
|
|
101
|
+
# --- Zellij ------------------------------------------------------------------
|
|
102
|
+
have_zellij="$("$BIN/zellij" --version 2>/dev/null | awk '{print $2}' || true)"
|
|
103
|
+
if [ "$have_zellij" != "$ZELLIJ_VERSION" ]; then
|
|
104
|
+
tmp="$(mktemp -d)"
|
|
105
|
+
fetch "https://github.com/zellij-org/zellij/releases/download/v$ZELLIJ_VERSION/zellij-$zellij_target.tar.gz" "$tmp/z.tgz"
|
|
106
|
+
tar -xzf "$tmp/z.tgz" -C "$tmp"
|
|
107
|
+
verify zellij "$tmp/zellij" "$zellij_sha"
|
|
108
|
+
cp "$tmp/zellij" "$BIN/zellij.new" && chmod 0755 "$BIN/zellij.new" && mv "$BIN/zellij.new" "$BIN/zellij"
|
|
109
|
+
rm -rf "$tmp"
|
|
110
|
+
step zellij "installed $ZELLIJ_VERSION (was ${have_zellij:-none})"
|
|
111
|
+
else
|
|
112
|
+
step zellij "present $have_zellij"
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# --- Python (zswarm slot needs 3.9+; not installed here) ----------------------
|
|
116
|
+
if python3 -c 'import sys; sys.exit(sys.version_info < (3, 9))' 2>/dev/null; then
|
|
117
|
+
step python3 "present $(python3 -c 'import platform; print(platform.python_version())')"
|
|
118
|
+
else
|
|
119
|
+
step python3 "MISSING (zswarm slot needs Python 3.9+; install it with the system package manager)"
|
|
120
|
+
fi
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# zswarm host doctor, host side: read-only facts as KEY=VALUE lines.
|
|
3
|
+
# host-report.sh [session]
|
|
4
|
+
PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:$HOME/.local/bin"
|
|
5
|
+
session="${1:-}"
|
|
6
|
+
v() { "$@" 2>/dev/null | head -1; }
|
|
7
|
+
echo "os=$(uname -s)"
|
|
8
|
+
echo "arch=$(uname -m)"
|
|
9
|
+
echo "user=$(id -un)"
|
|
10
|
+
echo "node=$(v node --version | sed 's/^v//')"
|
|
11
|
+
zv="$(v zswarm --version)"
|
|
12
|
+
case "$zv" in
|
|
13
|
+
[0-9]*) ;;
|
|
14
|
+
*) command -v zswarm >/dev/null 2>&1 && zv="unknown (older than zswarm --version)" || zv="" ;;
|
|
15
|
+
esac
|
|
16
|
+
echo "zswarm=$zv"
|
|
17
|
+
echo "zellij=$(v zellij --version | awk '{print $2}')"
|
|
18
|
+
echo "python3=$(v python3 -c 'import platform; print(platform.python_version())')"
|
|
19
|
+
echo "disk_home_gb=$(df -Pk "$HOME" 2>/dev/null | awk 'NR==2 {printf "%d", $4/1048576}')"
|
|
20
|
+
echo "disk_tmp_gb=$(df -Pk "${TMPDIR:-/tmp}" 2>/dev/null | awk 'NR==2 {printf "%d", $4/1048576}')"
|
|
21
|
+
echo "sessions=$(zellij list-sessions --no-formatting 2>/dev/null | grep -v EXITED | awk '{print $1}' | tr '\n' ' ')"
|
|
22
|
+
if [ "$(uname -s)" = Linux ]; then
|
|
23
|
+
echo "linger=$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null)"
|
|
24
|
+
systemctl --user list-units --all --plain --no-legend 'zswarm-*' 2>/dev/null | awk '{print "service=" $1 " " $3 " " $4}'
|
|
25
|
+
else
|
|
26
|
+
launchctl list 2>/dev/null | awk '$3 ~ /^zswarm\./ {print "service=" $3 " pid=" $1 " last=" $2}'
|
|
27
|
+
fi
|
|
28
|
+
[ -n "$session" ] && echo "session_wanted=$session"
|
|
29
|
+
exit 0
|
package/assets/slot.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""zswarm slot: run one build or test command inside a machine-wide build slot.
|
|
3
|
+
|
|
4
|
+
zswarm slot [--project NAME] [--] COMMAND [ARG ...]
|
|
5
|
+
zswarm slot --status
|
|
6
|
+
zswarm slot --setup --slots N [--jobs J] [--group G] (root)
|
|
7
|
+
zswarm slot --setup-project NAME --cap M [--group G] (root)
|
|
8
|
+
|
|
9
|
+
A host has N build slots and each project may be capped lower. The command
|
|
10
|
+
runs only while it holds one global slot and, for a capped project, one of
|
|
11
|
+
its project's slots; both are flock(2) locks (msvcrt byte locks on Windows)
|
|
12
|
+
held by this process while it waits for the command. The descriptors are close-on-exec, so a
|
|
13
|
+
daemon the command leaves running (a compiler server, a cache) never
|
|
14
|
+
inherits them, and the slot frees when the command exits or slot dies. A
|
|
15
|
+
slot run inside another (ZSWARM_SLOT set) reuses the outer slot instead of
|
|
16
|
+
deadlocking on a second one.
|
|
17
|
+
|
|
18
|
+
Unless already set, the command gets CARGO_BUILD_JOBS, NEXTEST_TEST_THREADS
|
|
19
|
+
and MAKEFLAGS sized to the per-slot job count, and runs at nice 5 so other
|
|
20
|
+
work on the machine (a CI runner, say) keeps priority.
|
|
21
|
+
|
|
22
|
+
Layout:
|
|
23
|
+
<etc>/slots.conf slots=N jobs=J
|
|
24
|
+
<etc>/projects/<name>.conf slots=M
|
|
25
|
+
<dir>/global-<i>.lock, <dir>/<name>-<i>.lock
|
|
26
|
+
<etc> is ZSWARM_SLOT_ETC (default /etc/zswarm, %ProgramData%\\zswarm\\etc),
|
|
27
|
+
<dir> is ZSWARM_SLOT_DIR (default /var/lib/zswarm/slots, %ProgramData%\\zswarm\\slots).
|
|
28
|
+
The project defaults to ZSWARM_SLOT_PROJECT, else the user name.
|
|
29
|
+
|
|
30
|
+
This is cooperative capacity control, not isolation: a process that skips
|
|
31
|
+
slot is not stopped.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations # macOS ships Python 3.9
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import signal
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
import time
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
WINDOWS = os.name == "nt"
|
|
45
|
+
if WINDOWS:
|
|
46
|
+
import msvcrt
|
|
47
|
+
else:
|
|
48
|
+
import fcntl
|
|
49
|
+
import grp
|
|
50
|
+
import pwd
|
|
51
|
+
|
|
52
|
+
_win_root = Path(os.environ.get("ProgramData") or "C:/ProgramData") / "zswarm"
|
|
53
|
+
ETC = Path(os.environ.get("ZSWARM_SLOT_ETC") or (_win_root / "etc" if WINDOWS else "/etc/zswarm"))
|
|
54
|
+
SLOTS = Path(os.environ.get("ZSWARM_SLOT_DIR") or (_win_root / "slots" if WINDOWS else "/var/lib/zswarm/slots"))
|
|
55
|
+
NAME = re.compile(r"[a-z0-9][a-z0-9_-]{0,31}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def die(msg: str, code: int = 2) -> None:
|
|
59
|
+
print(f"slot: {msg}", file=sys.stderr)
|
|
60
|
+
raise SystemExit(code)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def read_conf(path: Path) -> dict[str, int]:
|
|
64
|
+
values: dict[str, int] = {}
|
|
65
|
+
try:
|
|
66
|
+
text = path.read_text()
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
return values
|
|
69
|
+
for line in text.splitlines():
|
|
70
|
+
line = line.split("#", 1)[0].strip()
|
|
71
|
+
if not line:
|
|
72
|
+
continue
|
|
73
|
+
key, _, value = line.partition("=")
|
|
74
|
+
try:
|
|
75
|
+
values[key.strip()] = int(value.strip())
|
|
76
|
+
except ValueError:
|
|
77
|
+
die(f"{path}: {key.strip()} is not an integer")
|
|
78
|
+
return values
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def bounded(value: int, what: str) -> int:
|
|
82
|
+
if not 1 <= value <= 64:
|
|
83
|
+
die(f"{what}={value} is outside 1..64")
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def host_conf() -> tuple[int, int]:
|
|
88
|
+
conf = read_conf(ETC / "slots.conf")
|
|
89
|
+
if "slots" not in conf:
|
|
90
|
+
die(f"{ETC / 'slots.conf'} missing or has no slots= line (run zswarm slot --setup)")
|
|
91
|
+
return bounded(conf["slots"], "slots"), bounded(conf.get("jobs", 4), "jobs")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def project_cap(project: str, host_slots: int) -> int | None:
|
|
95
|
+
"""The project's cap, or None when it has no conf (only the host slots apply)."""
|
|
96
|
+
path = ETC / "projects" / f"{project}.conf"
|
|
97
|
+
if not path.exists():
|
|
98
|
+
return None
|
|
99
|
+
conf = read_conf(path)
|
|
100
|
+
return min(bounded(conf.get("slots", host_slots), "project slots"), host_slots)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def default_project() -> str:
|
|
104
|
+
if os.environ.get("ZSWARM_SLOT_PROJECT"):
|
|
105
|
+
return os.environ["ZSWARM_SLOT_PROJECT"]
|
|
106
|
+
return os.environ.get("USERNAME", "unknown").lower() if WINDOWS else pwd.getpwuid(os.getuid()).pw_name
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def lock_nb(fd: int) -> bool:
|
|
110
|
+
"""Take an exclusive lock without waiting; False if someone holds it."""
|
|
111
|
+
try:
|
|
112
|
+
if WINDOWS:
|
|
113
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
114
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
115
|
+
else:
|
|
116
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
117
|
+
return True
|
|
118
|
+
except OSError: # BlockingIOError on POSIX, PermissionError on Windows
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def lock_paths(prefix: str, count: int) -> list[Path]:
|
|
123
|
+
return [SLOTS / f"{prefix}-{i}.lock" for i in range(count)]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def try_lock(paths: list[Path]) -> tuple[int, Path] | None:
|
|
127
|
+
if not any(p.exists() for p in paths):
|
|
128
|
+
die(f"no lock files {paths[0].name}.. in {SLOTS} (run zswarm slot --setup / --setup-project)")
|
|
129
|
+
for path in paths:
|
|
130
|
+
try:
|
|
131
|
+
flags = os.O_RDWR | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NOINHERIT", 0)
|
|
132
|
+
fd = os.open(path, flags)
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
continue # count raised before setup created the file
|
|
135
|
+
except PermissionError:
|
|
136
|
+
die(f"no access to {path} (not in the slot group?)")
|
|
137
|
+
if not lock_nb(fd):
|
|
138
|
+
os.close(fd)
|
|
139
|
+
continue
|
|
140
|
+
return fd, path
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def note_holder(fd: int, project: str, command: list[str]) -> None:
|
|
145
|
+
# The lock file itself carries who holds it, for --status.
|
|
146
|
+
line = f"{os.getpid()} {project} {int(time.time())} {' '.join(command)[:200]}\n"
|
|
147
|
+
if WINDOWS:
|
|
148
|
+
return # byte 0 is locked; the holder note lives only on POSIX hosts
|
|
149
|
+
os.ftruncate(fd, 0)
|
|
150
|
+
os.pwrite(fd, line.encode(), 0)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def status() -> int:
|
|
154
|
+
host_slots, jobs = host_conf()
|
|
155
|
+
print(f"host: {host_slots} slots, {jobs} jobs per slot")
|
|
156
|
+
busy = 0
|
|
157
|
+
for path in lock_paths("global", host_slots):
|
|
158
|
+
held = try_lock([path])
|
|
159
|
+
if held:
|
|
160
|
+
os.close(held[0])
|
|
161
|
+
print(f" {path.name}: free")
|
|
162
|
+
else:
|
|
163
|
+
busy += 1
|
|
164
|
+
try:
|
|
165
|
+
holder = path.read_text().strip() or "?"
|
|
166
|
+
except OSError:
|
|
167
|
+
holder = "(held)"
|
|
168
|
+
print(f" {path.name}: busy {holder}")
|
|
169
|
+
print(f"busy {busy}/{host_slots}")
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def lock_file(path: Path, gid: int | None) -> None:
|
|
174
|
+
# Without a group every user must be able to open the lock read-write
|
|
175
|
+
# (the holder note is written through it). Slots are cooperative, so
|
|
176
|
+
# world-writable lock files cost nothing a user could not already do.
|
|
177
|
+
mode = 0o660 if gid is not None else 0o666
|
|
178
|
+
if not path.exists():
|
|
179
|
+
path.touch(mode=mode)
|
|
180
|
+
if gid is not None:
|
|
181
|
+
os.chown(path, 0, gid)
|
|
182
|
+
if not WINDOWS:
|
|
183
|
+
os.chmod(path, mode)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def setup_dirs(group: str | None) -> int | None:
|
|
187
|
+
if not WINDOWS and os.geteuid() != 0:
|
|
188
|
+
die("--setup needs root")
|
|
189
|
+
gid = None
|
|
190
|
+
if group and not WINDOWS:
|
|
191
|
+
try:
|
|
192
|
+
gid = grp.getgrnam(group).gr_gid
|
|
193
|
+
except KeyError:
|
|
194
|
+
die(f"group {group} does not exist (create it first)")
|
|
195
|
+
(ETC / "projects").mkdir(parents=True, exist_ok=True)
|
|
196
|
+
SLOTS.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
if not WINDOWS:
|
|
198
|
+
os.chmod(ETC, 0o755)
|
|
199
|
+
os.chmod(ETC / "projects", 0o755)
|
|
200
|
+
if gid is not None:
|
|
201
|
+
os.chown(SLOTS, 0, gid)
|
|
202
|
+
os.chmod(SLOTS, 0o770 if gid is not None else 0o755)
|
|
203
|
+
return gid
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def write_conf(path: Path, text: str) -> None:
|
|
207
|
+
tmp = path.with_name(path.name + ".new")
|
|
208
|
+
tmp.write_text(text)
|
|
209
|
+
if not WINDOWS:
|
|
210
|
+
os.chmod(tmp, 0o644)
|
|
211
|
+
os.replace(tmp, path)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def option(argv: list[str], name: str, default: str | None = None) -> str | None:
|
|
215
|
+
if name in argv:
|
|
216
|
+
i = argv.index(name)
|
|
217
|
+
if i + 1 >= len(argv):
|
|
218
|
+
die(f"{name} needs a value")
|
|
219
|
+
return argv[i + 1]
|
|
220
|
+
return default
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def setup(argv: list[str]) -> int:
|
|
224
|
+
slots = bounded(int(option(argv, "--slots") or die("--setup needs --slots N")), "slots")
|
|
225
|
+
jobs = bounded(int(option(argv, "--jobs", "4")), "jobs")
|
|
226
|
+
gid = setup_dirs(option(argv, "--group"))
|
|
227
|
+
write_conf(ETC / "slots.conf", f"slots={slots}\njobs={jobs}\n")
|
|
228
|
+
for path in lock_paths("global", slots):
|
|
229
|
+
lock_file(path, gid)
|
|
230
|
+
print(f"slot: {slots} slots x {jobs} jobs in {SLOTS}")
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def setup_project(argv: list[str]) -> int:
|
|
235
|
+
project = option(argv, "--setup-project")
|
|
236
|
+
if not project or not NAME.fullmatch(project):
|
|
237
|
+
die(f"invalid project name {project!r}")
|
|
238
|
+
cap = bounded(int(option(argv, "--cap") or die("--setup-project needs --cap M")), "cap")
|
|
239
|
+
gid = setup_dirs(option(argv, "--group"))
|
|
240
|
+
write_conf(ETC / "projects" / f"{project}.conf", f"slots={cap}\n")
|
|
241
|
+
for path in lock_paths(project, cap):
|
|
242
|
+
lock_file(path, gid)
|
|
243
|
+
print(f"slot: project {project} capped at {cap}")
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main(argv: list[str]) -> int:
|
|
248
|
+
if argv[:1] == ["--status"]:
|
|
249
|
+
return status()
|
|
250
|
+
if argv[:1] == ["--setup"]:
|
|
251
|
+
return setup(argv)
|
|
252
|
+
if argv[:1] == ["--setup-project"]:
|
|
253
|
+
return setup_project(argv)
|
|
254
|
+
project = None
|
|
255
|
+
if argv[:1] == ["--project"]:
|
|
256
|
+
if len(argv) < 2:
|
|
257
|
+
die("--project needs a name")
|
|
258
|
+
project, argv = argv[1], argv[2:]
|
|
259
|
+
if argv[:1] == ["--"]:
|
|
260
|
+
argv = argv[1:]
|
|
261
|
+
if not argv:
|
|
262
|
+
die("usage: zswarm slot [--project NAME] [--] COMMAND [ARG ...] | --status | --setup | --setup-project")
|
|
263
|
+
if os.environ.get("ZSWARM_SLOT"):
|
|
264
|
+
# Already inside a slot (a script run under slot calls slot again):
|
|
265
|
+
# the parent's slot covers this command; locking again would deadlock.
|
|
266
|
+
return run(argv, dict(os.environ), renice=False)
|
|
267
|
+
project = project or default_project()
|
|
268
|
+
if not NAME.fullmatch(project):
|
|
269
|
+
die(f"invalid project name {project!r}")
|
|
270
|
+
|
|
271
|
+
host_slots, jobs = host_conf()
|
|
272
|
+
cap = project_cap(project, host_slots)
|
|
273
|
+
project_locks = lock_paths(project, cap) if cap else []
|
|
274
|
+
global_locks = lock_paths("global", host_slots)
|
|
275
|
+
|
|
276
|
+
announced = False
|
|
277
|
+
while True:
|
|
278
|
+
mine = try_lock(project_locks) if project_locks else None
|
|
279
|
+
if mine or not project_locks:
|
|
280
|
+
shared = try_lock(global_locks)
|
|
281
|
+
if shared:
|
|
282
|
+
break
|
|
283
|
+
if mine:
|
|
284
|
+
os.close(mine[0])
|
|
285
|
+
if not announced:
|
|
286
|
+
capped = f"cap {cap}, " if cap else ""
|
|
287
|
+
print(f"slot: waiting for a build slot ({project} {capped}host {host_slots})", file=sys.stderr)
|
|
288
|
+
announced = True
|
|
289
|
+
time.sleep(float(os.environ.get("ZSWARM_SLOT_POLL", "1")))
|
|
290
|
+
|
|
291
|
+
for held in (mine, shared):
|
|
292
|
+
if held:
|
|
293
|
+
note_holder(held[0], project, argv)
|
|
294
|
+
|
|
295
|
+
env = dict(os.environ)
|
|
296
|
+
env.setdefault("CARGO_BUILD_JOBS", str(jobs))
|
|
297
|
+
env.setdefault("NEXTEST_TEST_THREADS", str(jobs))
|
|
298
|
+
env.setdefault("MAKEFLAGS", f"-j{jobs}")
|
|
299
|
+
env["ZSWARM_SLOT"] = shared[1].name
|
|
300
|
+
return run(argv, env)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def run(argv: list[str], env: dict[str, str], renice: bool = True) -> int:
|
|
304
|
+
"""Run the command as a child and wait. The lock descriptors stay in this
|
|
305
|
+
process (close-on-exec), so a daemon the command leaves behind never
|
|
306
|
+
inherits them and the slot frees when the command exits."""
|
|
307
|
+
if renice and not WINDOWS:
|
|
308
|
+
try:
|
|
309
|
+
os.nice(5)
|
|
310
|
+
except OSError:
|
|
311
|
+
pass
|
|
312
|
+
try:
|
|
313
|
+
child = subprocess.Popen(argv, env=env)
|
|
314
|
+
except FileNotFoundError:
|
|
315
|
+
die(f"{argv[0]}: command not found", 127)
|
|
316
|
+
for sig in (signal.SIGINT, signal.SIGTERM, getattr(signal, "SIGHUP", None)):
|
|
317
|
+
if sig is not None:
|
|
318
|
+
signal.signal(sig, lambda signum, _frame: child.send_signal(signum))
|
|
319
|
+
code = child.wait()
|
|
320
|
+
return 128 - code if code < 0 else code
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
sys.exit(main(sys.argv[1:]))
|
package/dist/exec.d.ts
CHANGED
|
@@ -21,7 +21,8 @@ export type ExecOptions = {
|
|
|
21
21
|
* Stop as soon as the accumulated stdout satisfies this, instead of waiting
|
|
22
22
|
* for the process to exit. `zellij pipe` answers in milliseconds but stays
|
|
23
23
|
* resident when it has no terminal, so waiting for exit means waiting for the
|
|
24
|
-
* timeout.
|
|
24
|
+
* timeout. Replies are JSON lines, so this is only consulted when a chunk
|
|
25
|
+
* could have completed one (see `mayCompleteLine`).
|
|
25
26
|
*/
|
|
26
27
|
until?: (stdout: string) => boolean;
|
|
27
28
|
/** Kill the child when aborted (MCP cancellation). */
|
package/dist/exec.js
CHANGED
|
@@ -322,6 +322,14 @@ export function createSshExec(target, env, options = {}) {
|
|
|
322
322
|
};
|
|
323
323
|
return exec;
|
|
324
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* A JSON line is complete once its newline arrives, or once the stream ends on
|
|
327
|
+
* `}` (a last line written without one). Asking `until` about every chunk
|
|
328
|
+
* re-parses a multi-megabyte single-line scrollback reply once per 64KB read.
|
|
329
|
+
*/
|
|
330
|
+
function mayCompleteLine(chunk) {
|
|
331
|
+
return chunk.includes("\n") || chunk.trimEnd().endsWith("}");
|
|
332
|
+
}
|
|
325
333
|
/** Read stdout until the answer is in hand, then stop caring about the child. */
|
|
326
334
|
function runUntil(binPath, args, env, options) {
|
|
327
335
|
return new Promise((resolve) => {
|
|
@@ -361,13 +369,18 @@ function runUntil(binPath, args, env, options) {
|
|
|
361
369
|
child.on("error", (err) => {
|
|
362
370
|
finish(localSpawnResult(err, binPath));
|
|
363
371
|
});
|
|
372
|
+
// Decode across reads: a multi-byte character split between two chunks
|
|
373
|
+
// must not come out as U+FFFD in the screen text.
|
|
374
|
+
child.stdout.setEncoding("utf8");
|
|
375
|
+
child.stderr.setEncoding("utf8");
|
|
364
376
|
child.stdout.on("data", (chunk) => {
|
|
365
|
-
stdout += chunk
|
|
366
|
-
if (options.until(stdout))
|
|
377
|
+
stdout += chunk;
|
|
378
|
+
if (mayCompleteLine(chunk) && options.until(stdout)) {
|
|
367
379
|
finish({ code: 0, stdout, stderr });
|
|
380
|
+
}
|
|
368
381
|
});
|
|
369
382
|
child.stderr.on("data", (chunk) => {
|
|
370
|
-
stderr += chunk
|
|
383
|
+
stderr += chunk;
|
|
371
384
|
});
|
|
372
385
|
child.on("close", (code) => finish({ code: code ?? 0, stdout, stderr }));
|
|
373
386
|
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host commands: CLI-only (not MCP ops), because they run long, pass argv
|
|
3
|
+
* through or print plain text. `zswarm <command> --help` prints the usage.
|
|
4
|
+
*/
|
|
5
|
+
/** `--flag value` pairs and positionals; boolean flags are listed in `bools`. */
|
|
6
|
+
export declare function parseHostArgs(argv: string[], bools?: string[]): {
|
|
7
|
+
flags: Record<string, string | true>;
|
|
8
|
+
rest: string[];
|
|
9
|
+
};
|
|
10
|
+
export declare const HOST_COMMANDS: readonly string[];
|
|
11
|
+
export declare function hostUsage(): string;
|
|
12
|
+
/** Run a host command; resolves to the process exit code. */
|
|
13
|
+
export declare function runHostCommand(argv: string[]): Promise<number>;
|