@vibelord/lanes 1.0.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/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lanes-install.mjs +158 -0
- package/lib/plan.mjs +65 -0
- package/package.json +37 -0
- package/payload/lanes/README.md +152 -0
- package/payload/lanes/VERSION +1 -0
- package/payload/lanes/bin/lanes +1035 -0
- package/payload/lanes/client/ghostty/common.conf +22 -0
- package/payload/lanes/client/ghostty/macos.conf +6 -0
- package/payload/lanes/client/ghostty/omarchy.conf +19 -0
- package/payload/lanes/client/install.sh +16 -0
- package/payload/lanes/client/lanes-launch +315 -0
- package/payload/lanes/client/lanes-upload +147 -0
- package/payload/lanes/client/lanes-window +36 -0
- package/payload/lanes/client/macos-helper.applescript +14 -0
- package/payload/lanes/client/omarchy/discover.sh +56 -0
- package/payload/lanes/client/omarchy/lanes.conf +5 -0
- package/payload/lanes/client/omarchy/lanes.desktop +9 -0
- package/payload/lanes/client/omarchy/lanes.lua +14 -0
- package/payload/lanes/client/ssh.stanza.in +54 -0
- package/payload/lanes/container/env.sh +16 -0
- package/payload/lanes/container/lane +268 -0
- package/payload/lanes/container/lane-run +34 -0
- package/payload/lanes/docs/ARCHITECTURE.md +93 -0
- package/payload/lanes/docs/HOST-GUARD.md +108 -0
- package/payload/lanes/docs/INSTALL.md +192 -0
- package/payload/lanes/docs/RUNBOOK.md +385 -0
- package/payload/lanes/docs/SHARING.md +124 -0
- package/payload/lanes/docs/ZMX-RING0.md +51 -0
- package/payload/lanes/docs/images/desktop.png +0 -0
- package/payload/lanes/docs/images/iphone.png +0 -0
- package/payload/lanes/holders/exec.sh +21 -0
- package/payload/lanes/holders/tmux.sh +111 -0
- package/payload/lanes/holders/zmx.sh +22 -0
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-phone.sh +43 -0
- package/payload/lanes/install/enable-web.sh +16 -0
- package/payload/lanes/install/host-install.sh +72 -0
- package/payload/lanes/install/services.py +61 -0
- package/payload/lanes/install.sh +603 -0
- package/payload/lanes/libexec/cursor_commit.py +159 -0
- package/payload/lanes/libexec/cursor_sync.py +166 -0
- package/payload/lanes/libexec/guard.py +231 -0
- package/payload/lanes/libexec/lanes_lib.py +610 -0
- package/payload/lanes/libexec/legacy_sessions.py +130 -0
- package/payload/lanes/libexec/session_actions.py +276 -0
- package/payload/lanes/libexec/sidebar +8 -0
- package/payload/lanes/libexec/sidebar.py +872 -0
- package/payload/lanes/libexec/statusd.py +764 -0
- package/payload/lanes/libexec/tmpscan.py +921 -0
- package/payload/lanes/libexec/view.sh +179 -0
- package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
- package/payload/lanes/site/README.md +28 -0
- package/payload/lanes/site/beyondwork/known_hosts +1 -0
- package/payload/lanes/site/beyondwork/site.conf +11 -0
- package/payload/lanes/site/site.example.conf +10 -0
- package/payload/lanes/sudo/host-guard.sh +356 -0
- package/payload/lanes/sudo/one-time-root.sh +32 -0
- package/payload/lanes/systemd/lanes-ensure.service +7 -0
- package/payload/lanes/systemd/lanes-ensure.timer +10 -0
- package/payload/lanes/systemd/lanes-gc.service +7 -0
- package/payload/lanes/systemd/lanes-gc.timer +9 -0
- package/payload/lanes/systemd/lanes-guard.service +8 -0
- package/payload/lanes/systemd/lanes-guard.timer +10 -0
- package/payload/lanes/systemd/lanes-mobile.service +23 -0
- package/payload/lanes/systemd/lanes-statusd.service +15 -0
- package/payload/lanes/systemd/lanes-sync.service +10 -0
- package/payload/lanes/systemd/lanes-sync.timer +10 -0
- package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
- package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
- package/payload/lanes/systemd/lanes-web.service +13 -0
- package/payload/lanes/systemd/lanes.slice +11 -0
- package/payload/lanes/web/Caddyfile +37 -0
- package/payload/lanes/web/README.md +35 -0
- package/payload/lanes/web/icon-192.png +0 -0
- package/payload/lanes/web/icon.svg +4 -0
- package/payload/lanes/web/index.html +30 -0
- package/payload/lanes/web/manifest.webmanifest +15 -0
- package/payload/lanes/web/mobile.css +301 -0
- package/payload/lanes/web/mobile.js +367 -0
- package/payload/lanes/workspaces.example/README.md +27 -0
- package/payload/lanes/workspaces.example/catalog.json +7 -0
- package/payload/lanes/workspaces.example/commit-targets.json +8 -0
- package/payload/lanes/workspaces.example/terminals.example.json +19 -0
- package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
- package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
- package/payload/lanes/zellij/config.kdl +360 -0
- package/payload/manifest.json +31 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Shared helpers for the lanes host server. Python 3 stdlib only.
|
|
2
|
+
|
|
3
|
+
Safety rules encoded here (see docs/ARCHITECTURE.md):
|
|
4
|
+
* Observation and attachment never create or stop lanes. Explicit New/Close actions are separate.
|
|
5
|
+
* tmux is always invoked with -N (never start a server) and a scrubbed environment.
|
|
6
|
+
* In-container sockets are reached through /proc/<container-init-pid>/root, anchored on the
|
|
7
|
+
container's init pid (stable across tmux-server restarts), never through docker exec.
|
|
8
|
+
"""
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import unicodedata
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
20
|
+
STATE = Path(os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'))
|
|
21
|
+
SHARE = Path(os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def workspaces_dir():
|
|
25
|
+
"""SITE data — which boards exist and where their terminal registries live. Not part of the product:
|
|
26
|
+
$LANES_WORKSPACES, else ~/.config/lanes/workspaces (if it holds a catalog), else <repo>/workspaces."""
|
|
27
|
+
if os.environ.get('LANES_WORKSPACES'):
|
|
28
|
+
return Path(os.environ['LANES_WORKSPACES'])
|
|
29
|
+
site = Path.home() / '.config/lanes/workspaces'
|
|
30
|
+
return site if (site / 'catalog.json').exists() else REPO / 'workspaces'
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
CONTAINER_HOME = '/home/coder'
|
|
34
|
+
CONTAINER_UID = 1000
|
|
35
|
+
|
|
36
|
+
LANE_OPEN = '/.lz/bin/lane open ' # the uniform Cursor-facing entry point written by `lanes cursor-sync`
|
|
37
|
+
IDENT = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.-]*$')
|
|
38
|
+
TMUX_SESSION = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_. -]*$')
|
|
39
|
+
SAFE_PATH = re.compile(r'^/[A-Za-z0-9_./-]+$')
|
|
40
|
+
|
|
41
|
+
# The Throttle Studio Host's OWN grouped session names, mirroring `studio_host::tmux::WebSession::channel_of`
|
|
42
|
+
# exactly: `view-<studio token>-<channel>` as `for_channel` mints it (the token is `[A-Za-z0-9_-]` and may
|
|
43
|
+
# itself carry `-`, so the channel is the segment after the LAST `-`), or the legacy `web-<channel>`. The
|
|
44
|
+
# channel is canonical decimal digits inside u32. A PREFIX test is deliberately NOT the rule: `view-notes`,
|
|
45
|
+
# `viewer-1`, `web-dev` and `web-007` are operator names the Host never mints — and therefore never un-flags
|
|
46
|
+
# and never sweeps (DEF-980).
|
|
47
|
+
HOST_VIEW_SESSION = re.compile(r'(?:view-[A-Za-z0-9_-]+|web)-(0|[1-9][0-9]*)')
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_host_view(session):
|
|
51
|
+
"""Whether `session` is a name the Studio Host mints for a browser attach. Pure, and the mirror of
|
|
52
|
+
`WebSession::channel_of` — the whole grammar, never the `view-`/`web-` prefix."""
|
|
53
|
+
found = HOST_VIEW_SESSION.fullmatch(session or '')
|
|
54
|
+
return bool(found) and int(found.group(1)) <= 0xFFFFFFFF
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def grouped_members(listing, session):
|
|
58
|
+
"""The sessions grouped onto `session` (sharing its windows), split into the ones the Studio Host MINTS
|
|
59
|
+
and the strangers — pure, so the rule is unit-testable without a tmux server. `listing` is
|
|
60
|
+
`list-sessions -F '#{session_name}\\t#{session_group}'` output.
|
|
61
|
+
|
|
62
|
+
tmux shares a group's windows between its sessions, so killing the group LEADER only unlinks the leader's
|
|
63
|
+
winlinks: every window and every process keeps running under the grouped member (MEASURED, tmux 3.4).
|
|
64
|
+
`studio_host::lane::stop` kills the Host's own view members first and refuses `not_stoppable` for a
|
|
65
|
+
stranger for exactly this reason; the sidebar's `×` did neither, said "Closed", and left the panes
|
|
66
|
+
running under a survivor that came back as a new 👻 on the next cycle (DEF-981)."""
|
|
67
|
+
views, strangers = [], []
|
|
68
|
+
for line in (listing or '').splitlines():
|
|
69
|
+
name, _, group = line.partition('\t')
|
|
70
|
+
if not name or name == session or group != session:
|
|
71
|
+
continue
|
|
72
|
+
(views if is_host_view(name) else strangers).append(name)
|
|
73
|
+
return views, strangers
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
ANSI_NAMES =['Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White']
|
|
77
|
+
ANSI_SLOTS = {}
|
|
78
|
+
for _i, _n in enumerate(ANSI_NAMES):
|
|
79
|
+
ANSI_SLOTS[f'terminal.ansi{_n}'] = _i
|
|
80
|
+
ANSI_SLOTS[f'terminal.ansiBright{_n}'] = _i + 8
|
|
81
|
+
|
|
82
|
+
# Default-emoji-presentation glyphs only (no VS16): column widths stay stable everywhere.
|
|
83
|
+
LANE_ICONS = {
|
|
84
|
+
'pulse': '💓', 'repo': '📁', 'type-hierarchy': '🌳', 'layers': '🧩', 'lightbulb': '💡',
|
|
85
|
+
'symbol-color': '🎨', 'person': '👤', 'game': '🎮', 'tools': '🔧', 'globe': '🌐',
|
|
86
|
+
'organization': '👥', 'terminal': '💻', 'watch': '⌚', 'compass': '🧭', 'book': '📖',
|
|
87
|
+
'package': '📦', 'map': '🌍', 'hubot': '🤖', 'files': '📄', 'layout': '▦',
|
|
88
|
+
'symbol-structure': '🧱', 'beaker': '🧪', 'references': '🔗', 'edit': '📝', 'sparkle': '✨',
|
|
89
|
+
'briefcase': '💼', 'comment': '💬', 'comment-discussion': '💬', 'search': '🔎',
|
|
90
|
+
'rocket': '🚀', 'database': '💾', 'shield': '🔐', 'terminal-bash': '💻', 'warning': '🚧',
|
|
91
|
+
'gear': '🔩', 'window': '🪟', 'table': '📊', 'dashboard': '📈', 'graph': '📈',
|
|
92
|
+
'device-desktop': '💻', 'server': '📡', 'git-pull-request': '🔀', 'sync': '🔄',
|
|
93
|
+
'extensions': '🧩', 'circuit-board': '🧠',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class LanesError(Exception):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def atomic_write(path, text, mode=0o600):
|
|
102
|
+
path = Path(path)
|
|
103
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
fd, tmp = tempfile.mkstemp(prefix='.' + path.name + '.', dir=str(path.parent))
|
|
105
|
+
try:
|
|
106
|
+
with os.fdopen(fd, 'w') as handle:
|
|
107
|
+
handle.write(text)
|
|
108
|
+
os.chmod(tmp, mode)
|
|
109
|
+
os.replace(tmp, path)
|
|
110
|
+
except BaseException:
|
|
111
|
+
try:
|
|
112
|
+
os.unlink(tmp)
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_json(path, default=None):
|
|
119
|
+
try:
|
|
120
|
+
return json.loads(Path(path).read_text())
|
|
121
|
+
except (OSError, ValueError):
|
|
122
|
+
return default
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def slug(text):
|
|
126
|
+
text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode()
|
|
127
|
+
text = re.sub(r'[^A-Za-z0-9]+', '-', text).strip('-').lower()
|
|
128
|
+
return text or 'lane'
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------ bounded spawns
|
|
132
|
+
|
|
133
|
+
def run_bounded(argv, timeout, input=None, env=None):
|
|
134
|
+
"""Run argv -> (rc, stdout, stderr), text mode. rc is None when the child did not finish in `timeout` seconds.
|
|
135
|
+
|
|
136
|
+
subprocess.run(timeout=) kills the child and then waits for it WITHOUT a bound. On a thrashing host that child
|
|
137
|
+
can sit in D state (uninterruptible) for minutes, or a grandchild keeps the pipes open — and the caller hangs
|
|
138
|
+
with it. Here: kill, ONE more bounded wait (2 s; it also collects what was already written), then abandon the
|
|
139
|
+
child. subprocess reaps an abandoned child on a later spawn, so no zombie is left behind.
|
|
140
|
+
A spawn that fails (no such binary, fork refused under memory pressure) is rc 127 with the reason on stderr.
|
|
141
|
+
Undecodable output bytes are replaced: a stray byte in somebody's argv (`ps`) must not raise in here."""
|
|
142
|
+
try:
|
|
143
|
+
child = subprocess.Popen(argv, stdin=subprocess.PIPE if input is not None else subprocess.DEVNULL,
|
|
144
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, errors='replace', env=env)
|
|
145
|
+
except OSError as error:
|
|
146
|
+
return 127, '', str(error)
|
|
147
|
+
try:
|
|
148
|
+
out, err = child.communicate(input, timeout=timeout)
|
|
149
|
+
return child.returncode, out, err
|
|
150
|
+
except subprocess.TimeoutExpired:
|
|
151
|
+
child.kill()
|
|
152
|
+
try:
|
|
153
|
+
out, err = child.communicate(timeout=2)
|
|
154
|
+
except subprocess.TimeoutExpired:
|
|
155
|
+
out, err = '', ''
|
|
156
|
+
for stream in (child.stdin, child.stdout, child.stderr): # abandoned: do not leak its pipes as well
|
|
157
|
+
try:
|
|
158
|
+
if stream:
|
|
159
|
+
stream.close()
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
child.poll() # reaps a child that DID die (only its pipes are held open); never blocks on one that did not
|
|
163
|
+
return None, out or '', err or ''
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def proc_root():
|
|
167
|
+
"""/proc, or a directory of synthetic files in the unit tests (LANES_PROC_ROOT)."""
|
|
168
|
+
return os.environ.get('LANES_PROC_ROOT') or '/proc'
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------- containers
|
|
172
|
+
|
|
173
|
+
class NoSuchContainer(LanesError):
|
|
174
|
+
"""docker ANSWERED that the container does not exist — an observation, unlike every other docker failure."""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def docker_inspect(container, template):
|
|
178
|
+
rc, out, err = run_bounded(['docker', 'inspect', '-f', template, container], 20)
|
|
179
|
+
if rc is None:
|
|
180
|
+
raise LanesError(f'docker inspect {container}: no answer within 20 s')
|
|
181
|
+
if rc:
|
|
182
|
+
# "Error: No such object: <name>" (after a `docker rm`) is an answer ABOUT the container. Any other failure
|
|
183
|
+
# — daemon unreachable or busy, fork refused, permission — says nothing about it, whatever the exit code.
|
|
184
|
+
reason = err.lower()
|
|
185
|
+
gone = 'no such object' in reason or 'no such container' in reason
|
|
186
|
+
raise (NoSuchContainer if gone else LanesError)(f'docker inspect {container}: {err.strip()}')
|
|
187
|
+
return out.strip()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def container_info(container):
|
|
191
|
+
"""Init pid and host path of the container's /home/coder volume."""
|
|
192
|
+
if not IDENT.match(container):
|
|
193
|
+
raise LanesError(f'Invalid container name: {container}')
|
|
194
|
+
pid = int(docker_inspect(container, '{{.State.Pid}}') or 0)
|
|
195
|
+
home = docker_inspect(
|
|
196
|
+
container,
|
|
197
|
+
'{{range .Mounts}}{{if eq .Destination "' + CONTAINER_HOME + '"}}{{.Source}}{{end}}{{end}}')
|
|
198
|
+
return {'pid': pid, 'home': home, 'running': pid > 0}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def refresh_containers(names):
|
|
202
|
+
previous = read_json(STATE / 'containers.json', {}) or {}
|
|
203
|
+
info = {}
|
|
204
|
+
for name in sorted(set(names)):
|
|
205
|
+
try:
|
|
206
|
+
info[name] = container_info(name)
|
|
207
|
+
except NoSuchContainer:
|
|
208
|
+
# An answer, like the pid 0 of a stopped container: NO 'error' key, so statusd publishes its lanes as
|
|
209
|
+
# 'missing' instead of keeping their last state as if docker had been silent.
|
|
210
|
+
info[name] = {'pid': 0, 'home': '', 'running': False}
|
|
211
|
+
except (LanesError, subprocess.SubprocessError, ValueError) as error:
|
|
212
|
+
# docker did not ANSWER (timeout, busy daemon): that says nothing about the container. While the init
|
|
213
|
+
# pid recorded last time is still alive, keep it — writing pid 0 here would tell every viewer and
|
|
214
|
+
# statusd "container down" because of a slow docker. A container that really went away takes its init
|
|
215
|
+
# pid with it, so this cannot keep a dead one alive.
|
|
216
|
+
old = previous.get(name) or {}
|
|
217
|
+
if old.get('pid') and os.path.exists(f"{proc_root()}/{old['pid']}"):
|
|
218
|
+
info[name] = dict(old, running=True, error=str(error))
|
|
219
|
+
else:
|
|
220
|
+
info[name] = {'pid': 0, 'home': '', 'running': False, 'error': str(error)}
|
|
221
|
+
# One tiny file per container: the POSIX-sh holders read it without python or docker.
|
|
222
|
+
atomic_write(STATE / 'containers' / f'{name}.pid', f"{info[name]['pid']}\n")
|
|
223
|
+
atomic_write(STATE / 'containers.json', json.dumps(info, indent=2) + '\n')
|
|
224
|
+
return info
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def host_path(info, container_path):
|
|
228
|
+
"""Map an absolute in-container path under /home/coder to the host volume path."""
|
|
229
|
+
if not SAFE_PATH.match(container_path) or '..' in container_path.split('/'):
|
|
230
|
+
raise LanesError(f'Unsafe registry path: {container_path}')
|
|
231
|
+
if not (container_path + '/').startswith(CONTAINER_HOME + '/'):
|
|
232
|
+
raise LanesError(f'Registry outside the home volume: {container_path}')
|
|
233
|
+
if not info.get('home'):
|
|
234
|
+
raise LanesError('Container home volume unknown')
|
|
235
|
+
return Path(info['home']) / container_path[len(CONTAINER_HOME) + 1:]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def tmux_socket(info, socket_name):
|
|
239
|
+
return f"/proc/{info['pid']}/root/tmp/tmux-{CONTAINER_UID}/{socket_name}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def tmux_env():
|
|
243
|
+
return {'PATH': '/usr/bin:/bin', 'TERM': 'xterm-256color', 'LANG': 'C.UTF-8', 'HOME': str(Path.home())}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def tmux_probe(info, socket_name, args, timeout=5):
|
|
247
|
+
"""tmux query against an in-container server -> (outcome, text). -N: never start a server.
|
|
248
|
+
|
|
249
|
+
'ok' the server answered; text = its stdout
|
|
250
|
+
'noserver' it positively does not exist: no container pid, no socket file, or tmux says nobody listens there
|
|
251
|
+
'timeout' no answer in time — on a busy host this says NOTHING about the server
|
|
252
|
+
'error' any other failure; text = tmux's stderr
|
|
253
|
+
Only ok/noserver are observations. A caller that turns timeout/error into "gone" reports an overloaded host
|
|
254
|
+
as lost lanes."""
|
|
255
|
+
if not info.get('pid'):
|
|
256
|
+
return 'noserver', ''
|
|
257
|
+
path = tmux_socket(info, socket_name)
|
|
258
|
+
if not os.path.exists(path):
|
|
259
|
+
return 'noserver', ''
|
|
260
|
+
rc, out, err = run_bounded(['tmux', '-N', '-u', '-S', path] + args, timeout, env=tmux_env())
|
|
261
|
+
if rc is None:
|
|
262
|
+
return 'timeout', ''
|
|
263
|
+
if rc == 0:
|
|
264
|
+
return 'ok', out
|
|
265
|
+
# tmux_env() pins LANG=C.UTF-8, so these are tmux's English texts. "error connecting" also covers EACCES,
|
|
266
|
+
# EAGAIN (listen backlog full) …: only the two reasons that mean "nobody listens there" count as no server.
|
|
267
|
+
reason = err.lower()
|
|
268
|
+
if 'no server running' in reason or ('error connecting' in reason and (
|
|
269
|
+
'no such file' in reason or 'connection refused' in reason)):
|
|
270
|
+
return 'noserver', ''
|
|
271
|
+
if reason.strip() == 'no current target' and args and args[0].startswith('list-'):
|
|
272
|
+
# A LIVE server that holds ZERO sessions. MEASURED (tmux 3.4): `list-sessions` exits 0 with empty output
|
|
273
|
+
# while `list-panes -a` / `list-windows -a` / `list-clients` exit 1 with exactly this text. Under the
|
|
274
|
+
# operator's `set -g exit-empty off` that is the STEADY state of a board socket once its last lane ends,
|
|
275
|
+
# not a transient — so reading it as a failure published `unknown` for every declared lane of that socket
|
|
276
|
+
# while the Studio Host answered `absent` for the same server (`studio_host::tmux::probe_socket` has this
|
|
277
|
+
# exact arm, "which is not an error"). An empty listing IS the answer: there are no sessions (DEF-894).
|
|
278
|
+
#
|
|
279
|
+
# ONLY for a LISTING verb. tmux answers the same text to a TARGETED verb against an absent session on
|
|
280
|
+
# such a server (MEASURED, tmux 3.4: `send-keys -t '=gone:'` -> `no current target`, rc 1, where with
|
|
281
|
+
# another session present it says `can't find session: gone`), and reading that as 'ok' turned a write
|
|
282
|
+
# that reached no pane into a reported success: `lanes upload --paste` printed "# typed into lane: <name>"
|
|
283
|
+
# for text nobody received, and `has-session` through `tmux_query` answered "it exists" for a session
|
|
284
|
+
# that does not (DEF-967). A targeted verb's failure stays 'error', as it was before this arm.
|
|
285
|
+
return 'ok', ''
|
|
286
|
+
return 'error', err.strip()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def tmux_query(info, socket_name, args, timeout=5):
|
|
290
|
+
"""tmux_probe for callers that only need "text or None" (None = no usable answer, for whatever reason)."""
|
|
291
|
+
outcome, text = tmux_probe(info, socket_name, args, timeout)
|
|
292
|
+
return text if outcome == 'ok' else None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# -------------------------------------------------------------------- health
|
|
296
|
+
# Host pressure from PLAIN FILES only — no subprocess: this is read exactly when spawning is what hurts.
|
|
297
|
+
|
|
298
|
+
HEALTH_LINE = ('at', 'level', 'mem_some10', 'mem_full10', 'io_some10', 'cpu_some10', 'swap_pct', 'load1', 'status_at')
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _text(path):
|
|
302
|
+
try:
|
|
303
|
+
return Path(path).read_text()
|
|
304
|
+
except (OSError, ValueError):
|
|
305
|
+
return ''
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _psi10(text, kind):
|
|
309
|
+
"""'some avg10=12.34 avg60=… total=…' -> 12 (percent of the last 10 s that tasks were stalled)."""
|
|
310
|
+
match = re.search(r'^' + kind + r' avg10=([0-9.]+)', text, re.M)
|
|
311
|
+
try:
|
|
312
|
+
return int(round(float(match.group(1)))) if match else 0
|
|
313
|
+
except ValueError:
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _container_memory(proc, cgroup_root, pid):
|
|
318
|
+
"""cgroup-v2 memory of the container whose init is `pid`; the cgroup is found the way the kernel names it."""
|
|
319
|
+
match = re.search(r'^0::(/\S*)$', _text(proc / str(pid) / 'cgroup'), re.M) if pid else None
|
|
320
|
+
if not match or '..' in match.group(1).split('/'):
|
|
321
|
+
return None
|
|
322
|
+
base = cgroup_root / match.group(1).lstrip('/')
|
|
323
|
+
current = _text(base / 'memory.current').strip()
|
|
324
|
+
if not current.isdigit():
|
|
325
|
+
return None
|
|
326
|
+
limit = _text(base / 'memory.max').strip()
|
|
327
|
+
|
|
328
|
+
def counter(name, key):
|
|
329
|
+
found = re.search(r'^' + key + r' (\d+)$', _text(base / name), re.M)
|
|
330
|
+
return int(found.group(1)) if found else 0
|
|
331
|
+
|
|
332
|
+
gib = 2 ** 30
|
|
333
|
+
return {'cur_gib': round(int(current) / gib, 1), 'max_gib': round(int(limit) / gib, 1) if limit.isdigit() else None,
|
|
334
|
+
'shmem_gib': round(counter('memory.stat', 'shmem') / gib, 1), # shmem = RAM-backed /tmp and /dev/shm
|
|
335
|
+
# anon + shmem is what cannot be given back; `file` minus shmem is page cache, which always grows to the
|
|
336
|
+
# limit on a busy container ("207.9 of 208" is normal and says nothing about pressure)
|
|
337
|
+
'anon_gib': round(counter('memory.stat', 'anon') / gib, 1),
|
|
338
|
+
'oom_kill': counter('memory.events', 'oom_kill')}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def health_level(health):
|
|
342
|
+
if health['mem_some10'] >= 40 or health['mem_full10'] >= 15:
|
|
343
|
+
return 'critical'
|
|
344
|
+
# Load is a last resort only (4 x cores): on a host that runs ~170 agents the load average sits near 1.5 x cores
|
|
345
|
+
# all day (measured: 100-150 on 64 cpus with memory and io pressure at 0-2 %), and a permanent 'busy' would both
|
|
346
|
+
# wear the banner out and keep statusd's size reconciler switched off. Pressure (PSI) is the signal.
|
|
347
|
+
if health['mem_some10'] >= 10 or health['io_some10'] >= 25 or health['load1'] >= 4 * health['ncpu']:
|
|
348
|
+
return 'busy'
|
|
349
|
+
return 'ok'
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def compute_health(paths=None):
|
|
353
|
+
"""paths (tests): 'proc' (else $LANES_PROC_ROOT, else /proc), 'cgroup' (/sys/fs/cgroup), 'containers'
|
|
354
|
+
($STATE/containers.json: whose init pids to look up), 'ncpu'. A file that is missing reads as 0."""
|
|
355
|
+
paths = paths or {}
|
|
356
|
+
proc = Path(paths.get('proc') or proc_root())
|
|
357
|
+
pressure = {name: _text(proc / 'pressure' / name) for name in ('memory', 'io', 'cpu')}
|
|
358
|
+
meminfo = {key: int(value) for key, value in re.findall(r'^(SwapTotal|SwapFree):\s+(\d+)', _text(proc / 'meminfo'), re.M)}
|
|
359
|
+
try:
|
|
360
|
+
load1 = float(_text(proc / 'loadavg').split()[0])
|
|
361
|
+
except (IndexError, ValueError):
|
|
362
|
+
load1 = 0.0
|
|
363
|
+
swap_total = meminfo.get('SwapTotal', 0)
|
|
364
|
+
health = {'at': time.time(), 'level': 'ok',
|
|
365
|
+
'mem_some10': _psi10(pressure['memory'], 'some'), 'mem_full10': _psi10(pressure['memory'], 'full'),
|
|
366
|
+
'io_some10': _psi10(pressure['io'], 'some'), 'cpu_some10': _psi10(pressure['cpu'], 'some'),
|
|
367
|
+
'swap_pct': int(round(100 * (swap_total - meminfo.get('SwapFree', 0)) / swap_total)) if swap_total else 0,
|
|
368
|
+
'load1': load1, 'ncpu': int(paths.get('ncpu') or os.cpu_count() or 1), 'containers': {}}
|
|
369
|
+
health['level'] = health_level(health)
|
|
370
|
+
recorded = read_json(paths.get('containers') or STATE / 'containers.json', {}) or {}
|
|
371
|
+
for name, info in recorded.items():
|
|
372
|
+
memory = _container_memory(proc, Path(paths.get('cgroup') or '/sys/fs/cgroup'), (info or {}).get('pid'))
|
|
373
|
+
if memory:
|
|
374
|
+
health['containers'][name] = memory
|
|
375
|
+
return health
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def health_line(health, status_at):
|
|
379
|
+
"""The one line of $STATE/health (sh and python read it without JSON). Key order is part of the format."""
|
|
380
|
+
values = dict(health, at=int(health['at']), load1=f"{health['load1']:.2f}", status_at=int(status_at))
|
|
381
|
+
return ' '.join(f'{key}={values[key]}' for key in HEALTH_LINE) + '\n'
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def read_health(state_dir=None):
|
|
385
|
+
"""$STATE/health as a dict ({} = no such file yet). The PRODUCER is what freezes on a bad host, so a reader
|
|
386
|
+
judges freshness with its own clock: now - at (or the file mtime); older than 30 s = statusd is not keeping up."""
|
|
387
|
+
health = {}
|
|
388
|
+
for token in _text(Path(state_dir or STATE) / 'health').split():
|
|
389
|
+
key, equals, value = token.partition('=')
|
|
390
|
+
if equals:
|
|
391
|
+
try:
|
|
392
|
+
health[key] = value if key == 'level' else float(value) if key == 'load1' else int(value)
|
|
393
|
+
except ValueError:
|
|
394
|
+
pass
|
|
395
|
+
return health
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ------------------------------------------------------------------ registry
|
|
399
|
+
|
|
400
|
+
def _require(pattern, value, label):
|
|
401
|
+
if not isinstance(value, str) or not pattern.match(value):
|
|
402
|
+
raise LanesError(f'Invalid {label}: {value!r}')
|
|
403
|
+
return value
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _title(value):
|
|
407
|
+
if not isinstance(value, str) or not value or re.search(r'[\x00-\x1f\x7f]', value):
|
|
408
|
+
raise LanesError('Invalid lane name')
|
|
409
|
+
return value
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _workspace_lanes(manifest, containers):
|
|
413
|
+
"""Port of coder-launch/manage.mjs: explicit tmux bindings only; nothing is ever executed."""
|
|
414
|
+
lanes = []
|
|
415
|
+
names = set()
|
|
416
|
+
repo_ids = set()
|
|
417
|
+
for repo in manifest.get('repositories') or []:
|
|
418
|
+
repo_id = _require(IDENT, repo.get('id'), 'repository id')
|
|
419
|
+
container = _require(IDENT, repo.get('host'), 'container')
|
|
420
|
+
if repo_id in repo_ids:
|
|
421
|
+
raise LanesError(f'Duplicate repository id: {repo_id}')
|
|
422
|
+
repo_ids.add(repo_id)
|
|
423
|
+
info = containers.get(container) or {}
|
|
424
|
+
registry_file = host_path(info, repo.get('registry', ''))
|
|
425
|
+
registry = json.loads(registry_file.read_text())
|
|
426
|
+
terminals = registry.get('terminals')
|
|
427
|
+
if not isinstance(terminals, list) or not terminals:
|
|
428
|
+
raise LanesError(f'No terminals in {repo_id}')
|
|
429
|
+
for source in repo.get('bindingSources') or []:
|
|
430
|
+
digest = hashlib.sha256(host_path(info, source.get('path', '')).read_bytes()).hexdigest()
|
|
431
|
+
if digest != source.get('sha256'):
|
|
432
|
+
raise LanesError(f"Binding source changed: {source.get('path')}; inspect its tmux targets "
|
|
433
|
+
'before updating the saved bindings.')
|
|
434
|
+
throttle = ((registry.get('throttle') or {}).get('terminals') or {})
|
|
435
|
+
declared = ((registry.get('lanes') or {}).get('terminals') or {})
|
|
436
|
+
folder = repo.get('registry', '').split('/.vscode/')[0] if '/.vscode/' in repo.get('registry', '') \
|
|
437
|
+
else (manifest.get('remoteShell') or {}).get('cwd') or CONTAINER_HOME
|
|
438
|
+
for entry in terminals:
|
|
439
|
+
name = entry.get('name')
|
|
440
|
+
own = declared.get(name) or {}
|
|
441
|
+
# `lanes cursor-sync` records the binding and the original command next to the terminal, so the
|
|
442
|
+
# Cursor-facing `command` can be the uniform `lane open <id>` entry point.
|
|
443
|
+
binding = own.get('tmux') or (throttle.get(name) or {}).get('tmux')
|
|
444
|
+
create = own.get('create') if 'create' in own else entry.get('command')
|
|
445
|
+
recovery = None if own.get('tmux') else (repo.get('bindingRecoveries') or {}).get(name)
|
|
446
|
+
if recovery:
|
|
447
|
+
if (not binding or entry.get('command') != recovery.get('expectedCommand')
|
|
448
|
+
or binding.get('socketName') != recovery.get('expectedSocketName')
|
|
449
|
+
or binding.get('sessionName') != recovery.get('expectedSessionName')):
|
|
450
|
+
raise LanesError(f'Registry changed since session recovery: {repo_id}/{name}')
|
|
451
|
+
binding = recovery
|
|
452
|
+
override = None if own.get('tmux') else (repo.get('bindingOverrides') or {}).get(name)
|
|
453
|
+
if not binding and override:
|
|
454
|
+
binding = override
|
|
455
|
+
if 'expectedCommand' in binding:
|
|
456
|
+
matches = entry.get('command') == binding['expectedCommand']
|
|
457
|
+
else:
|
|
458
|
+
prefix = f"TMUX= tmux -L {binding.get('socketName')} new -ADs {binding.get('sessionName')} "
|
|
459
|
+
matches = (entry.get('command') or '').startswith(prefix)
|
|
460
|
+
if not matches:
|
|
461
|
+
raise LanesError(f'Registry command no longer agrees with saved binding: {name}')
|
|
462
|
+
if not binding:
|
|
463
|
+
raise LanesError(f'Missing explicit tmux binding: {repo_id}/{name}')
|
|
464
|
+
title = _title(f"{repo.get('prefix') or ''}{name}")
|
|
465
|
+
if title in names:
|
|
466
|
+
raise LanesError('Duplicate lane names: configure repository prefixes')
|
|
467
|
+
names.add(title)
|
|
468
|
+
color = entry.get('color') if isinstance(entry.get('color'), str) else None
|
|
469
|
+
icon = entry.get('icon') if isinstance(entry.get('icon'), str) else None
|
|
470
|
+
display = (repo.get('displayNames') or {}).get(name)
|
|
471
|
+
lanes.append({
|
|
472
|
+
'name': _title(display) if display else title, 'registry_name': title, 'repo': repo_id, 'container': container, 'holder': 'tmux',
|
|
473
|
+
'tmux': {'socket': _require(IDENT, binding.get('socketName'), 'tmux socket'),
|
|
474
|
+
'session': _require(TMUX_SESSION, binding.get('sessionName'), 'tmux session')},
|
|
475
|
+
'focus': entry.get('focus') is True,
|
|
476
|
+
'color': ANSI_SLOTS.get(color),
|
|
477
|
+
'icon': LANE_ICONS.get(icon, '💻'),
|
|
478
|
+
'create': create if isinstance(create, str) and LANE_OPEN not in create else None,
|
|
479
|
+
'folder': folder, 'source': repo.get('registry'),
|
|
480
|
+
})
|
|
481
|
+
return lanes
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _workspace_cwd(manifest):
|
|
485
|
+
cwd = (manifest.get('remoteShell') or {}).get('cwd')
|
|
486
|
+
if cwd:
|
|
487
|
+
return cwd
|
|
488
|
+
registry = (manifest.get('repositories') or [{}])[0].get('registry', '')
|
|
489
|
+
return registry.split('/.vscode/')[0] if '/.vscode/' in registry else CONTAINER_HOME
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
SITE_SCHEMA = 1 # catalog.json / workspaces.<board>.json / commit-targets.json: optional "version" (absent = 1)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def read_site_json(path):
|
|
496
|
+
data = json.loads(Path(path).read_text())
|
|
497
|
+
if int(data.get('version', 1)) > SITE_SCHEMA:
|
|
498
|
+
raise LanesError(f'{path} is version {data["version"]}; this server understands {SITE_SCHEMA} — update the lanes checkout')
|
|
499
|
+
return data
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def build_registry(previous=None):
|
|
503
|
+
catalog = read_site_json(workspaces_dir() / 'catalog.json')['workspaces']
|
|
504
|
+
manifests = {}
|
|
505
|
+
container_names = set()
|
|
506
|
+
for workspace in catalog:
|
|
507
|
+
manifest = read_site_json(workspaces_dir() / workspace['manifest'])
|
|
508
|
+
manifests[workspace['id']] = manifest
|
|
509
|
+
for repo in manifest.get('repositories') or []:
|
|
510
|
+
container_names.add(repo.get('host'))
|
|
511
|
+
containers = refresh_containers(name for name in container_names if name)
|
|
512
|
+
previous_by_id = {w['id']: w for w in (previous or {}).get('workspaces', [])}
|
|
513
|
+
workspaces = []
|
|
514
|
+
used_ids = set()
|
|
515
|
+
for workspace in catalog:
|
|
516
|
+
manifest = manifests[workspace['id']]
|
|
517
|
+
entry = {'id': _require(IDENT, workspace['id'], 'workspace id'), 'title': workspace['title'],
|
|
518
|
+
'cwd': _workspace_cwd(manifest), 'errors': []}
|
|
519
|
+
try:
|
|
520
|
+
lanes = _workspace_lanes(manifest, containers)
|
|
521
|
+
except (LanesError, OSError, ValueError, KeyError) as error:
|
|
522
|
+
# Never lose a working board because one registry is momentarily invalid.
|
|
523
|
+
entry['errors'].append(str(error))
|
|
524
|
+
lanes = (previous_by_id.get(workspace['id']) or {}).get('lanes', [])
|
|
525
|
+
entry['stale'] = True
|
|
526
|
+
entry['container'] = (lanes[0]['container'] if lanes else
|
|
527
|
+
(manifest.get('repositories') or [{}])[0].get('host', ''))
|
|
528
|
+
for lane in lanes:
|
|
529
|
+
if 'id' not in lane: # lanes carried over from a previous registry keep their ids
|
|
530
|
+
base = f"{entry['id']}--{slug(lane['name'])}"
|
|
531
|
+
lane_id, n = base, 2
|
|
532
|
+
while lane_id in used_ids:
|
|
533
|
+
lane_id, n = f'{base}-{n}', n + 1
|
|
534
|
+
lane['id'] = lane_id
|
|
535
|
+
used_ids.add(lane['id'])
|
|
536
|
+
extras = []
|
|
537
|
+
entry['discovery_roots'] = manifest.get('discoveryRoots') or []
|
|
538
|
+
entry['lanes'] = lanes
|
|
539
|
+
entry['extras'] = extras
|
|
540
|
+
workspaces.append(entry)
|
|
541
|
+
# One shared tool tab: the Throttle monitor of the container that hosts most boards.
|
|
542
|
+
hosts = [(m.get('monitor') or {}).get('host') for m in manifests.values() if (m.get('monitor') or {}).get('host')]
|
|
543
|
+
tools = []
|
|
544
|
+
if hosts:
|
|
545
|
+
tools.append({'id': 'tools--throttle-monitor', 'view': '_throttle', 'tab': 'Throttle', 'name': 'Throttle Monitor',
|
|
546
|
+
'holder': 'exec', 'exec': 'monitor', 'container': max(set(hosts), key=hosts.count),
|
|
547
|
+
'cwd': CONTAINER_HOME, 'color': None, 'icon': '📊'})
|
|
548
|
+
return {'version': 1, 'generated_at': time.time(), 'containers': containers, 'workspaces': workspaces, 'tools': tools}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def write_resolve_files(registry):
|
|
552
|
+
"""One small line-oriented file per lane so POSIX-sh viewers never parse JSON or eval data."""
|
|
553
|
+
directory = STATE / 'resolve'
|
|
554
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
555
|
+
wanted = set()
|
|
556
|
+
for tool in registry.get('tools', []):
|
|
557
|
+
# A tool tab is a view with a fixed target: views/<device>/<view>.main falls back to defaults/<view>.
|
|
558
|
+
atomic_write(STATE / 'defaults' / tool['view'], tool['id'] + '\n')
|
|
559
|
+
for workspace in registry['workspaces'] + [{'lanes': registry.get('tools', []), 'extras': []}]:
|
|
560
|
+
for lane in workspace['lanes'] + workspace['extras']:
|
|
561
|
+
wanted.add(lane['id'])
|
|
562
|
+
if lane['holder'] == 'tmux':
|
|
563
|
+
fields = ['tmux', lane['container'], lane['tmux']['socket'], lane['tmux']['session']]
|
|
564
|
+
elif lane['holder'] == 'zmx':
|
|
565
|
+
fields = ['zmx', lane['container'], lane['zmx']['name'], lane['zmx'].get('version', '')]
|
|
566
|
+
else:
|
|
567
|
+
fields = ['exec', lane['container'], lane['exec'], lane.get('cwd', CONTAINER_HOME)]
|
|
568
|
+
atomic_write(directory / lane['id'], '\n'.join(fields + [lane['name']]) + '\n')
|
|
569
|
+
for stale in directory.iterdir():
|
|
570
|
+
if stale.name not in wanted and not stale.name.startswith('.'):
|
|
571
|
+
stale.unlink()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def export_container_registries(registry):
|
|
575
|
+
"""/home/coder/.lz/registry.json inside each container (written through the volume path): what `lane open`
|
|
576
|
+
needs — holder, exact target, create recipe, folder. Only where the lane contract is installed."""
|
|
577
|
+
per_container = {}
|
|
578
|
+
for workspace in registry['workspaces']:
|
|
579
|
+
for lane in workspace['lanes']:
|
|
580
|
+
per_container.setdefault(lane['container'], {})[lane['id']] = {
|
|
581
|
+
'name': lane['name'], 'ws': workspace['id'], 'holder': lane['holder'], 'tmux': lane.get('tmux'),
|
|
582
|
+
'zmx': lane.get('zmx'), 'create': lane.get('create'), 'folder': lane.get('folder')}
|
|
583
|
+
written = []
|
|
584
|
+
for container, lanes in per_container.items():
|
|
585
|
+
home = (registry['containers'].get(container) or {}).get('home')
|
|
586
|
+
if home and (Path(home) / '.lz/bin/lane').exists():
|
|
587
|
+
atomic_write(Path(home) / '.lz/registry.json',
|
|
588
|
+
json.dumps({'version': 1, 'generated_at': registry['generated_at'], 'lanes': lanes},
|
|
589
|
+
ensure_ascii=False, indent=1) + '\n', mode=0o644)
|
|
590
|
+
written.append(container)
|
|
591
|
+
return written
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def load_registry():
|
|
595
|
+
registry = read_json(STATE / 'registry.json')
|
|
596
|
+
if not registry:
|
|
597
|
+
raise LanesError('No registry yet: run `lanes sync`')
|
|
598
|
+
if int(registry.get('version', 1)) > 1:
|
|
599
|
+
raise LanesError('registry.json was written by a newer lanes: update this checkout, then `lanes sync`')
|
|
600
|
+
return registry
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def find_lane(registry, lane_id):
|
|
604
|
+
status = read_json(STATE / 'status.json', {}) or {}
|
|
605
|
+
for workspace in registry['workspaces']:
|
|
606
|
+
discovered = (status.get('unregistered') or {}).get(workspace['id'], [])
|
|
607
|
+
for lane in workspace['lanes'] + workspace['extras'] + discovered:
|
|
608
|
+
if lane['id'] == lane_id:
|
|
609
|
+
return workspace, lane
|
|
610
|
+
raise LanesError(f'Unknown lane: {lane_id}')
|