@vibelord/lanes 1.1.1 → 1.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/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +45 -22
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2459 -164
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +106 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1091 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1140 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +618 -13
- package/payload/lanes/docs/DESIGN.md +122 -44
- package/payload/lanes/docs/INSTALL.md +157 -17
- package/payload/lanes/docs/RUNBOOK.md +603 -72
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +777 -136
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +163 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/members.py +16 -2
- package/payload/lanes/libexec/notify.py +104 -24
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2135 -361
- package/payload/lanes/libexec/statusd.py +137 -10
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +121 -30
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +972 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/README.md +83 -24
- package/payload/lanes/web/browser.js +3 -5
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +44 -32
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +5 -3
|
@@ -11,10 +11,14 @@ import fcntl
|
|
|
11
11
|
import hashlib
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
|
+
import posixpath
|
|
14
15
|
import re
|
|
16
|
+
import secrets
|
|
15
17
|
import shlex
|
|
16
18
|
import signal
|
|
19
|
+
import stat
|
|
17
20
|
import subprocess
|
|
21
|
+
import sys
|
|
18
22
|
import tempfile
|
|
19
23
|
import time
|
|
20
24
|
import unicodedata
|
|
@@ -23,6 +27,144 @@ from pathlib import Path
|
|
|
23
27
|
REPO = Path(__file__).resolve().parent.parent
|
|
24
28
|
STATE = Path(os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'))
|
|
25
29
|
SHARE = Path(os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'))
|
|
30
|
+
PACKAGE_STAMP = '.lanes-package.json' # npm/lanes/bin/lanes-install.mjs `STAMP`, written into every tree it stages
|
|
31
|
+
RUN_DIR = 'run' # STATE/run/<name>.tree: the tree each long-running process started from
|
|
32
|
+
CURRENT_LINK = '.local/share/lanes/current' # under the HOME directory, never $XDG_DATA_HOME: install/services.py
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def current_link(home=None):
|
|
36
|
+
"""Where install/services.py writes the `current` link: `<home>/.local/share/lanes/current` (default: this
|
|
37
|
+
account's home; raises KeyError/RuntimeError with no HOME and no passwd entry). The npm wrapper's prune protects the
|
|
38
|
+
same path (npm/lanes/lib/plan.mjs `currentLink`); `current_tree` and `lanes version` read it."""
|
|
39
|
+
return (Path(home) if home is not None else Path.home()) / CURRENT_LINK
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def staged_tree(path):
|
|
43
|
+
"""Whether `path` is a tree the npm wrapper staged, not a checkout: it sits in the default pkg root
|
|
44
|
+
(`…/pkg/<version>`), or it carries the wrapper's stamp (PACKAGE_STAMP, a regular file lanes-install.mjs stage()
|
|
45
|
+
writes into every tree it stages — under `$LANES_PKG_ROOT/<version>` too, whatever that directory is called;
|
|
46
|
+
DEF-1757). A checkout has neither."""
|
|
47
|
+
path = Path(path)
|
|
48
|
+
if path.parent.name == 'pkg':
|
|
49
|
+
return True
|
|
50
|
+
try:
|
|
51
|
+
return (path / PACKAGE_STAMP).is_file()
|
|
52
|
+
except OSError: # an unreadable directory says nothing either way: not staged
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def current_links(repo, home=None):
|
|
57
|
+
"""Where the `current` link of the staged tree `repo` can be, in the order current_tree and installed_tree look:
|
|
58
|
+
beside the pkg root (`<pkg>/../current`, the default `…/pkg/<version>` layout only), then under the home
|
|
59
|
+
(current_link; left out with no HOME and no passwd entry)."""
|
|
60
|
+
repo = Path(repo)
|
|
61
|
+
links = [repo.parent.parent / 'current'] if repo.parent.name == 'pkg' else []
|
|
62
|
+
try:
|
|
63
|
+
links.append(current_link(home))
|
|
64
|
+
except (KeyError, RuntimeError): # no HOME and no passwd entry: the pkg-root candidate alone
|
|
65
|
+
pass
|
|
66
|
+
return links
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def current_tree(repo=None, home=None):
|
|
70
|
+
"""The path `repo` is INSTALLED under — the one layouts, units and wrappers should embed (plan W9 U1): the `current`
|
|
71
|
+
link when `repo` is an npm-staged tree (`staged_tree`: `…/pkg/<version>`, or any `$LANES_PKG_ROOT/<version>` with
|
|
72
|
+
the wrapper's stamp) and the link resolves to exactly that tree; else `repo` itself — a checkout install, or a
|
|
73
|
+
staged tree the link no longer names (after an update an old tree must never hand out a link that now means
|
|
74
|
+
another version). REPO is always the RESOLVED tree, because __file__ is resolved through the link.
|
|
75
|
+
|
|
76
|
+
The link is where install/services.py writes it, `~/.local/share/lanes/current` from the home directory, and —
|
|
77
|
+
for the default `…/pkg/<version>` layout only — beside the pkg root (`<pkg>/../current`, the same path unless the
|
|
78
|
+
npm wrapper staged under $XDG_DATA_HOME, or the only candidate with no home). The link stays under the home either
|
|
79
|
+
way (the wrapper's prune protects the same link: npm/lanes/lib/plan.mjs `currentLink`); beside a `$LANES_PKG_ROOT`
|
|
80
|
+
of another name nothing manages a link, so none is looked for there."""
|
|
81
|
+
repo = Path(repo) if repo is not None else REPO
|
|
82
|
+
if not staged_tree(repo):
|
|
83
|
+
return repo
|
|
84
|
+
links = current_links(repo, home)
|
|
85
|
+
try:
|
|
86
|
+
target = repo.resolve()
|
|
87
|
+
except (OSError, RuntimeError):
|
|
88
|
+
return repo
|
|
89
|
+
for link in links:
|
|
90
|
+
try:
|
|
91
|
+
if link.resolve() == target:
|
|
92
|
+
return link
|
|
93
|
+
except (OSError, RuntimeError): # a dangling link or a loop names no tree
|
|
94
|
+
continue
|
|
95
|
+
return repo
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
CURRENT = current_tree()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def installed_tree(repo=None, home=None):
|
|
102
|
+
"""Where a running process re-reads the INSTALLED release (the sidebar's version note and self-swap, cockpit v3 V2
|
|
103
|
+
and U5): for a staged `repo` (default REPO), the first of its current_links that is a link — whatever tree it names
|
|
104
|
+
NOW, unlike current_tree, which collapses to `repo` once an update has flipped the link to another tree (that flip
|
|
105
|
+
is exactly what this reader must see); else `repo` itself: a checkout, or a staged tree no link stands beside, is
|
|
106
|
+
its own installed tree. lstat only: nothing is resolved here."""
|
|
107
|
+
repo = Path(repo) if repo is not None else REPO
|
|
108
|
+
if not staged_tree(repo):
|
|
109
|
+
return repo
|
|
110
|
+
for link in current_links(repo, home):
|
|
111
|
+
try:
|
|
112
|
+
if link.is_symlink():
|
|
113
|
+
return link
|
|
114
|
+
except OSError:
|
|
115
|
+
continue
|
|
116
|
+
return repo
|
|
117
|
+
|
|
118
|
+
# The tree files a generated layout runs (bin/lanes layout_kdl, `lanes doctor` layouts_doctor), as `<tree>/<file>`.
|
|
119
|
+
LAYOUT_FILES = ('libexec/sidebar', 'libexec/view.sh', 'holders/ssh.sh')
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def tree_file_at(words, name):
|
|
123
|
+
"""The index of the first of a pane command's `words` that runs the tree file `name` (one of LAYOUT_FILES) of ANY
|
|
124
|
+
lanes tree, else None: an absolute path ending in `/<name>`. Since W9 U1 a layout names its files through CURRENT
|
|
125
|
+
(`…/current/<name>`), a layout built before names the `pkg/<version>` tree that built it, a checkout names itself —
|
|
126
|
+
and one session can hold panes of each after an update. They are the same pane, so a reader (device_sync's tab
|
|
127
|
+
identities, bin/lanes focused_view and stale_picker_layouts) matches the suffix, never one tree's full path; a
|
|
128
|
+
relative path, or a file that only ends like it (`/opt/myview.sh`), runs no tree file."""
|
|
129
|
+
suffix = '/' + name
|
|
130
|
+
return next((index for index, word in enumerate(words) if word.startswith('/') and word.endswith(suffix)), None)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def tree_version(path):
|
|
134
|
+
"""A tree's release, `<path>/VERSION` stripped; None when the file is absent, unreadable or empty. One open(), no
|
|
135
|
+
stat: the sidebar re-reads the installed tree's every 30 s through it (cockpit v3 V2), and its tick budget counts
|
|
136
|
+
stats."""
|
|
137
|
+
try:
|
|
138
|
+
with open(Path(path) / 'VERSION', encoding='utf-8') as handle:
|
|
139
|
+
text = handle.read().strip()
|
|
140
|
+
except (OSError, ValueError): # ValueError: undecodable bytes
|
|
141
|
+
return None
|
|
142
|
+
return text or None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def tree_stamp(path=None):
|
|
146
|
+
"""The npm wrapper's stamp of a staged tree (default REPO), as {version, digest, commit}: each a string, else None.
|
|
147
|
+
None for a checkout (no stamp) and for a stamp that is not a JSON object."""
|
|
148
|
+
document = read_json(Path(path if path is not None else REPO) / PACKAGE_STAMP)
|
|
149
|
+
if not isinstance(document, dict):
|
|
150
|
+
return None
|
|
151
|
+
return {key: document[key] if isinstance(document.get(key), str) else None
|
|
152
|
+
for key in ('version', 'digest', 'commit')}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def record_tree(name, state=None):
|
|
156
|
+
"""Write `<state>/run/<name>.tree` (default STATE) = the tree this process runs from — str(REPO), the RESOLVED path,
|
|
157
|
+
and a newline — rewritten at every start, so its mtime is the start time. Each long-running process calls it once
|
|
158
|
+
when it starts (statusd, enrold, economy, notify, the Studio Host enable step, the sidebar); `lanes version` and
|
|
159
|
+
doctor compare the records with `current` (plan W9 V1, V3). -> the path written, or None when the state directory
|
|
160
|
+
cannot be written: a record is observation, never a reason for a daemon or a sidebar not to start."""
|
|
161
|
+
_require(IDENT, name, 'tree record name')
|
|
162
|
+
path = Path(state if state is not None else STATE) / RUN_DIR / f'{name}.tree'
|
|
163
|
+
try:
|
|
164
|
+
atomic_write(path, f'{REPO}\n')
|
|
165
|
+
except OSError:
|
|
166
|
+
return None
|
|
167
|
+
return path
|
|
26
168
|
|
|
27
169
|
|
|
28
170
|
def workspaces_dir():
|
|
@@ -104,6 +246,20 @@ class LanesError(Exception):
|
|
|
104
246
|
pass
|
|
105
247
|
|
|
106
248
|
|
|
249
|
+
class ProjectionRefused(LanesError):
|
|
250
|
+
"""A change a person made (register, rename) was SAVED and published everywhere except these containers' own
|
|
251
|
+
`~/.lz/registry.json` — `publish_registry`'s `[(container, why)]` (DEF-1796 refusals). The action that published
|
|
252
|
+
raises it so the person hears it at once: `lane open` / `lane-signal` in those containers still resolve against
|
|
253
|
+
the old projection, and the next timer sync only reports it to its journal. `result` is what the action returns
|
|
254
|
+
on success (the sidebar finishes its success path with it); `pending` is the sentence after `<what> saved; `."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, what, refused, result=None):
|
|
257
|
+
self.what, self.refused, self.result = what, list(refused), result
|
|
258
|
+
self.pending = 'run lanes sync to finish publishing: ' + '; '.join(
|
|
259
|
+
f'container {container}: {why}' for container, why in self.refused)
|
|
260
|
+
super().__init__(f'{what} saved; {self.pending}')
|
|
261
|
+
|
|
262
|
+
|
|
107
263
|
def atomic_write(path, text, mode=0o600):
|
|
108
264
|
"""Replace `path` with `text` atomically (temp file in the same directory, chmod, rename). `text` may be bytes:
|
|
109
265
|
then it is written byte for byte, never through the locale's encoding (keys.py keeps foreign authorized_keys
|
|
@@ -138,9 +294,101 @@ def atomic_write_if_changed(path, text, mode=0o600):
|
|
|
138
294
|
return True
|
|
139
295
|
|
|
140
296
|
|
|
297
|
+
# Writing INTO a directory a container owns (LNS-32: statusd's `~/.lz/status.json`, written from the host through
|
|
298
|
+
# /proc/<container init pid>/root; DEF-1796: `lanes sync`'s `~/.lz/registry.json`, through the home volume). The
|
|
299
|
+
# container can plant anything there: an absolute symlink resolves against the HOST's root when the host follows it,
|
|
300
|
+
# and a FIFO blocks a plain open-for-read forever. So the directory is walked with O_NOFOLLOW per component, the
|
|
301
|
+
# compare read never follows a link or blocks, and the temp file is O_EXCL in the same directory, renamed over
|
|
302
|
+
# whatever the name holds (rename replaces a link, it never writes through it).
|
|
303
|
+
_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_CLOEXEC', 0)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _leaf(name):
|
|
307
|
+
"""`name` when it is one directory entry (no `/`, not `.`/`..`/empty): every *_at helper resolves exactly one."""
|
|
308
|
+
if not isinstance(name, str) or not name or '/' in name or name in ('.', '..') or '\0' in name:
|
|
309
|
+
raise LanesError(f'Invalid path component: {name!r}')
|
|
310
|
+
return name
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def open_real_dir(base, parts):
|
|
314
|
+
"""fd of `base`/<parts…>, every component in `parts` a REAL directory (a symlink is refused with OSError, never
|
|
315
|
+
followed). `base` itself is trusted and opened as given (it may be the /proc/<pid>/root magic link). The caller
|
|
316
|
+
closes the fd."""
|
|
317
|
+
fd = os.open(str(base), _DIR_FLAGS)
|
|
318
|
+
try:
|
|
319
|
+
for part in parts:
|
|
320
|
+
child = os.open(_leaf(part), _DIR_FLAGS | os.O_NOFOLLOW, dir_fd=fd)
|
|
321
|
+
os.close(fd)
|
|
322
|
+
fd = child
|
|
323
|
+
except BaseException:
|
|
324
|
+
os.close(fd)
|
|
325
|
+
raise
|
|
326
|
+
return fd
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def read_regular_at(dir_fd, name, limit):
|
|
330
|
+
"""The bytes of `name` in `dir_fd` when it is a regular file of at most `limit` bytes, else None (absent, a
|
|
331
|
+
symlink, a FIFO or device, bigger). Never follows a link, never blocks on a FIFO (O_NONBLOCK on the open)."""
|
|
332
|
+
_leaf(name)
|
|
333
|
+
try:
|
|
334
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0), dir_fd=dir_fd)
|
|
335
|
+
except OSError:
|
|
336
|
+
return None
|
|
337
|
+
try:
|
|
338
|
+
info = os.fstat(fd)
|
|
339
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
|
|
340
|
+
return None
|
|
341
|
+
chunks, size = [], 0
|
|
342
|
+
while size <= limit:
|
|
343
|
+
chunk = os.read(fd, limit + 1 - size)
|
|
344
|
+
if not chunk:
|
|
345
|
+
break
|
|
346
|
+
chunks.append(chunk)
|
|
347
|
+
size += len(chunk)
|
|
348
|
+
return b''.join(chunks) if size <= limit else None
|
|
349
|
+
except OSError:
|
|
350
|
+
return None
|
|
351
|
+
finally:
|
|
352
|
+
os.close(fd)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def atomic_write_if_changed_at(dir_fd, name, text, mode=0o600):
|
|
356
|
+
"""`atomic_write_if_changed` inside an open directory (see `open_real_dir`) -> True when it wrote. `text` is str
|
|
357
|
+
(UTF-8) or bytes; the file is replaced only when it is not already a regular file holding exactly these bytes."""
|
|
358
|
+
data = text.encode() if isinstance(text, str) else text
|
|
359
|
+
if read_regular_at(dir_fd, name, len(data)) == data:
|
|
360
|
+
return False
|
|
361
|
+
atomic_write_at(dir_fd, name, data, mode)
|
|
362
|
+
return True
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def atomic_write_at(dir_fd, name, text, mode=0o600):
|
|
366
|
+
"""`atomic_write` inside an open directory: an O_EXCL|O_NOFOLLOW temp file beside `name`, chmod, then a rename
|
|
367
|
+
that replaces whatever `name` is (a symlink or FIFO planted there is replaced, never followed or opened)."""
|
|
368
|
+
_leaf(name)
|
|
369
|
+
data = text.encode() if isinstance(text, str) else text
|
|
370
|
+
temporary = f'.{name}.{secrets.token_hex(6)}'
|
|
371
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | getattr(os, 'O_CLOEXEC', 0),
|
|
372
|
+
0o600, dir_fd=dir_fd)
|
|
373
|
+
try:
|
|
374
|
+
with os.fdopen(fd, 'wb') as handle:
|
|
375
|
+
handle.write(data)
|
|
376
|
+
os.fchmod(handle.fileno(), mode)
|
|
377
|
+
os.replace(temporary, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
|
378
|
+
except BaseException:
|
|
379
|
+
try:
|
|
380
|
+
os.unlink(temporary, dir_fd=dir_fd)
|
|
381
|
+
except OSError:
|
|
382
|
+
pass
|
|
383
|
+
raise
|
|
384
|
+
|
|
385
|
+
|
|
141
386
|
def _same_json(path, document, ignore=('generated_at',)):
|
|
142
387
|
"""True when `path` holds `document` minus the `ignore` keys (a timestamp alone is not a change)."""
|
|
143
|
-
|
|
388
|
+
return _same_document(read_json(path), document, ignore)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _same_document(old, document, ignore=('generated_at',)):
|
|
144
392
|
if not isinstance(old, dict):
|
|
145
393
|
return False
|
|
146
394
|
return ({k: v for k, v in old.items() if k not in ignore} ==
|
|
@@ -178,6 +426,38 @@ def show_lane(view_dir, workspace, slot, lane_id):
|
|
|
178
426
|
return False
|
|
179
427
|
|
|
180
428
|
|
|
429
|
+
# DM-LANES-SIDEBAR-CMD (cockpit v3 S4): a key handed to a sidebar without typing it. Ctrl-Alt-/ and Ctrl-Alt-u
|
|
430
|
+
# (zellij/config.kdl) walk the focus to the sidebar and run `lanes sidebar-key filter|upload --focused`, which writes
|
|
431
|
+
# {"cmd": <one of SIDEBAR_KEYS>, "at": <epoch seconds>} to <view_dir>/<workspace>.sidebar.cmd; that workspace's sidebar
|
|
432
|
+
# acts on each new file (reload()'s stamp, and before every read of keys). Writer and reader share these helpers.
|
|
433
|
+
SIDEBAR_KEYS = ('filter', 'upload')
|
|
434
|
+
SIDEBAR_KEY_FRESH = 10.0 # seconds: an older command (one a sidebar took before it restarted) is history, not a key
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def sidebar_key_path(view_dir, workspace):
|
|
438
|
+
return os.path.join(str(view_dir), f'{workspace}.sidebar.cmd')
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def send_sidebar_key(view_dir, workspace, key, now=None):
|
|
442
|
+
"""Write one sidebar command atomically (0600). `key` must be one of SIDEBAR_KEYS."""
|
|
443
|
+
if key not in SIDEBAR_KEYS:
|
|
444
|
+
raise LanesError(f'unknown sidebar key {key!r} (one of {", ".join(SIDEBAR_KEYS)})')
|
|
445
|
+
at = time.time() if now is None else now
|
|
446
|
+
atomic_write(sidebar_key_path(view_dir, workspace), json.dumps({'cmd': key, 'at': at}) + '\n')
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def read_sidebar_key(path, now=None):
|
|
450
|
+
"""The key a sidebar command file holds, or None: unreadable, not the v1 shape, an unknown key, or an `at` more
|
|
451
|
+
than SIDEBAR_KEY_FRESH seconds away from now."""
|
|
452
|
+
document = read_json(path)
|
|
453
|
+
if not isinstance(document, dict):
|
|
454
|
+
return None
|
|
455
|
+
key, at = document.get('cmd'), document.get('at')
|
|
456
|
+
if key not in SIDEBAR_KEYS or isinstance(at, bool) or not isinstance(at, (int, float)):
|
|
457
|
+
return None
|
|
458
|
+
return key if abs((time.time() if now is None else now) - at) <= SIDEBAR_KEY_FRESH else None
|
|
459
|
+
|
|
460
|
+
|
|
181
461
|
def clean_seen(document):
|
|
182
462
|
"""DM-LANES-CLIENT-SEEN as read: string keys with numeric values only (anything else is ignored)."""
|
|
183
463
|
return {k: v for k, v in (document.items() if isinstance(document, dict) else ())
|
|
@@ -256,6 +536,9 @@ class NoSuchContainer(LanesError):
|
|
|
256
536
|
INSPECT_FORMAT = ('{{.Name}}\t{{.State.Pid}}\t{{range .Mounts}}{{if eq .Destination "' + CONTAINER_HOME +
|
|
257
537
|
'"}}{{.Source}}{{end}}{{end}}')
|
|
258
538
|
_GONE = re.compile(r'no such (?:object|container): (\S+)', re.I)
|
|
539
|
+
# How long the one `docker inspect` of a sync may take before it counts as no answer. A bound other code sizes its
|
|
540
|
+
# waits by (the sidebar's PENDING_S), so it is named here, where it is enforced.
|
|
541
|
+
DOCKER_INSPECT_S = 20
|
|
259
542
|
|
|
260
543
|
|
|
261
544
|
def containers_info(names):
|
|
@@ -276,9 +559,10 @@ def containers_info(names):
|
|
|
276
559
|
if not names:
|
|
277
560
|
return {}
|
|
278
561
|
label = 'docker inspect ' + ' '.join(names)
|
|
279
|
-
rc, out, err = run_bounded(['docker', 'inspect', '--type', 'container', '-f', INSPECT_FORMAT, *names],
|
|
562
|
+
rc, out, err = run_bounded(['docker', 'inspect', '--type', 'container', '-f', INSPECT_FORMAT, *names],
|
|
563
|
+
DOCKER_INSPECT_S)
|
|
280
564
|
if rc is None:
|
|
281
|
-
raise LanesError(f'{label}: no answer within
|
|
565
|
+
raise LanesError(f'{label}: no answer within {DOCKER_INSPECT_S} s')
|
|
282
566
|
result = {}
|
|
283
567
|
for line in out.splitlines():
|
|
284
568
|
parts = line.split('\t')
|
|
@@ -356,6 +640,21 @@ def host_path(info, container_path):
|
|
|
356
640
|
return Path(info['home']) / container_path[len(CONTAINER_HOME) + 1:]
|
|
357
641
|
|
|
358
642
|
|
|
643
|
+
def lane_core():
|
|
644
|
+
"""container/lane_core.py — the in-container contract's shared helpers — as a module, one instance per process
|
|
645
|
+
(sys.modules, the same one `bin/lanes` loads for the agents' MCP entries): the host edits a container's
|
|
646
|
+
terminals.json with the very editor lane-mcp runs inside it (session_actions.unregister_declared, LNS-35)."""
|
|
647
|
+
import sys
|
|
648
|
+
module = sys.modules.get('lane_core')
|
|
649
|
+
if module is None:
|
|
650
|
+
import importlib.util
|
|
651
|
+
spec = importlib.util.spec_from_file_location('lane_core', str(REPO / 'container' / 'lane_core.py'))
|
|
652
|
+
module = importlib.util.module_from_spec(spec)
|
|
653
|
+
spec.loader.exec_module(module)
|
|
654
|
+
sys.modules['lane_core'] = module
|
|
655
|
+
return module
|
|
656
|
+
|
|
657
|
+
|
|
359
658
|
# A markdown path as a lane prints it: not glued to a word or a `/` on its left, `.md` ending at a word boundary
|
|
360
659
|
# (`foo.md5`, `c.mdx` are not markdown). `lanes preview` (plan Task 15) offers what this finds.
|
|
361
660
|
MARKDOWN_PATH = re.compile(r'(?<![\w/])(?:\.{0,2}/)?[\w./-]+\.md\b')
|
|
@@ -386,6 +685,116 @@ def markdown_candidates(text, cwd):
|
|
|
386
685
|
return found
|
|
387
686
|
|
|
388
687
|
|
|
688
|
+
# ------------------------------------------------------- container file system
|
|
689
|
+
# `lanes edit` (W9 E2, LNS-96) looks at a lane's checkout through /proc/<init>/root (`root`). A symbolic link in there
|
|
690
|
+
# is the container's: walked by the kernel from the host, an ABSOLUTE target would restart at the HOST's root (and
|
|
691
|
+
# os.path.realpath reads /proc/<init>/root itself as `/`), so every path below is resolved one component at a time
|
|
692
|
+
# with the container's root as `/`, and only a path whose every component is no link is opened.
|
|
693
|
+
|
|
694
|
+
CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]')
|
|
695
|
+
SYMLINK_LIMIT = 40 # as the kernel's ELOOP bound
|
|
696
|
+
GIT_FILE_LIMIT = 4096 # a .git link file or a commondir file is one short line
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def container_realpath(root, path):
|
|
700
|
+
"""The absolute container path `path` names, every symbolic link resolved inside the container whose root
|
|
701
|
+
directory is `root` on the host: an absolute target restarts at `root`, `..` climbs from where a link led (the
|
|
702
|
+
kernel's walk, not string folding) and never above `/`. A component that does not exist (or cannot be read) is
|
|
703
|
+
kept as it is, like os.path.realpath(strict=False). Raises LanesError after SYMLINK_LIMIT links, and for a path
|
|
704
|
+
with a NUL byte (no file has one; os.readlink would raise ValueError)."""
|
|
705
|
+
if '\x00' in path:
|
|
706
|
+
raise LanesError('a path with a NUL byte names no file')
|
|
707
|
+
pending = [part for part in path.split('/') if part not in ('', '.')]
|
|
708
|
+
resolved, links = [], 0
|
|
709
|
+
while pending:
|
|
710
|
+
part = pending.pop(0)
|
|
711
|
+
if part == '..':
|
|
712
|
+
if resolved:
|
|
713
|
+
resolved.pop()
|
|
714
|
+
continue
|
|
715
|
+
try:
|
|
716
|
+
target = os.readlink(Path(root, *resolved, part))
|
|
717
|
+
except OSError: # EINVAL: no link; ENOENT, EACCES: kept as it is
|
|
718
|
+
resolved.append(part)
|
|
719
|
+
continue
|
|
720
|
+
links += 1
|
|
721
|
+
if links > SYMLINK_LIMIT:
|
|
722
|
+
raise LanesError('too many symbolic links')
|
|
723
|
+
if target.startswith('/'):
|
|
724
|
+
resolved = []
|
|
725
|
+
pending = [p for p in target.split('/') if p not in ('', '.')] + pending
|
|
726
|
+
return '/' + '/'.join(resolved)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def container_lstat(root, path):
|
|
730
|
+
"""os.lstat of a RESOLVED container path (container_realpath) under `root`, or None for any OSError: a caller that
|
|
731
|
+
names the file it asked about never prints the error, which carries the host path (W9 E2: `.env.local`)."""
|
|
732
|
+
try:
|
|
733
|
+
return os.lstat(Path(root, path.lstrip('/')))
|
|
734
|
+
except (OSError, ValueError): # ValueError: a NUL byte
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _container_line(root, path):
|
|
739
|
+
"""The first line of the small regular file at the resolved container path `path`, trailing whitespace removed, or
|
|
740
|
+
None when it is missing, no regular file, a link, or unreadable. Never follows a link and never blocks on a FIFO."""
|
|
741
|
+
try:
|
|
742
|
+
fd = os.open(Path(root, path.lstrip('/')), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC)
|
|
743
|
+
except (OSError, ValueError):
|
|
744
|
+
return None
|
|
745
|
+
try:
|
|
746
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
747
|
+
return None
|
|
748
|
+
text = os.read(fd, GIT_FILE_LIMIT).decode('utf-8', 'replace')
|
|
749
|
+
except OSError:
|
|
750
|
+
return None
|
|
751
|
+
finally:
|
|
752
|
+
os.close(fd)
|
|
753
|
+
return (text.splitlines() or [''])[0].rstrip()
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _main_checkout(root, gitdir, worktree):
|
|
757
|
+
"""The main checkout of a linked worktree whose gitdir is `gitdir`: the folder holding the common `.git` its
|
|
758
|
+
`commondir` names (relative to the gitdir; git writes `../..`). No commondir (a submodule, a --separate-git-dir
|
|
759
|
+
checkout) or a common dir that is not a checkout's `.git` (a bare repository): the worktree is its own. The file is
|
|
760
|
+
the container's to write: one with a control character names no main checkout either."""
|
|
761
|
+
common = _container_line(root, container_realpath(root, posixpath.join(gitdir, 'commondir')))
|
|
762
|
+
if not common or CONTROL_CHARS.search(common):
|
|
763
|
+
return worktree
|
|
764
|
+
common = container_realpath(root, posixpath.join(gitdir, common))
|
|
765
|
+
if posixpath.basename(common) != '.git':
|
|
766
|
+
return worktree
|
|
767
|
+
main = posixpath.dirname(common)
|
|
768
|
+
found = container_lstat(root, main)
|
|
769
|
+
return main if found and stat.S_ISDIR(found.st_mode) else worktree
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def repo_roots(cwd, root):
|
|
773
|
+
"""(worktree root, main checkout) of the git checkout the container path `cwd` is in, both resolved container paths,
|
|
774
|
+
read through `root` (/proc/<init>/root). Walks up from `cwd` to the first `.git`: a directory holding HEAD makes
|
|
775
|
+
that folder the top level and its own main checkout; a `.git` FILE (`gitdir: <path>`, relative to its folder or
|
|
776
|
+
absolute in the container) makes it a linked worktree whose gitdir's `commondir` leads to the main checkout
|
|
777
|
+
(_main_checkout). Raises LanesError for a cwd that is not an absolute path without control characters, a `.git`
|
|
778
|
+
file without a gitdir line (or with a control character in it), and no checkout up to `/`."""
|
|
779
|
+
if not isinstance(cwd, str) or not cwd.startswith('/') or CONTROL_CHARS.search(cwd):
|
|
780
|
+
raise LanesError('the lane has no usable working directory')
|
|
781
|
+
here = container_realpath(root, cwd)
|
|
782
|
+
while True:
|
|
783
|
+
dotgit = container_realpath(root, posixpath.join(here, '.git'))
|
|
784
|
+
found = container_lstat(root, dotgit)
|
|
785
|
+
if found and stat.S_ISDIR(found.st_mode) and os.path.lexists(Path(root, dotgit.lstrip('/'), 'HEAD')):
|
|
786
|
+
return here, here
|
|
787
|
+
if found and stat.S_ISREG(found.st_mode):
|
|
788
|
+
line = _container_line(root, dotgit) or ''
|
|
789
|
+
if not line.startswith('gitdir: ') or not line[8:] or CONTROL_CHARS.search(line):
|
|
790
|
+
raise LanesError(f'{posixpath.join(here, ".git")} is not a git link file')
|
|
791
|
+
gitdir = container_realpath(root, posixpath.join(here, line[8:]))
|
|
792
|
+
return here, _main_checkout(root, gitdir, here)
|
|
793
|
+
if here == '/':
|
|
794
|
+
raise LanesError(f'no git checkout in or above {cwd}')
|
|
795
|
+
here = posixpath.dirname(here)
|
|
796
|
+
|
|
797
|
+
|
|
389
798
|
def tmux_socket(info, socket_name):
|
|
390
799
|
return f"/proc/{info['pid']}/root/tmp/tmux-{CONTAINER_UID}/{socket_name}"
|
|
391
800
|
|
|
@@ -548,8 +957,11 @@ def read_health(state_dir=None):
|
|
|
548
957
|
|
|
549
958
|
# ------------------------------------------------------------------ registry
|
|
550
959
|
|
|
960
|
+
REGISTRY_LOCK_S = 5 # how long a sync, registration or close waits for the lock before "busy" (sizes PENDING_S)
|
|
961
|
+
|
|
962
|
+
|
|
551
963
|
@contextlib.contextmanager
|
|
552
|
-
def registry_lock(timeout=
|
|
964
|
+
def registry_lock(timeout=REGISTRY_LOCK_S):
|
|
553
965
|
"""Serialize sync, registration and close across device sidebars and the timer."""
|
|
554
966
|
STATE.mkdir(parents=True, exist_ok=True)
|
|
555
967
|
with (STATE / 'registry.lock').open('a') as handle:
|
|
@@ -568,6 +980,93 @@ def registry_lock(timeout=5):
|
|
|
568
980
|
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
569
981
|
|
|
570
982
|
|
|
983
|
+
# ------------------------------------------------------------------ view sessions
|
|
984
|
+
|
|
985
|
+
SESSIONS_LOCK = 'locks/sessions.lock' # under STATE: the view-session lock (sessions_lock, DEF-1792)
|
|
986
|
+
SESSIONS_POLL = 0.1 # seconds between two tries while another process holds it
|
|
987
|
+
_SESSIONS_DOING = re.compile(r'[A-Za-z0-9_. -]{1,80}\Z')
|
|
988
|
+
_SESSIONS_SINCE = re.compile(r'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\Z')
|
|
989
|
+
_sessions_depth = 0 # how many sessions_lock blocks this process is inside (re-entry)
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
class SessionsBusy(LanesError):
|
|
993
|
+
"""sessions_lock was not taken in time: another lanes process is acting on the view sessions."""
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def sessions_holder(path=None):
|
|
997
|
+
"""Who holds the view-session lock, as sessions_lock recorded it: 'lanes rebuild mac (pid 4242, since
|
|
998
|
+
2026-10-07T08:40:12Z)'. 'another lanes process' when the record is missing, cut short or not one sessions_lock
|
|
999
|
+
writes: no file text reaches a terminal unchecked."""
|
|
1000
|
+
record = read_json(STATE / SESSIONS_LOCK if path is None else path)
|
|
1001
|
+
if (isinstance(record, dict) and isinstance(record.get('doing'), str) and _SESSIONS_DOING.match(record['doing'])
|
|
1002
|
+
and type(record.get('pid')) is int and record['pid'] > 0
|
|
1003
|
+
and _SESSIONS_SINCE.match(str(record.get('since', '')))):
|
|
1004
|
+
return f"{record['doing']} (pid {record['pid']}, since {record['since']})"
|
|
1005
|
+
return 'another lanes process'
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
@contextlib.contextmanager
|
|
1009
|
+
def sessions_lock(timeout, doing):
|
|
1010
|
+
"""Serialize every check-then-act on the zellij view sessions across processes (DEF-1792): `lanes ensure` (the
|
|
1011
|
+
minute timer, and `--if-layout-changed`, the server update's last step), `lanes rebuild` (also the sidebar's `r`),
|
|
1012
|
+
the create of `lanes attach`, `prepare` and `web`, the end `lanes offboard` gives a view and `lanes device forget`.
|
|
1013
|
+
Each decides from the session list (or the devices/ record) and then creates, kills or deletes a session, or removes
|
|
1014
|
+
the record `ensure` recreates from; interleaved, one could start a second `zellij attach --create-background` for a
|
|
1015
|
+
name another had just killed and was about to create.
|
|
1016
|
+
|
|
1017
|
+
An exclusive flock(2) on STATE/locks/sessions.lock, tried every SESSIONS_POLL s for up to `timeout` s (one line on
|
|
1018
|
+
stderr the first time it is busy, naming the holder). Taken, it records `{pid, doing, since}` for a waiter to name
|
|
1019
|
+
(`doing`: what this process is, e.g. 'lanes rebuild mac'), and empties the record again when the block ends. The
|
|
1020
|
+
lock belongs to the open file: a holder that dies releases it, and no child inherits it (O_CLOEXEC; subprocess
|
|
1021
|
+
closes every other descriptor). Re-entrant within one process — `lanes ensure` rebuilds through `cmd_rebuild` while
|
|
1022
|
+
it holds it. Raises SessionsBusy (naming the holder) when not taken in time, LanesError when the file cannot be
|
|
1023
|
+
opened."""
|
|
1024
|
+
global _sessions_depth
|
|
1025
|
+
if _sessions_depth:
|
|
1026
|
+
_sessions_depth += 1
|
|
1027
|
+
try:
|
|
1028
|
+
yield
|
|
1029
|
+
finally:
|
|
1030
|
+
_sessions_depth -= 1
|
|
1031
|
+
return
|
|
1032
|
+
path = STATE / SESSIONS_LOCK
|
|
1033
|
+
try:
|
|
1034
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1035
|
+
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
|
|
1036
|
+
except OSError as error:
|
|
1037
|
+
raise LanesError(f'cannot open the view-session lock {path}: {error.strerror or error}')
|
|
1038
|
+
try:
|
|
1039
|
+
deadline, said = time.monotonic() + timeout, False
|
|
1040
|
+
while True:
|
|
1041
|
+
try:
|
|
1042
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1043
|
+
break
|
|
1044
|
+
except BlockingIOError:
|
|
1045
|
+
if time.monotonic() >= deadline:
|
|
1046
|
+
raise SessionsBusy(f'{sessions_holder(path)} is acting on the view sessions '
|
|
1047
|
+
f'(waited {timeout:g} s)')
|
|
1048
|
+
if not said:
|
|
1049
|
+
print(f'lanes: {sessions_holder(path)} is acting on the view sessions; waiting up to {timeout:g} s',
|
|
1050
|
+
file=sys.stderr, flush=True)
|
|
1051
|
+
said = True
|
|
1052
|
+
time.sleep(SESSIONS_POLL)
|
|
1053
|
+
os.ftruncate(descriptor, 0)
|
|
1054
|
+
os.pwrite(descriptor, (json.dumps({'pid': os.getpid(), 'doing': doing,
|
|
1055
|
+
'since': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())})
|
|
1056
|
+
+ '\n').encode(), 0)
|
|
1057
|
+
_sessions_depth = 1
|
|
1058
|
+
try:
|
|
1059
|
+
yield
|
|
1060
|
+
finally:
|
|
1061
|
+
_sessions_depth = 0
|
|
1062
|
+
try:
|
|
1063
|
+
os.ftruncate(descriptor, 0)
|
|
1064
|
+
except OSError:
|
|
1065
|
+
pass
|
|
1066
|
+
finally:
|
|
1067
|
+
os.close(descriptor) # releases the lock
|
|
1068
|
+
|
|
1069
|
+
|
|
571
1070
|
def _require(pattern, value, label):
|
|
572
1071
|
if not isinstance(value, str) or not pattern.match(value):
|
|
573
1072
|
raise LanesError(f'Invalid {label}: {value!r}')
|
|
@@ -580,9 +1079,20 @@ def _title(value):
|
|
|
580
1079
|
return value
|
|
581
1080
|
|
|
582
1081
|
|
|
1082
|
+
def declared_tmux(registry_doc, name):
|
|
1083
|
+
"""The tmux binding ({socketName, sessionName}) one terminals.json declares for its terminal `name` — the terminal's
|
|
1084
|
+
own `lanes.terminals[name].tmux`, else the one recorded under `throttle.terminals[name]` — exactly as
|
|
1085
|
+
_workspace_lanes reads it; None when the file declares none (the manifest's recoveries and overrides are the
|
|
1086
|
+
board's, not the file's). session_actions.promote_to_registered asks it which session a file already binds."""
|
|
1087
|
+
own = ((registry_doc.get('lanes') or {}).get('terminals') or {}).get(name) or {}
|
|
1088
|
+
return own.get('tmux') or ((((registry_doc.get('throttle') or {}).get('terminals') or {}).get(name) or {})
|
|
1089
|
+
.get('tmux'))
|
|
1090
|
+
|
|
1091
|
+
|
|
583
1092
|
def _workspace_lanes(manifest, containers):
|
|
584
1093
|
"""Port of coder-launch/manage.mjs: explicit tmux bindings only; nothing is ever executed."""
|
|
585
1094
|
lanes = []
|
|
1095
|
+
layers = [] # (lane, title, host override, container-declared name): _settle_declared_displays
|
|
586
1096
|
names = set()
|
|
587
1097
|
repo_ids = set()
|
|
588
1098
|
for repo in manifest.get('repositories') or []:
|
|
@@ -602,7 +1112,6 @@ def _workspace_lanes(manifest, containers):
|
|
|
602
1112
|
if digest != source.get('sha256'):
|
|
603
1113
|
raise LanesError(f"Binding source changed: {source.get('path')}; inspect its tmux targets "
|
|
604
1114
|
'before updating the saved bindings.')
|
|
605
|
-
throttle = ((registry.get('throttle') or {}).get('terminals') or {})
|
|
606
1115
|
declared = ((registry.get('lanes') or {}).get('terminals') or {})
|
|
607
1116
|
folder = repo.get('registry', '').split('/.vscode/')[0] if '/.vscode/' in repo.get('registry', '') \
|
|
608
1117
|
else (manifest.get('remoteShell') or {}).get('cwd') or CONTAINER_HOME
|
|
@@ -611,7 +1120,7 @@ def _workspace_lanes(manifest, containers):
|
|
|
611
1120
|
own = declared.get(name) or {}
|
|
612
1121
|
# `lanes cursor-sync` records the binding and the original command next to the terminal, so the
|
|
613
1122
|
# Cursor-facing `command` can be the uniform `lane open <id>` entry point.
|
|
614
|
-
binding =
|
|
1123
|
+
binding = declared_tmux(registry, name)
|
|
615
1124
|
create = own.get('create') if 'create' in own else entry.get('command')
|
|
616
1125
|
recovery = None if own.get('tmux') else (repo.get('bindingRecoveries') or {}).get(name)
|
|
617
1126
|
if recovery:
|
|
@@ -638,11 +1147,15 @@ def _workspace_lanes(manifest, containers):
|
|
|
638
1147
|
names.add(title)
|
|
639
1148
|
color = entry.get('color') if isinstance(entry.get('color'), str) else None
|
|
640
1149
|
icon = entry.get('icon') if isinstance(entry.get('icon'), str) else None
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
|
|
644
|
-
lanes.
|
|
645
|
-
|
|
1150
|
+
# Two display layers, both keyed by the DECLARED (unprefixed) terminal name and both changing `name` only:
|
|
1151
|
+
# the id derives from `registry_name` (lane_id_for). The host manifest's `repositories[i].displayNames`
|
|
1152
|
+
# (the operator's override, written by session_actions.rename_registered) wins over the container's own
|
|
1153
|
+
# `lanes.terminals[<name>].displayName` (LNS-33: what the repository, or an agent in the container,
|
|
1154
|
+
# declares). The container layer is read leniently, like color/icon: a value that is no usable lane name
|
|
1155
|
+
# is ignored, never a board error — and it is settled only once the whole board is known (below).
|
|
1156
|
+
override = (repo.get('displayNames') or {}).get(name)
|
|
1157
|
+
lane = {
|
|
1158
|
+
'name': _title(override) if override else title, 'registry_name': title, 'repo': repo_id, 'container': container, 'holder': 'tmux',
|
|
646
1159
|
'tmux': {'socket': _require(IDENT, binding.get('socketName'), 'tmux socket'),
|
|
647
1160
|
'session': _require(TMUX_SESSION, binding.get('sessionName'), 'tmux session')},
|
|
648
1161
|
'focus': entry.get('focus') is True,
|
|
@@ -650,10 +1163,56 @@ def _workspace_lanes(manifest, containers):
|
|
|
650
1163
|
'icon': LANE_ICONS.get(icon, '💻'),
|
|
651
1164
|
'create': create if isinstance(create, str) and LANE_OPEN not in create else None,
|
|
652
1165
|
'folder': folder, 'source': repo.get('registry'),
|
|
653
|
-
}
|
|
1166
|
+
}
|
|
1167
|
+
lanes.append(lane)
|
|
1168
|
+
layers.append((lane, title, override or None, _declared_display(own.get('displayName'))))
|
|
1169
|
+
_settle_declared_displays(layers)
|
|
654
1170
|
return lanes
|
|
655
1171
|
|
|
656
1172
|
|
|
1173
|
+
def _settle_declared_displays(layers):
|
|
1174
|
+
"""The container layer names only its OWN lane (LNS-33 review fix). The "Duplicate lane names" refusal above
|
|
1175
|
+
covers titles; a container-declared name is not refused but DROPPED when another lane of the same board also
|
|
1176
|
+
answers to it — that lane's title (the name it shows the moment its override is cleared), its host `displayNames`
|
|
1177
|
+
override (the operator wins), or its own container-declared name (dropped on both sides, so terminal order never
|
|
1178
|
+
decides who keeps it). Names are compared as a row reads them (`_shown_key`). A dropped name leaves the lane on
|
|
1179
|
+
its override or title, with no board error. A kept one is carried as `declared_display` — only then, so a board
|
|
1180
|
+
that declares none publishes the registry it did before — because a rename back to it drops the override; a
|
|
1181
|
+
dropped one is not, so a rename back to the title is "back to the default" (rename_registered).
|
|
1182
|
+
`layers` is [(lane, title, override or None, declared or None)] in board order; the lanes are updated in place."""
|
|
1183
|
+
holders = {}
|
|
1184
|
+
for lane, title, override, declared in layers:
|
|
1185
|
+
for key in {_shown_key(value) for value in (title, override, declared) if value}:
|
|
1186
|
+
holders[key] = holders.get(key, 0) + 1
|
|
1187
|
+
for lane, title, override, declared in layers:
|
|
1188
|
+
if declared and holders[_shown_key(declared)] == 1:
|
|
1189
|
+
lane['declared_display'] = declared
|
|
1190
|
+
if not override:
|
|
1191
|
+
lane['name'] = declared
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
def _shown_key(value):
|
|
1195
|
+
"""A lane name as a row reads it: Unicode compatibility forms folded (NFKC), invisible format characters (Cf:
|
|
1196
|
+
zero-width space/joiner, word joiner, BOM) dropped, whitespace runs collapsed and trimmed. Two names with the same
|
|
1197
|
+
key are two rows nobody can tell apart; an empty key is a row that reads blank."""
|
|
1198
|
+
text = ''.join(ch for ch in unicodedata.normalize('NFKC', value) if unicodedata.category(ch) != 'Cf')
|
|
1199
|
+
return ' '.join(text.split())
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _declared_display(value):
|
|
1203
|
+
"""A container-declared `displayName` (terminals.json), or None when it is no usable lane name: not a string,
|
|
1204
|
+
reading blank (whitespace or invisible format characters only), no UTF-8 text (a lone surrogate JSON can spell,
|
|
1205
|
+
which would make every projection's write raise), or holding a control character (the `_title` rule). Never
|
|
1206
|
+
raises — the container's file must not be able to take a board down over a label."""
|
|
1207
|
+
if not isinstance(value, str) or not _shown_key(value):
|
|
1208
|
+
return None
|
|
1209
|
+
try:
|
|
1210
|
+
value.encode('utf-8')
|
|
1211
|
+
return _title(value)
|
|
1212
|
+
except (LanesError, UnicodeEncodeError):
|
|
1213
|
+
return None
|
|
1214
|
+
|
|
1215
|
+
|
|
657
1216
|
def _workspace_cwd(manifest):
|
|
658
1217
|
cwd = (manifest.get('remoteShell') or {}).get('cwd')
|
|
659
1218
|
if cwd:
|
|
@@ -927,9 +1486,11 @@ def publish_registry(registry):
|
|
|
927
1486
|
"""Publish a prepared registry while holding registry_lock(). Every projection is written only when its content
|
|
928
1487
|
changed (plan Task 17): `lanes sync` runs every 2 minutes and a registry.json rewritten with nothing but a new
|
|
929
1488
|
`generated_at` made statusd, notify and every sidebar reload it. An unchanged registry.json therefore keeps its
|
|
930
|
-
OLD `generated_at`; the sync itself is recorded in `registry.synced`, which only `lanes doctor` reads.
|
|
1489
|
+
OLD `generated_at`; the sync itself is recorded in `registry.synced`, which only `lanes doctor` reads.
|
|
1490
|
+
-> [(container, why)] for each container whose `~/.lz/registry.json` was refused (DEF-1796): it costs only that
|
|
1491
|
+
container, never the other projections; `lanes sync` reports it."""
|
|
931
1492
|
write_resolve_files(registry)
|
|
932
|
-
export_container_registries(registry)
|
|
1493
|
+
_written, refused = export_container_registries(registry)
|
|
933
1494
|
for workspace in registry['workspaces']:
|
|
934
1495
|
focus = next((lane for lane in workspace['lanes'] if lane.get('focus')), None)
|
|
935
1496
|
first = focus or (workspace['lanes'][0] if workspace['lanes'] else None)
|
|
@@ -937,6 +1498,7 @@ def publish_registry(registry):
|
|
|
937
1498
|
if not _same_json(STATE / 'registry.json', registry):
|
|
938
1499
|
atomic_write(STATE / 'registry.json', json.dumps(registry, indent=1, ensure_ascii=False) + '\n')
|
|
939
1500
|
atomic_write(STATE / 'registry.synced', f"{registry['generated_at']}\n")
|
|
1501
|
+
return refused
|
|
940
1502
|
|
|
941
1503
|
|
|
942
1504
|
def registry_synced_at():
|
|
@@ -974,7 +1536,13 @@ def write_resolve_files(registry):
|
|
|
974
1536
|
|
|
975
1537
|
def export_container_registries(registry):
|
|
976
1538
|
"""/home/coder/.lz/registry.json inside each container (written through the volume path): what `lane open`
|
|
977
|
-
needs — holder, exact target, create recipe, folder. Only where the lane contract is installed.
|
|
1539
|
+
needs — holder, exact target, create recipe, folder. Only where the lane contract is installed.
|
|
1540
|
+
|
|
1541
|
+
LNS-32: each entry also says who declares the lane and where, for the in-container Agent API (`lane-mcp`):
|
|
1542
|
+
`source` (the container's spelling of its terminals.json), `repo` (the manifest's repository id) and
|
|
1543
|
+
`registry_name` (the declared terminal name with the repository prefix — what the id derives from, whatever the
|
|
1544
|
+
display name), all null for a session adopted through `registered-sessions.json` (`registered_session: true`),
|
|
1545
|
+
plus `container`. Additive within version 1: `lane` and the Studio's registry reader read by key."""
|
|
978
1546
|
per_container = {}
|
|
979
1547
|
for workspace in registry['workspaces']:
|
|
980
1548
|
for lane in workspace['lanes']:
|
|
@@ -982,17 +1550,47 @@ def export_container_registries(registry):
|
|
|
982
1550
|
continue
|
|
983
1551
|
per_container.setdefault(lane['container'], {})[lane['id']] = {
|
|
984
1552
|
'name': lane['name'], 'ws': workspace['id'], 'holder': lane['holder'], 'tmux': lane.get('tmux'),
|
|
985
|
-
'zmx': lane.get('zmx'), 'create': lane.get('create'), 'folder': lane.get('folder')
|
|
986
|
-
|
|
1553
|
+
'zmx': lane.get('zmx'), 'create': lane.get('create'), 'folder': lane.get('folder'),
|
|
1554
|
+
'source': lane.get('source'), 'repo': lane.get('repo'), 'registry_name': lane.get('registry_name'),
|
|
1555
|
+
'container': lane['container'], 'registered_session': bool(lane.get('registered_session'))}
|
|
1556
|
+
written, refused = [], []
|
|
987
1557
|
for container, lanes in per_container.items():
|
|
988
1558
|
home = (registry['containers'].get(container) or {}).get('home')
|
|
989
|
-
if
|
|
1559
|
+
if not home:
|
|
1560
|
+
continue
|
|
1561
|
+
# `~/.lz` belongs to the container: walked without following a link, read without blocking on a FIFO,
|
|
1562
|
+
# replaced by rename (DEF-1796: a planted absolute symlink redirected this write on the HOST — into another
|
|
1563
|
+
# container's ~/.lz through its `~/.lz/<name>` host link — and a FIFO at registry.json hung the sync).
|
|
1564
|
+
try:
|
|
1565
|
+
fd = open_real_dir(home, ('.lz',))
|
|
1566
|
+
except FileNotFoundError:
|
|
1567
|
+
continue # no ~/.lz: the lane contract is not installed there
|
|
1568
|
+
except OSError as error:
|
|
1569
|
+
refused.append((container, f'~/.lz is not a real directory ({type(error).__name__}: {error})'))
|
|
1570
|
+
continue
|
|
1571
|
+
try:
|
|
1572
|
+
try:
|
|
1573
|
+
os.stat('bin/lane', dir_fd=fd)
|
|
1574
|
+
except FileNotFoundError:
|
|
1575
|
+
continue # a ~/.lz without `lane`: not installed either
|
|
990
1576
|
document = {'version': 1, 'generated_at': registry['generated_at'], 'lanes': lanes}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1577
|
+
old = read_regular_at(fd, 'registry.json', EXPORT_READ_MAX)
|
|
1578
|
+
try:
|
|
1579
|
+
old = json.loads(old) if old is not None else None
|
|
1580
|
+
except ValueError:
|
|
1581
|
+
old = None
|
|
1582
|
+
if not _same_document(old, document): # `generated_at` alone is no change
|
|
1583
|
+
atomic_write_at(fd, 'registry.json', json.dumps(document, ensure_ascii=False, indent=1) + '\n',
|
|
1584
|
+
mode=0o644)
|
|
994
1585
|
written.append(container)
|
|
995
|
-
|
|
1586
|
+
except OSError as error:
|
|
1587
|
+
refused.append((container, f'~/.lz/registry.json not written ({type(error).__name__}: {error})'))
|
|
1588
|
+
finally:
|
|
1589
|
+
os.close(fd)
|
|
1590
|
+
return written, refused
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
EXPORT_READ_MAX = 16 << 20 # a ~/.lz/registry.json bigger than this is not ours: it is replaced, never read whole
|
|
996
1594
|
|
|
997
1595
|
|
|
998
1596
|
def load_registry():
|