@vibelord/lanes 1.0.1 → 1.0.3
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/package.json +1 -1
- package/payload/lanes/README.md +37 -7
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +664 -50
- package/payload/lanes/client/ghostty/common.conf +3 -0
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
- package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
- package/payload/lanes/client/ghostty/omarchy.conf +3 -2
- package/payload/lanes/client/lanes-launch +18 -3
- package/payload/lanes/client/lanes-window +17 -1
- package/payload/lanes/container/env.sh +1 -1
- package/payload/lanes/container/hooks/claude-settings.json +10 -0
- package/payload/lanes/container/hooks/codex-hooks.json +22 -0
- package/payload/lanes/container/lane +310 -26
- package/payload/lanes/container/lane-signal +173 -0
- package/payload/lanes/docs/ARCHITECTURE.md +87 -13
- package/payload/lanes/docs/DESIGN.md +182 -4
- package/payload/lanes/docs/INSTALL.md +37 -2
- package/payload/lanes/docs/RUNBOOK.md +458 -39
- package/payload/lanes/docs/SHARING.md +240 -5
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders.lock +13 -0
- package/payload/lanes/install/enable-studio-host.sh +14 -0
- package/payload/lanes/install/host-install.sh +15 -3
- package/payload/lanes/install/services.py +128 -4
- package/payload/lanes/install.sh +108 -3
- package/payload/lanes/libexec/agent_state.py +32 -0
- package/payload/lanes/libexec/cursor_sync.py +10 -2
- package/payload/lanes/libexec/economy_poll.py +685 -0
- package/payload/lanes/libexec/guard.py +27 -1
- package/payload/lanes/libexec/lanes_lib.py +285 -38
- package/payload/lanes/libexec/notify.py +317 -0
- package/payload/lanes/libexec/registrations.py +1 -1
- package/payload/lanes/libexec/session_actions.py +7 -8
- package/payload/lanes/libexec/sidebar.py +911 -218
- package/payload/lanes/libexec/statusd.py +258 -74
- package/payload/lanes/libexec/studio_host.py +1608 -0
- package/payload/lanes/libexec/tokens.py +38 -7
- package/payload/lanes/libexec/view.sh +13 -3
- package/payload/lanes/systemd/lanes-economy.service +20 -0
- package/payload/lanes/systemd/lanes-notify.service +19 -0
- package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
- package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
- package/payload/lanes/systemd/lanes-studio-host.service +37 -0
- package/payload/lanes/systemd/lanes-sync.service +3 -0
- package/payload/lanes/web/README.md +83 -4
- package/payload/lanes/web/index.html +2 -1
- package/payload/lanes/web/mobile.css +53 -198
- package/payload/lanes/web/mobile.js +87 -55
- package/payload/lanes/zellij/config.kdl +67 -16
- package/payload/manifest.json +5 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
|
@@ -14,7 +14,9 @@ allow-list — so that one of them is the victim instead. A build can be re-run;
|
|
|
14
14
|
One pass per invocation (the 60 s timer is systemd's job; --once is accepted and means exactly that).
|
|
15
15
|
--dry-run prints what it would do. Optional arguments: container names (default: every recorded container).
|
|
16
16
|
|
|
17
|
-
This module is also where tmpscan.py gets the container's pids from (Env, container_pid, cgroup_pids)
|
|
17
|
+
This module is also where tmpscan.py gets the container's pids from (Env, container_pid, cgroup_pids), and where
|
|
18
|
+
statusd gets its live tmux servers from (tmux_servers_by_container). Importing it has no side effect: the pass runs
|
|
19
|
+
only under `__main__`.
|
|
18
20
|
"""
|
|
19
21
|
import os
|
|
20
22
|
import signal
|
|
@@ -137,6 +139,30 @@ def tmux_servers(env, pid):
|
|
|
137
139
|
return found
|
|
138
140
|
|
|
139
141
|
|
|
142
|
+
def tmux_servers_by_container(env, containers, errors=None):
|
|
143
|
+
"""{container: {socket name: host pid}} for statusd's live-server set (plan Task 16), from each container's OWN
|
|
144
|
+
cgroup — never a host-wide `ps`. `containers` is statusd's {name: info}; the init pid is `info['pid']` (statusd
|
|
145
|
+
keeps it current from /proc), not the pid file `container_pid` reads.
|
|
146
|
+
|
|
147
|
+
A container with no init pid (down, or never answered for) runs no servers: it is left out. A container whose
|
|
148
|
+
recorded pid cannot be walked — no such /proc entry, not a docker cgroup, not a namespace init (a recycled pid)
|
|
149
|
+
— maps to None: nobody knows which servers it runs, and the caller must not conclude that any of them is gone.
|
|
150
|
+
`errors`, when given, receives {container: why} for each such None, so the caller can NAME the container whose
|
|
151
|
+
pid broke the read (the None alone said only that some container did)."""
|
|
152
|
+
found = {}
|
|
153
|
+
for name, info in containers.items():
|
|
154
|
+
pid = (info or {}).get('pid')
|
|
155
|
+
if not pid:
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
found[name] = tmux_servers(env, int(pid))
|
|
159
|
+
except (L.LanesError, OSError, ValueError) as error:
|
|
160
|
+
found[name] = None
|
|
161
|
+
if errors is not None:
|
|
162
|
+
errors[name] = f'pid {pid}: {error}'
|
|
163
|
+
return found
|
|
164
|
+
|
|
165
|
+
|
|
140
166
|
def sockets(env, names, repair=False, out=print, settle=2.0):
|
|
141
167
|
"""`lanes sockets [check|repair]`: tmux servers whose socket FILE is gone (someone emptied the container's /tmp:
|
|
142
168
|
2026-09-20 every lane was alive and unreachable). repair = tmux's own remedy: recreate the directory, then
|
|
@@ -13,6 +13,7 @@ import json
|
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
15
|
import shlex
|
|
16
|
+
import signal
|
|
16
17
|
import subprocess
|
|
17
18
|
import tempfile
|
|
18
19
|
import time
|
|
@@ -118,6 +119,28 @@ def atomic_write(path, text, mode=0o600):
|
|
|
118
119
|
raise
|
|
119
120
|
|
|
120
121
|
|
|
122
|
+
def atomic_write_if_changed(path, text, mode=0o600):
|
|
123
|
+
"""atomic_write, skipped when the file already holds exactly `text` -> True when it wrote. Readers that reload
|
|
124
|
+
on mtime (statusd and notify on registry.json, every sidebar) then do not wake for a projection that did not
|
|
125
|
+
change, and the timer-driven sync stops rewriting the container volumes every two minutes."""
|
|
126
|
+
try:
|
|
127
|
+
if Path(path).read_text() == text:
|
|
128
|
+
return False
|
|
129
|
+
except (OSError, ValueError):
|
|
130
|
+
pass
|
|
131
|
+
atomic_write(path, text, mode)
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _same_json(path, document, ignore=('generated_at',)):
|
|
136
|
+
"""True when `path` holds `document` minus the `ignore` keys (a timestamp alone is not a change)."""
|
|
137
|
+
old = read_json(path)
|
|
138
|
+
if not isinstance(old, dict):
|
|
139
|
+
return False
|
|
140
|
+
return ({k: v for k, v in old.items() if k not in ignore} ==
|
|
141
|
+
{k: v for k, v in json.loads(json.dumps(document)).items() if k not in ignore})
|
|
142
|
+
|
|
143
|
+
|
|
121
144
|
def read_json(path, default=None):
|
|
122
145
|
try:
|
|
123
146
|
return json.loads(Path(path).read_text())
|
|
@@ -125,6 +148,53 @@ def read_json(path, default=None):
|
|
|
125
148
|
return default
|
|
126
149
|
|
|
127
150
|
|
|
151
|
+
def show_lane(view_dir, workspace, slot, lane_id):
|
|
152
|
+
"""Point one viewport at a lane — the one implementation behind the sidebar's open_lane and `lanes next|prev`
|
|
153
|
+
(plan Task 19): write `<view_dir>/<workspace>.<slot>.target` atomically, then SIGUSR1 the process the viewport's
|
|
154
|
+
`.pid` file names, only when its /proc cmdline says it is a view.sh (a recycled pid is never signalled).
|
|
155
|
+
-> True when a view.sh was woken; False when none runs yet (the target is written either way: a view.sh that
|
|
156
|
+
starts later reads it)."""
|
|
157
|
+
target_file = os.path.join(view_dir, f'{workspace}.{slot}.target')
|
|
158
|
+
os.makedirs(view_dir, exist_ok=True)
|
|
159
|
+
tmp = target_file + f'.{os.getpid()}.tmp'
|
|
160
|
+
with open(tmp, 'w') as handle:
|
|
161
|
+
handle.write(lane_id + '\n')
|
|
162
|
+
os.replace(tmp, target_file)
|
|
163
|
+
try:
|
|
164
|
+
with open(os.path.join(view_dir, f'{workspace}.{slot}.pid')) as handle:
|
|
165
|
+
pid = int(handle.read().strip())
|
|
166
|
+
with open(os.path.join(proc_root(), str(pid), 'cmdline'), 'rb') as handle:
|
|
167
|
+
if b'view.sh' not in handle.read():
|
|
168
|
+
return False
|
|
169
|
+
os.kill(pid, signal.SIGUSR1)
|
|
170
|
+
return True
|
|
171
|
+
except (OSError, ValueError):
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def clean_seen(document):
|
|
176
|
+
"""DM-LANES-CLIENT-SEEN as read: string keys with numeric values only (anything else is ignored)."""
|
|
177
|
+
return {k: v for k, v in (document.items() if isinstance(document, dict) else ())
|
|
178
|
+
if isinstance(k, str) and isinstance(v, (int, float))}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def record_seen(path, lane_id, since, known, mine=None):
|
|
182
|
+
"""Mark `lane_id` opened now on this device (DM-LANES-CLIENT-SEEN): the file on disk merged with `mine` (the
|
|
183
|
+
caller's in-memory marks; the later mark per lane wins, so the per-workspace sidebars of one device and
|
|
184
|
+
`lanes next|prev` never clobber each other), bounded to the lane ids in `known` (what status.json lists), the
|
|
185
|
+
mark at least the agent's `since`. Written atomically; an unwritable file is not an error. -> the merged dict."""
|
|
186
|
+
seen = clean_seen(read_json(path))
|
|
187
|
+
for key, value in (mine or {}).items():
|
|
188
|
+
seen[key] = max(value, seen.get(key, value))
|
|
189
|
+
seen = {k: v for k, v in seen.items() if k in known}
|
|
190
|
+
seen[lane_id] = max(time.time(), since if isinstance(since, (int, float)) else 0)
|
|
191
|
+
try:
|
|
192
|
+
atomic_write(Path(path), json.dumps(seen, sort_keys=True) + '\n')
|
|
193
|
+
except OSError:
|
|
194
|
+
pass
|
|
195
|
+
return seen
|
|
196
|
+
|
|
197
|
+
|
|
128
198
|
def slug(text):
|
|
129
199
|
text = unicodedata.normalize('NFKD', text).encode('ascii', 'ignore').decode()
|
|
130
200
|
text = re.sub(r'[^A-Za-z0-9]+', '-', text).strip('-').lower()
|
|
@@ -177,36 +247,78 @@ class NoSuchContainer(LanesError):
|
|
|
177
247
|
"""docker ANSWERED that the container does not exist — an observation, unlike every other docker failure."""
|
|
178
248
|
|
|
179
249
|
|
|
180
|
-
|
|
181
|
-
|
|
250
|
+
INSPECT_FORMAT = ('{{.Name}}\t{{.State.Pid}}\t{{range .Mounts}}{{if eq .Destination "' + CONTAINER_HOME +
|
|
251
|
+
'"}}{{.Source}}{{end}}{{end}}')
|
|
252
|
+
_GONE = re.compile(r'no such (?:object|container): (\S+)', re.I)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def containers_info(names):
|
|
256
|
+
"""{name: {pid, home, running}} for every container in ONE `docker inspect` (it was two spawns per container
|
|
257
|
+
per sync). A name docker ANSWERED does not exist maps to None; a name docker said nothing about is left out (the
|
|
258
|
+
caller must not conclude anything about it). Raises LanesError when docker gave no answer at all.
|
|
259
|
+
|
|
260
|
+
"Error: No such container/object: <name>" (after a `docker rm`) is an answer ABOUT that container. Any other
|
|
261
|
+
failure — daemon unreachable or busy, fork refused, permission — says nothing about it, whatever the exit code.
|
|
262
|
+
|
|
263
|
+
`--type container`: an image or volume that shares a name would otherwise be inspected, and its missing
|
|
264
|
+
`.State` would fail the template for the whole batch. Found containers are printed (one line each, keyed by
|
|
265
|
+
`.Name`) even when docker exits 1 because another name is gone."""
|
|
266
|
+
names = sorted(set(names))
|
|
267
|
+
for name in names:
|
|
268
|
+
if not IDENT.match(name):
|
|
269
|
+
raise LanesError(f'Invalid container name: {name}')
|
|
270
|
+
if not names:
|
|
271
|
+
return {}
|
|
272
|
+
label = 'docker inspect ' + ' '.join(names)
|
|
273
|
+
rc, out, err = run_bounded(['docker', 'inspect', '--type', 'container', '-f', INSPECT_FORMAT, *names], 20)
|
|
182
274
|
if rc is None:
|
|
183
|
-
raise LanesError(f'
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
275
|
+
raise LanesError(f'{label}: no answer within 20 s')
|
|
276
|
+
result = {}
|
|
277
|
+
for line in out.splitlines():
|
|
278
|
+
parts = line.split('\t')
|
|
279
|
+
if len(parts) != 3 or parts[0].lstrip('/') not in names:
|
|
280
|
+
continue
|
|
281
|
+
try:
|
|
282
|
+
pid = int(parts[1] or 0)
|
|
283
|
+
except ValueError:
|
|
284
|
+
continue
|
|
285
|
+
result[parts[0].lstrip('/')] = {'pid': pid, 'home': parts[2].strip(), 'running': pid > 0}
|
|
286
|
+
for match in _GONE.finditer(err):
|
|
287
|
+
if match.group(1) in names and match.group(1) not in result:
|
|
288
|
+
result[match.group(1)] = None
|
|
289
|
+
if rc and not result:
|
|
290
|
+
# Daemon unreachable or busy, fork refused, permission: nothing was said about any container.
|
|
291
|
+
raise LanesError(f'{label}: {err.strip()}')
|
|
292
|
+
return result
|
|
191
293
|
|
|
192
294
|
|
|
193
295
|
def container_info(container):
|
|
194
|
-
"""Init pid and host path of the container's /home/coder volume."""
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
container
|
|
200
|
-
|
|
201
|
-
return {'pid': pid, 'home': home, 'running': pid > 0}
|
|
296
|
+
"""Init pid and host path of the container's /home/coder volume (one name of `containers_info`)."""
|
|
297
|
+
found = containers_info([container])
|
|
298
|
+
if container not in found:
|
|
299
|
+
raise LanesError(f'docker inspect {container}: no answer about it')
|
|
300
|
+
if found[container] is None:
|
|
301
|
+
raise NoSuchContainer(f'docker inspect {container}: no such container')
|
|
302
|
+
return found[container]
|
|
202
303
|
|
|
203
304
|
|
|
204
305
|
def refresh_containers(names):
|
|
205
306
|
previous = read_json(STATE / 'containers.json', {}) or {}
|
|
206
307
|
info = {}
|
|
207
|
-
|
|
308
|
+
names = sorted(set(names))
|
|
309
|
+
try: # ONE docker spawn for every container; an invalid name stays that name's error, not the batch's
|
|
310
|
+
answers, failure = containers_info([n for n in names if IDENT.match(n)]), None
|
|
311
|
+
except (LanesError, subprocess.SubprocessError, ValueError) as error:
|
|
312
|
+
answers, failure = {}, error
|
|
313
|
+
for name in names:
|
|
208
314
|
try:
|
|
209
|
-
|
|
315
|
+
if not IDENT.match(name):
|
|
316
|
+
raise LanesError(f'Invalid container name: {name}')
|
|
317
|
+
if name not in answers:
|
|
318
|
+
raise failure or LanesError(f'docker inspect {name}: no answer about it')
|
|
319
|
+
if answers[name] is None:
|
|
320
|
+
raise NoSuchContainer(f'docker inspect {name}: no such container')
|
|
321
|
+
info[name] = answers[name]
|
|
210
322
|
except NoSuchContainer:
|
|
211
323
|
# An answer, like the pid 0 of a stopped container: NO 'error' key, so statusd publishes its lanes as
|
|
212
324
|
# 'missing' instead of keeping their last state as if docker had been silent.
|
|
@@ -222,8 +334,8 @@ def refresh_containers(names):
|
|
|
222
334
|
else:
|
|
223
335
|
info[name] = {'pid': 0, 'home': '', 'running': False, 'error': str(error)}
|
|
224
336
|
# One tiny file per container: the POSIX-sh holders read it without python or docker.
|
|
225
|
-
|
|
226
|
-
|
|
337
|
+
atomic_write_if_changed(STATE / 'containers' / f'{name}.pid', f"{info[name]['pid']}\n")
|
|
338
|
+
atomic_write_if_changed(STATE / 'containers.json', json.dumps(info, indent=2) + '\n')
|
|
227
339
|
return info
|
|
228
340
|
|
|
229
341
|
|
|
@@ -238,6 +350,36 @@ def host_path(info, container_path):
|
|
|
238
350
|
return Path(info['home']) / container_path[len(CONTAINER_HOME) + 1:]
|
|
239
351
|
|
|
240
352
|
|
|
353
|
+
# A markdown path as a lane prints it: not glued to a word or a `/` on its left, `.md` ending at a word boundary
|
|
354
|
+
# (`foo.md5`, `c.mdx` are not markdown). `lanes preview` (plan Task 15) offers what this finds.
|
|
355
|
+
MARKDOWN_PATH = re.compile(r'(?<![\w/])(?:\.{0,2}/)?[\w./-]+\.md\b')
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def markdown_candidates(text, cwd):
|
|
359
|
+
"""The in-container paths of the markdown files `text` (a lane's scrollback) names, resolved against the lane's
|
|
360
|
+
`cwd`, de-duplicated with each path at its NEWEST position (oldest first, newest last). A match right after a `:`
|
|
361
|
+
is the path part of a URL (`https://host/a.md`) and is skipped; `~/x.md` is under the container home; a relative
|
|
362
|
+
path needs an absolute `cwd`. Pure: whether a path exists is the caller's question."""
|
|
363
|
+
found = []
|
|
364
|
+
for match in MARKDOWN_PATH.finditer(text):
|
|
365
|
+
raw, before = match.group(0), text[match.start() - 1:match.start()]
|
|
366
|
+
if before == ':' or raw.startswith('//'):
|
|
367
|
+
continue
|
|
368
|
+
if before == '~' and raw.startswith('/'):
|
|
369
|
+
raw = CONTAINER_HOME + raw
|
|
370
|
+
if not raw.startswith('/'):
|
|
371
|
+
if not cwd.startswith('/'):
|
|
372
|
+
continue
|
|
373
|
+
raw = cwd.rstrip('/') + '/' + raw
|
|
374
|
+
path = os.path.normpath(raw)
|
|
375
|
+
if not SAFE_PATH.match(path):
|
|
376
|
+
continue
|
|
377
|
+
if path in found:
|
|
378
|
+
found.remove(path)
|
|
379
|
+
found.append(path)
|
|
380
|
+
return found
|
|
381
|
+
|
|
382
|
+
|
|
241
383
|
def tmux_socket(info, socket_name):
|
|
242
384
|
return f"/proc/{info['pid']}/root/tmp/tmux-{CONTAINER_UID}/{socket_name}"
|
|
243
385
|
|
|
@@ -571,6 +713,100 @@ def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
|
571
713
|
f'-e THROTTLE_TRACE={trace} {cmd}')
|
|
572
714
|
|
|
573
715
|
|
|
716
|
+
# What `lane open` gets when it runs a lane's recorded `create` for a lane that does not exist (plan Task 18; the
|
|
717
|
+
# shapes measured in the boards' terminals.json on 2026-10-03). `lanes doctor` counts the lanes per class.
|
|
718
|
+
# idempotent a tmux new-session with -A: creates the lane, or attaches when it exists (the canonical recipe)
|
|
719
|
+
# attach-only tmux attach without any new-session: a lane that is gone stays gone
|
|
720
|
+
# null no recipe at all
|
|
721
|
+
# external anything else (a script, a new-session without -A, unparseable): run as written, unvouched for
|
|
722
|
+
# retired-path names the retired in-container coder-launch config, which no longer creates anything
|
|
723
|
+
# attach-only, null and retired-path are what `lanes cursor-sync --canonicalize` replaces (cursor_sync.needs_canonical
|
|
724
|
+
# reads them through this function, says why per lane, and also catches an external recipe whose script is missing in
|
|
725
|
+
# the container).
|
|
726
|
+
RECIPE_CLASSES = ('idempotent', 'attach-only', 'null', 'external', 'retired-path')
|
|
727
|
+
RETIRED_RECIPE_PATH = '.config/coder-launch'
|
|
728
|
+
_RECIPE_ATTACH = {'attach-session'[:n] for n in range(1, 15)} # tmux takes any unique prefix (+ `attach`)
|
|
729
|
+
_RECIPE_NEW = {'new-session'[:n] for n in range(3, 12)}
|
|
730
|
+
_TMUX_GLOBAL_ARGS = {'-L', '-S', '-f', '-T', '-c'}
|
|
731
|
+
_NEW_SESSION_ARGS = set('cefFnstxy') # new-session options that take a value
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _recipe_word_lists(text, depth=3):
|
|
735
|
+
"""The shell words of a recipe as one list, then one list per quoted word that holds a command of its own
|
|
736
|
+
(`bash -lc 'tmux … new …'`), each kept apart so a nested string never lands among an outer command's options;
|
|
737
|
+
None = unparseable."""
|
|
738
|
+
try:
|
|
739
|
+
words = shlex.split(text)
|
|
740
|
+
except ValueError:
|
|
741
|
+
return None
|
|
742
|
+
lists = [words]
|
|
743
|
+
for word in words:
|
|
744
|
+
if depth and any(c.isspace() for c in word):
|
|
745
|
+
lists += _recipe_word_lists(word, depth - 1) or []
|
|
746
|
+
return lists
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _tmux_commands(lists):
|
|
750
|
+
"""[(verb, the words after it)] of every tmux invocation in these word lists (its global options skipped)."""
|
|
751
|
+
found = []
|
|
752
|
+
for words in lists:
|
|
753
|
+
for i, word in enumerate(words):
|
|
754
|
+
if word == 'tmux' or word.endswith('/tmux'):
|
|
755
|
+
j = i + 1
|
|
756
|
+
while j < len(words) and words[j].startswith('-'):
|
|
757
|
+
j += 2 if words[j] in _TMUX_GLOBAL_ARGS else 1
|
|
758
|
+
if j < len(words):
|
|
759
|
+
found.append((words[j], words[j + 1:]))
|
|
760
|
+
return found
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _new_session_options(words):
|
|
764
|
+
"""[(letter, value or None)] of a new-session's options, getopt-style: `-ADs x` gives A, D, (s, x); an option's
|
|
765
|
+
VALUE (the `A=1` of `-e A=1`, the `A` of `-s A`) is never read as flags. Stops at the first word that is no
|
|
766
|
+
option (the command)."""
|
|
767
|
+
options, i = [], 0
|
|
768
|
+
while i < len(words) and words[i].startswith('-') and len(words[i]) > 1 and words[i] != '--':
|
|
769
|
+
word, i = words[i], i + 1
|
|
770
|
+
for k, letter in enumerate(word[1:]):
|
|
771
|
+
if letter not in _NEW_SESSION_ARGS:
|
|
772
|
+
options.append((letter, None))
|
|
773
|
+
continue
|
|
774
|
+
value = word[k + 2:]
|
|
775
|
+
if not value: # the value is the next word
|
|
776
|
+
value = words[i] if i < len(words) else ''
|
|
777
|
+
i += 1
|
|
778
|
+
options.append((letter, value))
|
|
779
|
+
break
|
|
780
|
+
return options
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def classify_recipe(create):
|
|
784
|
+
"""One of RECIPE_CLASSES for a lane's recorded create recipe (None, a string, or anything a registry holds)."""
|
|
785
|
+
if not isinstance(create, str) or not create.strip():
|
|
786
|
+
return 'null'
|
|
787
|
+
if RETIRED_RECIPE_PATH in create:
|
|
788
|
+
return 'retired-path'
|
|
789
|
+
lists = _recipe_word_lists(create)
|
|
790
|
+
if lists is None:
|
|
791
|
+
return 'external'
|
|
792
|
+
commands = _tmux_commands(lists)
|
|
793
|
+
if any(verb in _RECIPE_NEW and ('A', None) in _new_session_options(rest) for verb, rest in commands):
|
|
794
|
+
return 'idempotent'
|
|
795
|
+
if any(verb in _RECIPE_ATTACH for verb, _ in commands) and not any(verb in _RECIPE_NEW for verb, _ in commands):
|
|
796
|
+
return 'attach-only'
|
|
797
|
+
return 'external'
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def recipe_identity(create):
|
|
801
|
+
"""Does the recipe give the lane it creates its identity — an `-e LANE_ID=…` on a tmux new-session (the
|
|
802
|
+
canonical recipe's flags, DEF-1559)? An env prefix (`LANE_ID=x tmux …`) does not count: it reaches the tmux
|
|
803
|
+
client, never the session."""
|
|
804
|
+
lists = _recipe_word_lists(create) if isinstance(create, str) else None
|
|
805
|
+
return any(verb in _RECIPE_NEW and any(letter == 'e' and value.startswith('LANE_ID=')
|
|
806
|
+
for letter, value in _new_session_options(rest))
|
|
807
|
+
for verb, rest in _tmux_commands(lists or []))
|
|
808
|
+
|
|
809
|
+
|
|
574
810
|
def ssh_workspaces(catalog):
|
|
575
811
|
"""Native tabs for sessions on hosts outside the workspace containers; no network calls."""
|
|
576
812
|
def require(pattern, value, label):
|
|
@@ -617,7 +853,7 @@ def ssh_workspaces(catalog):
|
|
|
617
853
|
def ssh_workspace_board(workspace):
|
|
618
854
|
"""Use the standard sidebar/view layout for fixed, attach-only remote lanes."""
|
|
619
855
|
return {'id': workspace['id'], 'title': workspace['title'], 'container': '',
|
|
620
|
-
'cwd': '', 'errors': [], '
|
|
856
|
+
'cwd': '', 'errors': [], 'discovery_roots': [],
|
|
621
857
|
'attach_only': True, 'position': workspace.get('position', 'before-host'),
|
|
622
858
|
'lanes': [{'id': f"{workspace['id']}--{pane['id']}", 'name': pane['name'],
|
|
623
859
|
'holder': 'ssh', 'container': '', 'color': None, 'icon': '💻',
|
|
@@ -662,10 +898,8 @@ def build_registry(previous=None):
|
|
|
662
898
|
lane_id, n = f'{base}-{n}', n + 1
|
|
663
899
|
lane['id'] = lane_id
|
|
664
900
|
used_ids.add(lane['id'])
|
|
665
|
-
extras = []
|
|
666
901
|
entry['discovery_roots'] = manifest.get('discoveryRoots') or []
|
|
667
902
|
entry['lanes'] = lanes
|
|
668
|
-
entry['extras'] = extras
|
|
669
903
|
workspaces.append(entry)
|
|
670
904
|
workspaces += [ssh_workspace_board(w) for w in remote_tabs if w['layout'] == 'lanes']
|
|
671
905
|
remote_tabs = [w for w in remote_tabs if w['layout'] != 'lanes']
|
|
@@ -682,15 +916,27 @@ def build_registry(previous=None):
|
|
|
682
916
|
|
|
683
917
|
|
|
684
918
|
def publish_registry(registry):
|
|
685
|
-
"""Publish a prepared registry while holding registry_lock().
|
|
919
|
+
"""Publish a prepared registry while holding registry_lock(). Every projection is written only when its content
|
|
920
|
+
changed (plan Task 17): `lanes sync` runs every 2 minutes and a registry.json rewritten with nothing but a new
|
|
921
|
+
`generated_at` made statusd, notify and every sidebar reload it. An unchanged registry.json therefore keeps its
|
|
922
|
+
OLD `generated_at`; the sync itself is recorded in `registry.synced`, which only `lanes doctor` reads."""
|
|
686
923
|
write_resolve_files(registry)
|
|
687
924
|
export_container_registries(registry)
|
|
688
925
|
for workspace in registry['workspaces']:
|
|
689
926
|
focus = next((lane for lane in workspace['lanes'] if lane.get('focus')), None)
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
927
|
+
first = focus or (workspace['lanes'][0] if workspace['lanes'] else None)
|
|
928
|
+
atomic_write_if_changed(STATE / 'defaults' / workspace['id'], (first['id'] if first else '!empty') + '\n')
|
|
929
|
+
if not _same_json(STATE / 'registry.json', registry):
|
|
930
|
+
atomic_write(STATE / 'registry.json', json.dumps(registry, indent=1, ensure_ascii=False) + '\n')
|
|
931
|
+
atomic_write(STATE / 'registry.synced', f"{registry['generated_at']}\n")
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def registry_synced_at():
|
|
935
|
+
"""When a sync last published (or confirmed unchanged) the registry; 0 when no sync has recorded it."""
|
|
936
|
+
try:
|
|
937
|
+
return float((STATE / 'registry.synced').read_text().strip())
|
|
938
|
+
except (OSError, ValueError):
|
|
939
|
+
return 0.0
|
|
694
940
|
|
|
695
941
|
|
|
696
942
|
def write_resolve_files(registry):
|
|
@@ -700,9 +946,9 @@ def write_resolve_files(registry):
|
|
|
700
946
|
wanted = set()
|
|
701
947
|
for tool in registry.get('tools', []):
|
|
702
948
|
# A tool tab is a view with a fixed target: views/<device>/<view>.main falls back to defaults/<view>.
|
|
703
|
-
|
|
704
|
-
for workspace in registry['workspaces'] + [{'lanes': registry.get('tools', [])
|
|
705
|
-
for lane in workspace['lanes']
|
|
949
|
+
atomic_write_if_changed(STATE / 'defaults' / tool['view'], tool['id'] + '\n')
|
|
950
|
+
for workspace in registry['workspaces'] + [{'lanes': registry.get('tools', [])}]:
|
|
951
|
+
for lane in workspace['lanes']:
|
|
706
952
|
wanted.add(lane['id'])
|
|
707
953
|
if lane['holder'] == 'tmux':
|
|
708
954
|
fields = ['tmux', lane['container'], lane['tmux']['socket'], lane['tmux']['session']]
|
|
@@ -712,7 +958,7 @@ def write_resolve_files(registry):
|
|
|
712
958
|
fields = ['zmx', lane['container'], lane['zmx']['name'], lane['zmx'].get('version', '')]
|
|
713
959
|
else:
|
|
714
960
|
fields = ['exec', lane['container'], lane['exec'], lane.get('cwd', CONTAINER_HOME)]
|
|
715
|
-
|
|
961
|
+
atomic_write_if_changed(directory / lane['id'], '\n'.join(fields + [lane['name']]) + '\n')
|
|
716
962
|
for stale in directory.iterdir():
|
|
717
963
|
if stale.name not in wanted and not stale.name.startswith('.'):
|
|
718
964
|
stale.unlink()
|
|
@@ -733,9 +979,10 @@ def export_container_registries(registry):
|
|
|
733
979
|
for container, lanes in per_container.items():
|
|
734
980
|
home = (registry['containers'].get(container) or {}).get('home')
|
|
735
981
|
if home and (Path(home) / '.lz/bin/lane').exists():
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
982
|
+
document = {'version': 1, 'generated_at': registry['generated_at'], 'lanes': lanes}
|
|
983
|
+
if not _same_json(Path(home) / '.lz/registry.json', document): # `generated_at` alone is no change
|
|
984
|
+
atomic_write(Path(home) / '.lz/registry.json',
|
|
985
|
+
json.dumps(document, ensure_ascii=False, indent=1) + '\n', mode=0o644)
|
|
739
986
|
written.append(container)
|
|
740
987
|
return written
|
|
741
988
|
|
|
@@ -752,7 +999,7 @@ def load_registry():
|
|
|
752
999
|
def find_lane(registry, lane_id):
|
|
753
1000
|
status = read_json(STATE / 'status.json', {}) or {}
|
|
754
1001
|
for workspace in registry['workspaces']:
|
|
755
|
-
for lane in workspace['lanes']
|
|
1002
|
+
for lane in workspace['lanes']:
|
|
756
1003
|
if lane['id'] == lane_id:
|
|
757
1004
|
return workspace, lane
|
|
758
1005
|
# Registration can move a session before the next observer cycle clears its previous workspace.
|