@vibelord/lanes 1.0.0 → 1.0.1
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 +56 -1
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +281 -104
- package/payload/lanes/client/lanes-launch +44 -2
- package/payload/lanes/container/env.sh +8 -0
- package/payload/lanes/container/lane +19 -2
- package/payload/lanes/docs/ARCHITECTURE.md +86 -1
- package/payload/lanes/docs/DESIGN.md +72 -0
- package/payload/lanes/docs/INSTALL.md +39 -0
- package/payload/lanes/docs/RUNBOOK.md +94 -1
- package/payload/lanes/holders/ssh.sh +18 -0
- package/payload/lanes/install/enable-phone.sh +15 -6
- package/payload/lanes/install/enable-web.sh +19 -6
- package/payload/lanes/libexec/agent_state.py +214 -0
- package/payload/lanes/libexec/connection.py +178 -0
- package/payload/lanes/libexec/cursor_sync.py +139 -6
- package/payload/lanes/libexec/device_sync.py +134 -0
- package/payload/lanes/libexec/lanes_lib.py +158 -5
- package/payload/lanes/libexec/registrations.py +148 -0
- package/payload/lanes/libexec/session_actions.py +47 -15
- package/payload/lanes/libexec/sidebar.py +372 -61
- package/payload/lanes/libexec/statusd.py +126 -33
- package/payload/lanes/libexec/tokens.py +279 -0
- package/payload/lanes/libexec/tokens.sh +11 -0
- package/payload/lanes/libexec/view.sh +9 -8
- package/payload/lanes/web/README.md +15 -0
- package/payload/lanes/web/browser.css +22 -0
- package/payload/lanes/web/browser.js +73 -0
- package/payload/lanes/web/mobile.css +65 -33
- package/payload/lanes/web/mobile.js +23 -5
- package/payload/lanes/workspaces.example/README.md +9 -3
- package/payload/lanes/zellij/config.kdl +61 -3
- package/payload/manifest.json +6 -3
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Reconcile catalog tabs without switching focus or touching persistent agents."""
|
|
2
|
+
import fcntl
|
|
3
|
+
import json
|
|
4
|
+
import shlex
|
|
5
|
+
|
|
6
|
+
import lanes_lib as L
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def observe(device, call):
|
|
10
|
+
values = []
|
|
11
|
+
for action in ('list-tabs', 'list-panes'):
|
|
12
|
+
rc, output, error = call(['-s', device, 'action', action, '--json'], 20)
|
|
13
|
+
if rc != 0:
|
|
14
|
+
raise L.LanesError(f'{device}: cannot read {action}: {error or "no answer"}')
|
|
15
|
+
try:
|
|
16
|
+
value = json.loads(output)
|
|
17
|
+
if not isinstance(value, list):
|
|
18
|
+
raise ValueError('expected a list')
|
|
19
|
+
except (ValueError, TypeError) as error:
|
|
20
|
+
raise L.LanesError(f'{device}: invalid {action} response') from error
|
|
21
|
+
values.append(value)
|
|
22
|
+
tabs, panes = values
|
|
23
|
+
if tabs and not panes:
|
|
24
|
+
raise L.LanesError(f"{device}: pane identities unavailable")
|
|
25
|
+
if any(type(t.get('tab_id')) is not int or type(t.get('position')) is not int for t in tabs):
|
|
26
|
+
raise L.LanesError(f'{device}: tab identities unavailable')
|
|
27
|
+
return tabs, panes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def identities(device, tabs, panes, registry, mobile):
|
|
31
|
+
"""Read workspace IDs from the launched commands, independent of tab labels."""
|
|
32
|
+
by_tab = {}
|
|
33
|
+
for pane in panes:
|
|
34
|
+
if pane.get('is_plugin'):
|
|
35
|
+
continue
|
|
36
|
+
try:
|
|
37
|
+
args = shlex.split(pane.get('terminal_command') or pane.get('pane_command') or '')
|
|
38
|
+
except ValueError:
|
|
39
|
+
continue
|
|
40
|
+
for script in ('sidebar', 'view.sh'):
|
|
41
|
+
path = str(L.REPO / 'libexec' / script)
|
|
42
|
+
if path not in args:
|
|
43
|
+
continue
|
|
44
|
+
tail = args[args.index(path) + 1:]
|
|
45
|
+
if len(tail) < 2 or tail[0] != device:
|
|
46
|
+
continue
|
|
47
|
+
workspace = tail[1]
|
|
48
|
+
if workspace.startswith('_'):
|
|
49
|
+
key = 'tool/' + workspace
|
|
50
|
+
else:
|
|
51
|
+
role = 'picker' if mobile and script == 'sidebar' else 'view'
|
|
52
|
+
key = f'workspace/{workspace}/{role}'
|
|
53
|
+
by_tab.setdefault(pane['tab_id'], set()).add(key)
|
|
54
|
+
holder = str(L.REPO / 'holders/ssh.sh')
|
|
55
|
+
if holder in args:
|
|
56
|
+
tail = args[args.index(holder) + 1:]
|
|
57
|
+
for workspace in registry.get('ssh_workspaces', []):
|
|
58
|
+
if (len(tail) == 3 and tail[:2] == [workspace['host'], workspace['command']]
|
|
59
|
+
and any(pane['id'] == tail[2] for pane in workspace['panes'])):
|
|
60
|
+
by_tab.setdefault(pane['tab_id'], set()).add('ssh/' + workspace['id'])
|
|
61
|
+
result = {}
|
|
62
|
+
for tab in tabs:
|
|
63
|
+
keys = by_tab.get(tab['tab_id'], set())
|
|
64
|
+
if not keys and tab['name'] == 'Host':
|
|
65
|
+
keys = {'host'}
|
|
66
|
+
if len(keys) > 1:
|
|
67
|
+
raise L.LanesError(f'{device}: ambiguous workspace tab {tab["tab_id"]}')
|
|
68
|
+
for key in keys:
|
|
69
|
+
if key in result:
|
|
70
|
+
raise L.LanesError(f'{device}: duplicate workspace identity {key}')
|
|
71
|
+
result[key] = tab
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def reconcile(device, registry, specs, mobile, call):
|
|
76
|
+
lock = L.STATE / 'locks' / f'device-sync.{device}.lock'
|
|
77
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
with lock.open('a') as handle:
|
|
79
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
80
|
+
tabs, panes = observe(device, call)
|
|
81
|
+
known = identities(device, tabs, panes, registry, mobile)
|
|
82
|
+
wanted = []
|
|
83
|
+
changes = []
|
|
84
|
+
|
|
85
|
+
def action(args):
|
|
86
|
+
rc, output, error = call(['-s', device, 'action'] + args, 30)
|
|
87
|
+
if rc != 0:
|
|
88
|
+
raise L.LanesError(f'{device}: {args[0]} failed: {error or "no answer"}')
|
|
89
|
+
return output
|
|
90
|
+
|
|
91
|
+
for spec in specs:
|
|
92
|
+
tab = known.get(spec['key'])
|
|
93
|
+
if tab is None:
|
|
94
|
+
if any(t['name'] == spec['title'] for t in tabs):
|
|
95
|
+
raise L.LanesError(f'{device}: tab {spec["title"]!r} has no matching workspace identity; left intact')
|
|
96
|
+
# Zellij 0.45.1 acknowledges background tabs without spawning their
|
|
97
|
+
# panes when no client is attached. Wait for a client; the ensure
|
|
98
|
+
# timer retries after attach without leaving empty tabs behind.
|
|
99
|
+
if not any(t.get('active') is True for t in tabs):
|
|
100
|
+
continue
|
|
101
|
+
output = action(['new-tab', '--no-focus', '--name', spec['title'],
|
|
102
|
+
'--layout-string', spec['layout']])
|
|
103
|
+
# Zellij 0.45.1 returns the stable ID. Do not guess after a timeout
|
|
104
|
+
# or repeat creation if the answer is missing: next sync observes it.
|
|
105
|
+
try:
|
|
106
|
+
tab_id = int(output.strip())
|
|
107
|
+
except ValueError as error:
|
|
108
|
+
raise L.LanesError(f'{device}: created tab ID unavailable; retry sync after observation') from error
|
|
109
|
+
tab = {'tab_id': tab_id, 'name': spec['title'], 'position': len(tabs)}
|
|
110
|
+
tabs.append(tab)
|
|
111
|
+
changes.append('added ' + spec['title'])
|
|
112
|
+
elif tab['name'] != spec['title']:
|
|
113
|
+
action(['rename-tab', spec['title'], '--tab-id', str(tab['tab_id'])])
|
|
114
|
+
changes.append('renamed ' + tab['name'] + ' to ' + spec['title'])
|
|
115
|
+
wanted.append(tab['tab_id'])
|
|
116
|
+
|
|
117
|
+
# Sort managed tabs by adjacent moves using stable IDs. Each observation
|
|
118
|
+
# must answer. Unmanaged tabs remain open; no close/rebuild/focus action exists.
|
|
119
|
+
for position, tab_id in enumerate(wanted):
|
|
120
|
+
for _ in range(len(tabs) + 1):
|
|
121
|
+
rc, output, error = call(['-s', device, 'action', 'list-tabs', '--json'], 20)
|
|
122
|
+
if rc != 0:
|
|
123
|
+
raise L.LanesError(f'{device}: cannot check tab order: {error or "no answer"}')
|
|
124
|
+
live = json.loads(output)
|
|
125
|
+
tab = next((t for t in live if t.get('tab_id') == tab_id), None)
|
|
126
|
+
if tab is None:
|
|
127
|
+
raise L.LanesError(f'{device}: workspace tab changed during sync')
|
|
128
|
+
if tab['position'] == position:
|
|
129
|
+
break
|
|
130
|
+
action(['move-tab', 'left' if tab['position'] > position else 'right',
|
|
131
|
+
'--tab-id', str(tab_id)])
|
|
132
|
+
else:
|
|
133
|
+
raise L.LanesError(f'{device}: tab order did not settle')
|
|
134
|
+
return changes
|
|
@@ -6,10 +6,13 @@ Safety rules encoded here (see docs/ARCHITECTURE.md):
|
|
|
6
6
|
* In-container sockets are reached through /proc/<container-init-pid>/root, anchored on the
|
|
7
7
|
container's init pid (stable across tmux-server restarts), never through docker exec.
|
|
8
8
|
"""
|
|
9
|
+
import contextlib
|
|
10
|
+
import fcntl
|
|
9
11
|
import hashlib
|
|
10
12
|
import json
|
|
11
13
|
import os
|
|
12
14
|
import re
|
|
15
|
+
import shlex
|
|
13
16
|
import subprocess
|
|
14
17
|
import tempfile
|
|
15
18
|
import time
|
|
@@ -397,6 +400,26 @@ def read_health(state_dir=None):
|
|
|
397
400
|
|
|
398
401
|
# ------------------------------------------------------------------ registry
|
|
399
402
|
|
|
403
|
+
@contextlib.contextmanager
|
|
404
|
+
def registry_lock(timeout=5):
|
|
405
|
+
"""Serialize sync, registration and close across device sidebars and the timer."""
|
|
406
|
+
STATE.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
with (STATE / 'registry.lock').open('a') as handle:
|
|
408
|
+
deadline = time.monotonic() + timeout
|
|
409
|
+
while True:
|
|
410
|
+
try:
|
|
411
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
412
|
+
break
|
|
413
|
+
except BlockingIOError:
|
|
414
|
+
if time.monotonic() >= deadline:
|
|
415
|
+
raise LanesError('Workspace sync is busy; try again shortly')
|
|
416
|
+
time.sleep(0.05)
|
|
417
|
+
try:
|
|
418
|
+
yield
|
|
419
|
+
finally:
|
|
420
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
421
|
+
|
|
422
|
+
|
|
400
423
|
def _require(pattern, value, label):
|
|
401
424
|
if not isinstance(value, str) or not pattern.match(value):
|
|
402
425
|
raise LanesError(f'Invalid {label}: {value!r}')
|
|
@@ -467,6 +490,8 @@ def _workspace_lanes(manifest, containers):
|
|
|
467
490
|
names.add(title)
|
|
468
491
|
color = entry.get('color') if isinstance(entry.get('color'), str) else None
|
|
469
492
|
icon = entry.get('icon') if isinstance(entry.get('icon'), str) else None
|
|
493
|
+
# Keyed by the DECLARED (unprefixed) terminal name; written by session_actions.rename_registered.
|
|
494
|
+
# The override changes `name` only: the id derives from `registry_name` (lane_id_for).
|
|
470
495
|
display = (repo.get('displayNames') or {}).get(name)
|
|
471
496
|
lanes.append({
|
|
472
497
|
'name': _title(display) if display else title, 'registry_name': title, 'repo': repo_id, 'container': container, 'holder': 'tmux',
|
|
@@ -499,8 +524,112 @@ def read_site_json(path):
|
|
|
499
524
|
return data
|
|
500
525
|
|
|
501
526
|
|
|
502
|
-
def
|
|
527
|
+
def manifest_path(workspace_id):
|
|
528
|
+
"""The `workspaces.<board>.json` that build_registry opens for this board (resolved through catalog.json)."""
|
|
503
529
|
catalog = read_site_json(workspaces_dir() / 'catalog.json')['workspaces']
|
|
530
|
+
workspace = next((w for w in catalog if w.get('id') == workspace_id), None)
|
|
531
|
+
if workspace is None:
|
|
532
|
+
raise LanesError(f'Workspace {workspace_id!r} is not in catalog.json')
|
|
533
|
+
return workspaces_dir() / workspace['manifest']
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def lane_id_for(workspace_id, lane):
|
|
537
|
+
"""The stable id: workspace + slug of the DECLARED terminal name. A display override (`displayNames`)
|
|
538
|
+
changes `name`, never the id, or every resolve file, view target and `lane open <id>` would break."""
|
|
539
|
+
return f"{workspace_id}--{slug(lane.get('registry_name') or lane['name'])}"
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# The proxy's `x-throttle-trace` charset and cap (src/proxy.rs): a lane trace outside it would be dropped there.
|
|
543
|
+
TRACE = re.compile(r'[A-Za-z0-9._/-]{1,256}')
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def lane_trace(lane_id, ws):
|
|
547
|
+
"""`lanes/<ws>/<id>` — the trace a lane's agents send as `x-throttle-trace`; None if the proxy would reject it."""
|
|
548
|
+
if not isinstance(ws, str) or not isinstance(lane_id, str):
|
|
549
|
+
return None
|
|
550
|
+
trace = f'lanes/{ws}/{lane_id}'
|
|
551
|
+
return trace if TRACE.fullmatch(trace) else None
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def canonical_recipe(lane_id, ws, socket, session, folder, cmd='bash -l'):
|
|
555
|
+
"""The one create recipe that gives a new tmux lane its identity: LANE_ID / LANE_WS for scripts and hooks, the
|
|
556
|
+
lanes/<ws>/<id> trace for Throttle (ANTHROPIC_CUSTOM_HEADERS for Claude Code, THROTTLE_TRACE for the rest).
|
|
557
|
+
`tmux new -A` attaches when the session already exists, so the env lands only on a real create — never through
|
|
558
|
+
`set-environment`, which would leak into unrelated windows. `cmd` is a shell command string, appended as is."""
|
|
559
|
+
trace = lane_trace(lane_id, ws)
|
|
560
|
+
if trace is None:
|
|
561
|
+
raise LanesError(f'Invalid lane trace: lanes/{ws}/{lane_id} (allowed: A-Z a-z 0-9 . _ - /, at most 256)')
|
|
562
|
+
_require(IDENT, socket, 'tmux socket')
|
|
563
|
+
_require(TMUX_SESSION, session, 'tmux session')
|
|
564
|
+
if not isinstance(folder, str) or not folder.startswith('/'):
|
|
565
|
+
raise LanesError(f'Invalid lane folder: {folder!r}')
|
|
566
|
+
if not isinstance(cmd, str) or not cmd.strip():
|
|
567
|
+
raise LanesError('Invalid lane command')
|
|
568
|
+
header = shlex.quote(f'x-throttle-trace: {trace}')
|
|
569
|
+
return (f'tmux -L {shlex.quote(socket)} new -ADs {shlex.quote(session)} -c {shlex.quote(folder)} '
|
|
570
|
+
f'-e LANE_ID={lane_id} -e LANE_WS={ws} -e ANTHROPIC_CUSTOM_HEADERS={header} '
|
|
571
|
+
f'-e THROTTLE_TRACE={trace} {cmd}')
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def ssh_workspaces(catalog):
|
|
575
|
+
"""Native tabs for sessions on hosts outside the workspace containers; no network calls."""
|
|
576
|
+
def require(pattern, value, label):
|
|
577
|
+
if not isinstance(value, str) or not pattern.fullmatch(value):
|
|
578
|
+
raise LanesError(f'Invalid {label}: {value!r}')
|
|
579
|
+
return value
|
|
580
|
+
|
|
581
|
+
result = []
|
|
582
|
+
ids = {w['id'] for w in catalog['workspaces']}
|
|
583
|
+
titles = {w['title'] for w in catalog['workspaces']} | {'Host', 'Throttle'}
|
|
584
|
+
host_pattern = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_.-]*(?:@[A-Za-z0-9_][A-Za-z0-9_.-]*)?$')
|
|
585
|
+
for source in catalog.get('sshWorkspaces', []):
|
|
586
|
+
ident = require(IDENT, source.get('id'), 'SSH workspace id')
|
|
587
|
+
title = _title(source.get('title'))
|
|
588
|
+
if ident in ids or title in titles:
|
|
589
|
+
raise LanesError('Duplicate SSH workspace id or title')
|
|
590
|
+
ids.add(ident)
|
|
591
|
+
titles.add(title)
|
|
592
|
+
host = require(host_pattern, source.get('host'), 'SSH host')
|
|
593
|
+
command = require(SAFE_PATH, source.get('command'), 'SSH command path')
|
|
594
|
+
if '..' in command.split('/'):
|
|
595
|
+
raise LanesError('Unsafe SSH command path')
|
|
596
|
+
panes, pane_ids = [], set()
|
|
597
|
+
for pane in source.get('panes', []):
|
|
598
|
+
pane_id = require(IDENT, pane.get('id'), 'SSH pane id')
|
|
599
|
+
if pane_id in pane_ids:
|
|
600
|
+
raise LanesError('Duplicate SSH pane id')
|
|
601
|
+
pane_ids.add(pane_id)
|
|
602
|
+
panes.append({'id': pane_id, 'name': _title(pane.get('name'))})
|
|
603
|
+
layout = source.get('layout', 'split')
|
|
604
|
+
if layout not in ('split', 'lanes'):
|
|
605
|
+
raise LanesError('SSH workspace layout must be split or lanes')
|
|
606
|
+
limit = 32 if layout == 'lanes' else 3
|
|
607
|
+
if not 1 <= len(panes) <= limit:
|
|
608
|
+
raise LanesError(f'An SSH workspace needs one to {limit} panes')
|
|
609
|
+
position = source.get('position', 'before-host')
|
|
610
|
+
if position not in ('workspace', 'before-host'):
|
|
611
|
+
raise LanesError('SSH workspace position must be workspace or before-host')
|
|
612
|
+
result.append({'id': ident, 'title': title, 'host': host, 'command': command,
|
|
613
|
+
'panes': panes, 'layout': layout, 'position': position})
|
|
614
|
+
return result
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def ssh_workspace_board(workspace):
|
|
618
|
+
"""Use the standard sidebar/view layout for fixed, attach-only remote lanes."""
|
|
619
|
+
return {'id': workspace['id'], 'title': workspace['title'], 'container': '',
|
|
620
|
+
'cwd': '', 'errors': [], 'extras': [], 'discovery_roots': [],
|
|
621
|
+
'attach_only': True, 'position': workspace.get('position', 'before-host'),
|
|
622
|
+
'lanes': [{'id': f"{workspace['id']}--{pane['id']}", 'name': pane['name'],
|
|
623
|
+
'holder': 'ssh', 'container': '', 'color': None, 'icon': '💻',
|
|
624
|
+
'focus': index == 0,
|
|
625
|
+
'ssh': {'host': workspace['host'], 'command': workspace['command'], 'pane': pane['id']}}
|
|
626
|
+
for index, pane in enumerate(workspace['panes'])]}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def build_registry(previous=None):
|
|
630
|
+
site = read_site_json(workspaces_dir() / 'catalog.json')
|
|
631
|
+
remote_tabs = ssh_workspaces(site)
|
|
632
|
+
catalog = site['workspaces']
|
|
504
633
|
manifests = {}
|
|
505
634
|
container_names = set()
|
|
506
635
|
for workspace in catalog:
|
|
@@ -527,7 +656,7 @@ def build_registry(previous=None):
|
|
|
527
656
|
(manifest.get('repositories') or [{}])[0].get('host', ''))
|
|
528
657
|
for lane in lanes:
|
|
529
658
|
if 'id' not in lane: # lanes carried over from a previous registry keep their ids
|
|
530
|
-
base =
|
|
659
|
+
base = lane_id_for(entry['id'], lane)
|
|
531
660
|
lane_id, n = base, 2
|
|
532
661
|
while lane_id in used_ids:
|
|
533
662
|
lane_id, n = f'{base}-{n}', n + 1
|
|
@@ -538,6 +667,8 @@ def build_registry(previous=None):
|
|
|
538
667
|
entry['lanes'] = lanes
|
|
539
668
|
entry['extras'] = extras
|
|
540
669
|
workspaces.append(entry)
|
|
670
|
+
workspaces += [ssh_workspace_board(w) for w in remote_tabs if w['layout'] == 'lanes']
|
|
671
|
+
remote_tabs = [w for w in remote_tabs if w['layout'] != 'lanes']
|
|
541
672
|
# One shared tool tab: the Throttle monitor of the container that hosts most boards.
|
|
542
673
|
hosts = [(m.get('monitor') or {}).get('host') for m in manifests.values() if (m.get('monitor') or {}).get('host')]
|
|
543
674
|
tools = []
|
|
@@ -545,7 +676,21 @@ def build_registry(previous=None):
|
|
|
545
676
|
tools.append({'id': 'tools--throttle-monitor', 'view': '_throttle', 'tab': 'Throttle', 'name': 'Throttle Monitor',
|
|
546
677
|
'holder': 'exec', 'exec': 'monitor', 'container': max(set(hosts), key=hosts.count),
|
|
547
678
|
'cwd': CONTAINER_HOME, 'color': None, 'icon': '📊'})
|
|
548
|
-
|
|
679
|
+
import registrations
|
|
680
|
+
return registrations.apply({'version': 1, 'generated_at': time.time(), 'containers': containers,
|
|
681
|
+
'workspaces': workspaces, 'tools': tools, 'ssh_workspaces': remote_tabs})
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def publish_registry(registry):
|
|
685
|
+
"""Publish a prepared registry while holding registry_lock()."""
|
|
686
|
+
write_resolve_files(registry)
|
|
687
|
+
export_container_registries(registry)
|
|
688
|
+
for workspace in registry['workspaces']:
|
|
689
|
+
focus = next((lane for lane in workspace['lanes'] if lane.get('focus')), None)
|
|
690
|
+
candidates = workspace['lanes'] or workspace.get('extras', [])
|
|
691
|
+
first = focus or (candidates[0] if candidates else None)
|
|
692
|
+
atomic_write(STATE / 'defaults' / workspace['id'], (first['id'] if first else '!empty') + '\n')
|
|
693
|
+
atomic_write(STATE / 'registry.json', json.dumps(registry, indent=1, ensure_ascii=False) + '\n')
|
|
549
694
|
|
|
550
695
|
|
|
551
696
|
def write_resolve_files(registry):
|
|
@@ -561,6 +706,8 @@ def write_resolve_files(registry):
|
|
|
561
706
|
wanted.add(lane['id'])
|
|
562
707
|
if lane['holder'] == 'tmux':
|
|
563
708
|
fields = ['tmux', lane['container'], lane['tmux']['socket'], lane['tmux']['session']]
|
|
709
|
+
elif lane['holder'] == 'ssh':
|
|
710
|
+
fields = ['ssh', lane['ssh']['host'], lane['ssh']['command'], lane['ssh']['pane']]
|
|
564
711
|
elif lane['holder'] == 'zmx':
|
|
565
712
|
fields = ['zmx', lane['container'], lane['zmx']['name'], lane['zmx'].get('version', '')]
|
|
566
713
|
else:
|
|
@@ -577,6 +724,8 @@ def export_container_registries(registry):
|
|
|
577
724
|
per_container = {}
|
|
578
725
|
for workspace in registry['workspaces']:
|
|
579
726
|
for lane in workspace['lanes']:
|
|
727
|
+
if not lane.get('container'):
|
|
728
|
+
continue
|
|
580
729
|
per_container.setdefault(lane['container'], {})[lane['id']] = {
|
|
581
730
|
'name': lane['name'], 'ws': workspace['id'], 'holder': lane['holder'], 'tmux': lane.get('tmux'),
|
|
582
731
|
'zmx': lane.get('zmx'), 'create': lane.get('create'), 'folder': lane.get('folder')}
|
|
@@ -603,8 +752,12 @@ def load_registry():
|
|
|
603
752
|
def find_lane(registry, lane_id):
|
|
604
753
|
status = read_json(STATE / 'status.json', {}) or {}
|
|
605
754
|
for workspace in registry['workspaces']:
|
|
606
|
-
|
|
607
|
-
|
|
755
|
+
for lane in workspace['lanes'] + workspace.get('extras', []):
|
|
756
|
+
if lane['id'] == lane_id:
|
|
757
|
+
return workspace, lane
|
|
758
|
+
# Registration can move a session before the next observer cycle clears its previous workspace.
|
|
759
|
+
for workspace in registry['workspaces']:
|
|
760
|
+
for lane in (status.get('unregistered') or {}).get(workspace['id'], []):
|
|
608
761
|
if lane['id'] == lane_id:
|
|
609
762
|
return workspace, lane
|
|
610
763
|
raise LanesError(f'Unknown lane: {lane_id}')
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Durable, attach-only registrations for sessions adopted through the sidebar or CLI."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import lanes_lib as L
|
|
6
|
+
|
|
7
|
+
FILENAME = 'registered-sessions.json'
|
|
8
|
+
VERSION = 1
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def key(entry):
|
|
12
|
+
binding = entry.get('tmux') or {}
|
|
13
|
+
return entry.get('container'), binding.get('socket'), binding.get('session')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def eligible(entry):
|
|
17
|
+
return entry.get('holder') == 'tmux' and bool(entry.get('tmux')) and not entry.get('legacy')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def destinations(registry, entry):
|
|
21
|
+
if not eligible(entry):
|
|
22
|
+
return []
|
|
23
|
+
return [w for w in registry['workspaces'] if w.get('container') == entry.get('container') or
|
|
24
|
+
any(lane.get('container') == entry.get('container') for lane in w.get('lanes', []))]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load():
|
|
28
|
+
path = L.workspaces_dir() / FILENAME
|
|
29
|
+
try:
|
|
30
|
+
data = json.loads(path.read_text())
|
|
31
|
+
except FileNotFoundError:
|
|
32
|
+
return {'version': VERSION, 'sessions': []}
|
|
33
|
+
except (OSError, ValueError) as error:
|
|
34
|
+
raise L.LanesError(f'Cannot read {path}: {error}') from error
|
|
35
|
+
if not isinstance(data, dict) or type(data.get('version')) is not int or data['version'] != VERSION:
|
|
36
|
+
raise L.LanesError(f'Unsupported {FILENAME} version; this server reads version {VERSION}')
|
|
37
|
+
if not isinstance(data.get('sessions'), list):
|
|
38
|
+
raise L.LanesError(f'Invalid sessions in {FILENAME}')
|
|
39
|
+
ids, bindings = set(), set()
|
|
40
|
+
for record in data['sessions']:
|
|
41
|
+
if not isinstance(record, dict) or not isinstance(record.get('tmux'), dict):
|
|
42
|
+
raise L.LanesError(f'Invalid registration in {FILENAME}')
|
|
43
|
+
for field in ('id', 'workspace', 'container'):
|
|
44
|
+
L._require(L.IDENT, record.get(field), field)
|
|
45
|
+
L._title(record.get('name'))
|
|
46
|
+
L._require(L.IDENT, record['tmux'].get('socket'), 'tmux socket')
|
|
47
|
+
L._require(L.TMUX_SESSION, record['tmux'].get('session'), 'tmux session')
|
|
48
|
+
L._require(L.SAFE_PATH, record.get('folder'), 'working directory')
|
|
49
|
+
if record['id'] in ids or key(record) in bindings:
|
|
50
|
+
raise L.LanesError(f'Duplicate registration in {FILENAME}')
|
|
51
|
+
ids.add(record['id']); bindings.add(key(record))
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def apply(registry, data=None):
|
|
56
|
+
"""Overlay local registrations. Repository declarations win if they adopt the same binding."""
|
|
57
|
+
data = load() if data is None else data
|
|
58
|
+
by_id = {w['id']: w for w in registry['workspaces']}
|
|
59
|
+
for workspace in by_id.values():
|
|
60
|
+
# A stale repository can retain a previous board. Rebuild its local registrations from authority too.
|
|
61
|
+
workspace['lanes'] = [lane for lane in workspace['lanes'] if not lane.get('registered_session')]
|
|
62
|
+
lanes = [lane for w in by_id.values() for lane in w['lanes']]
|
|
63
|
+
bindings = {key(lane) for lane in lanes if lane.get('holder') == 'tmux'}
|
|
64
|
+
ids = {lane['id'] for lane in lanes}
|
|
65
|
+
for record in data['sessions']:
|
|
66
|
+
workspace = by_id.get(record['workspace'])
|
|
67
|
+
if not workspace or key(record) in bindings:
|
|
68
|
+
continue
|
|
69
|
+
lane = dict(record, holder='tmux', registered_session=True, icon='💻', color=None, create=None)
|
|
70
|
+
if workspace not in destinations(registry, lane):
|
|
71
|
+
workspace.setdefault('errors', []).append('Registered session belongs to a different container: ' + record['name'])
|
|
72
|
+
continue
|
|
73
|
+
if lane['id'] in ids:
|
|
74
|
+
raise L.LanesError('Registered session ID collides with another lane: ' + lane['id'])
|
|
75
|
+
workspace['lanes'].append(lane)
|
|
76
|
+
bindings.add(key(lane)); ids.add(lane['id'])
|
|
77
|
+
return registry
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def rename(lane_id, new_name):
|
|
81
|
+
"""Display name of one registered session — this file is its only name authority (`apply` takes the lane's
|
|
82
|
+
name from the record). Only `name` changes: the id, binding, workspace and folder stay, so view targets,
|
|
83
|
+
resolve files and `lane open <id>` keep working. The `user-sessions/` and `resolve.dynamic/` files an
|
|
84
|
+
UNREGISTERED session is renamed through are never read for a registered one (DEF-1412)."""
|
|
85
|
+
name = L._title(new_name)
|
|
86
|
+
with L.registry_lock():
|
|
87
|
+
data = load()
|
|
88
|
+
record = next((item for item in data['sessions'] if item['id'] == lane_id), None)
|
|
89
|
+
if record is None:
|
|
90
|
+
raise L.LanesError(f'{lane_id} is not a registered session in {FILENAME}; reload')
|
|
91
|
+
record['name'] = name
|
|
92
|
+
registry = apply(L.load_registry(), data)
|
|
93
|
+
registry['generated_at'] = time.time()
|
|
94
|
+
L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
|
95
|
+
try:
|
|
96
|
+
L.publish_registry(registry)
|
|
97
|
+
except OSError as error:
|
|
98
|
+
raise L.LanesError('Rename saved; run lanes sync to finish publishing: ' + str(error)) from error
|
|
99
|
+
return next((lane for w in registry['workspaces'] for lane in w['lanes'] if lane['id'] == lane_id),
|
|
100
|
+
dict(record))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def register(workspace, entry, destination=None):
|
|
104
|
+
"""Keep the same binding and ID; registration never starts, stops, renames or reattaches a process."""
|
|
105
|
+
destination = destination or workspace
|
|
106
|
+
with L.registry_lock():
|
|
107
|
+
data = load()
|
|
108
|
+
registry = apply(L.load_registry(), data)
|
|
109
|
+
if not eligible(entry):
|
|
110
|
+
raise L.LanesError('Only reconnectable tmux sessions can be registered')
|
|
111
|
+
for board in registry['workspaces']:
|
|
112
|
+
if any(key(lane) == key(entry) for lane in board['lanes'] if lane.get('holder') == 'tmux'):
|
|
113
|
+
raise L.LanesError('Already registered in ' + (board.get('title') or board['id']))
|
|
114
|
+
board = next((w for w in destinations(registry, entry) if w['id'] == destination), None)
|
|
115
|
+
if not board:
|
|
116
|
+
raise L.LanesError('Choose a workspace that uses this session’s container')
|
|
117
|
+
published = (L.read_json(L.STATE / 'status.json', {}) or {}).get('unregistered', {}).get(workspace, [])
|
|
118
|
+
actual = next((item for item in published if item.get('id') == entry.get('id')), None)
|
|
119
|
+
if not actual or key(actual) != key(entry) or actual.get('identity') != entry.get('identity'):
|
|
120
|
+
raise L.LanesError('Session changed; reload before registering')
|
|
121
|
+
container, socket, name = key(actual)
|
|
122
|
+
L._require(L.IDENT, actual['id'], 'lane id')
|
|
123
|
+
L._require(L.IDENT, container, 'container')
|
|
124
|
+
L._require(L.IDENT, socket, 'tmux socket')
|
|
125
|
+
L._require(L.TMUX_SESSION, name, 'tmux session')
|
|
126
|
+
identity = actual.get('identity') or {}
|
|
127
|
+
sid, created = identity.get('session_id', ''), identity.get('created', '')
|
|
128
|
+
if not isinstance(sid, str) or not isinstance(created, str) or not sid.startswith('$') or not sid[1:].isdigit() or not created.isdigit():
|
|
129
|
+
raise L.LanesError('Session identity is not ready; wait for the next status update')
|
|
130
|
+
info = (L.read_json(L.STATE / 'containers.json', {}) or {}).get(container) or {}
|
|
131
|
+
outcome, observed = L.tmux_probe(info, socket, ['display-message', '-p', '-t', '=' + name + ':',
|
|
132
|
+
'#{session_id}\t#{session_created}'], 5)
|
|
133
|
+
if outcome != 'ok' or observed.strip() != sid + '\t' + created:
|
|
134
|
+
raise L.LanesError('Session changed or could not be checked; nothing was registered')
|
|
135
|
+
record = {'id': actual['id'], 'workspace': destination, 'name': L._title(actual['name']),
|
|
136
|
+
'container': container, 'tmux': dict(actual['tmux']),
|
|
137
|
+
'folder': L._require(L.SAFE_PATH, board.get('cwd') or L.CONTAINER_HOME, 'working directory')}
|
|
138
|
+
if any(item['id'] == record['id'] or key(item) == key(record) for item in data['sessions']):
|
|
139
|
+
raise L.LanesError(f'Registration already saved; check {FILENAME} and run lanes sync')
|
|
140
|
+
data['sessions'].append(record)
|
|
141
|
+
registry = apply(registry, data)
|
|
142
|
+
registry['generated_at'] = time.time()
|
|
143
|
+
L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
|
144
|
+
try:
|
|
145
|
+
L.publish_registry(registry)
|
|
146
|
+
except OSError as error:
|
|
147
|
+
raise L.LanesError('Registration saved; run lanes sync to finish publishing: ' + str(error)) from error
|
|
148
|
+
return next(lane for lane in board['lanes'] if lane['id'] == record['id'])
|
|
@@ -82,6 +82,8 @@ def new_shell(workspace):
|
|
|
82
82
|
board = next((w for w in registry['workspaces'] if w['id'] == workspace), None)
|
|
83
83
|
if not board:
|
|
84
84
|
raise L.LanesError('Workspace no longer exists; reload the sidebar')
|
|
85
|
+
if board.get('attach_only'):
|
|
86
|
+
raise L.LanesError('Select an existing remote terminal in this workspace')
|
|
85
87
|
container = L._require(L.IDENT, board['container'], 'container')
|
|
86
88
|
cwd = L._require(L.SAFE_PATH, board['cwd'], 'working directory')
|
|
87
89
|
socket = shell_socket(workspace)
|
|
@@ -140,19 +142,30 @@ def rename_session(workspace, entry, new_name):
|
|
|
140
142
|
return dict(entry, name=new_name)
|
|
141
143
|
|
|
142
144
|
|
|
143
|
-
def rename_registered(manifest_path, registry_name, new_name):
|
|
144
|
-
"""
|
|
145
|
-
`
|
|
146
|
-
|
|
145
|
+
def rename_registered(manifest_path, repo_id, registry_name, new_name):
|
|
146
|
+
"""Display override for one declared terminal. The reader is `lanes_lib._workspace_lanes`, which looks at
|
|
147
|
+
`repositories[i].displayNames` keyed by the terminal's declared name — the manifest's top level (and the
|
|
148
|
+
lane's terminals.json `source`) has no such key. `registry_name` is the lane's title (repository prefix +
|
|
149
|
+
declared name); renaming back to it clears the override. The lane id never changes (lanes_lib.lane_id_for).
|
|
150
|
+
The first write keeps a `<manifest>.bak-<UTC timestamp>` copy beside it. Callers run `lanes sync`."""
|
|
147
151
|
L._title(new_name)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
152
|
+
path = Path(manifest_path)
|
|
153
|
+
original = path.read_text()
|
|
154
|
+
data = json.loads(original)
|
|
155
|
+
repo = next((r for r in data.get('repositories') or [] if r.get('id') == repo_id), None)
|
|
156
|
+
if repo is None:
|
|
157
|
+
raise L.LanesError(f'Repository {repo_id!r} not in {path.name}')
|
|
158
|
+
prefix = repo.get('prefix') or ''
|
|
159
|
+
declared = registry_name[len(prefix):] if prefix and registry_name.startswith(prefix) else registry_name
|
|
160
|
+
names = repo.setdefault('displayNames', {})
|
|
151
161
|
if new_name == registry_name:
|
|
152
|
-
|
|
162
|
+
names.pop(declared, None) # removing the override restores the default title
|
|
153
163
|
else:
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
names[declared] = new_name
|
|
165
|
+
if not any(path.parent.glob(path.name + '.bak-*')):
|
|
166
|
+
L.atomic_write(path.parent / (path.name + '.bak-' + time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())),
|
|
167
|
+
original, mode=0o644)
|
|
168
|
+
L.atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + '\n', mode=0o644)
|
|
156
169
|
|
|
157
170
|
|
|
158
171
|
def promote_to_registered(workspace_id, entry, registry):
|
|
@@ -163,19 +176,32 @@ def promote_to_registered(workspace_id, entry, registry):
|
|
|
163
176
|
Raises LanesError for unsupported configurations or invalid session state."""
|
|
164
177
|
if entry.get('holder') != 'tmux':
|
|
165
178
|
raise L.LanesError('Only tmux sessions can be promoted (zmx and exec holders are not supported)')
|
|
179
|
+
# A binding any board already holds — a repository declaration or an adopted registered session — is
|
|
180
|
+
# registered. Appending it again would make the repository declaration win the binding on the next sync,
|
|
181
|
+
# re-keying an adopted lane's id and orphaning its view targets (DEF-1411). Nothing is written.
|
|
182
|
+
if entry.get('registered_session') or session_key(entry) in {
|
|
183
|
+
session_key(lane) for w in registry.get('workspaces', []) for lane in w.get('lanes', [])
|
|
184
|
+
if lane.get('holder') == 'tmux'}:
|
|
185
|
+
raise L.LanesError('Already registered; nothing was promoted')
|
|
166
186
|
board = next((w for w in registry.get('workspaces', []) if w['id'] == workspace_id), None)
|
|
167
187
|
if not board:
|
|
168
188
|
raise L.LanesError('Workspace not found')
|
|
169
|
-
# Find the terminals.json source — must be a single unambiguous
|
|
170
|
-
|
|
171
|
-
|
|
189
|
+
# Find the terminals.json source — must be a single unambiguous file. A source is (container, spelling): the
|
|
190
|
+
# same spelling in two containers (one repository cloned in two boxes) is two files, so it is still ambiguous.
|
|
191
|
+
sources = [(lane.get('container') or board.get('container') or '', lane['source'])
|
|
192
|
+
for lane in board.get('lanes', []) if lane.get('source')]
|
|
193
|
+
unique_sources = list(dict.fromkeys(sources))
|
|
172
194
|
if not unique_sources:
|
|
173
195
|
raise L.LanesError(
|
|
174
196
|
'No registry source found for this workspace; add the lane to terminals.json manually')
|
|
175
197
|
if len(unique_sources) > 1:
|
|
176
198
|
raise L.LanesError(
|
|
177
199
|
'Multiple registry sources in this workspace; add the lane to terminals.json manually')
|
|
178
|
-
|
|
200
|
+
# The source is the container's spelling of terminals.json; this process runs on the host, where the file only
|
|
201
|
+
# exists under the container's home volume. Writing Path(source) would miss it (or write a stray host file).
|
|
202
|
+
container, source = unique_sources[0]
|
|
203
|
+
info = (L.read_json(L.STATE / 'containers.json') or {}).get(container) or {}
|
|
204
|
+
terminals_path = L.host_path(info, source)
|
|
179
205
|
if not terminals_path.exists():
|
|
180
206
|
raise L.LanesError(f'Registry file not found: {terminals_path}')
|
|
181
207
|
tmux = entry.get('tmux') or {}
|
|
@@ -206,7 +232,13 @@ def promote_to_registered(workspace_id, entry, registry):
|
|
|
206
232
|
|
|
207
233
|
def close_session(workspace, entry):
|
|
208
234
|
"""Close the exact observed session, never an identically named replacement or a registered lane."""
|
|
209
|
-
|
|
235
|
+
with L.registry_lock():
|
|
236
|
+
return _close_session(workspace, entry)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _close_session(workspace, entry):
|
|
240
|
+
import registrations
|
|
241
|
+
registry = registrations.apply(L.load_registry())
|
|
210
242
|
current = (L.read_json(L.STATE / 'status.json', {}) or {}).get('unregistered', {}).get(workspace, [])
|
|
211
243
|
published = next((x for x in current if x['id'] == entry['id']), None)
|
|
212
244
|
if not published or session_key(published) != session_key(entry) or not entry.get('closable'):
|