@vibelord/lanes 1.0.1 → 1.0.2
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 +651 -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 +80 -13
- package/payload/lanes/docs/DESIGN.md +182 -4
- package/payload/lanes/docs/RUNBOOK.md +429 -39
- package/payload/lanes/docs/SHARING.md +1 -1
- package/payload/lanes/holders/exec.sh +2 -5
- package/payload/lanes/holders.lock +3 -0
- package/payload/lanes/install/host-install.sh +10 -1
- package/payload/lanes/install/services.py +30 -3
- 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 +238 -69
- 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-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 +4 -3
- package/payload/lanes/libexec/legacy_sessions.py +0 -130
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Opt-in push notifications: one ntfy message per lane per transition into needs-you (plan Task 14).
|
|
3
|
+
|
|
4
|
+
The desktop sidebar already raises one OSC 9 per transition while a terminal shows it (spec §4.9); this is the same
|
|
5
|
+
event for the operator who is away from every terminal. `lanes-notify.service` (opt-in — no installer enables it)
|
|
6
|
+
re-reads `STATE/status.json` when its mtime changes (checked every `--interval` seconds, 3 in the unit) and POSTs
|
|
7
|
+
to an ntfy server when a lane's `agent.status` ENTERS `needs_input`, `needs_approval` or `errored` — the sidebar's
|
|
8
|
+
NEEDS_YOU set. The six-word `state` is never read: the agent status is the only input.
|
|
9
|
+
|
|
10
|
+
* A status that stays the same is not news; a change between two needs-you statuses is (input → approval).
|
|
11
|
+
* The first document the service reads only seeds it: lanes that were already waiting when it (re)started are
|
|
12
|
+
not announced, so a restart never sends a burst.
|
|
13
|
+
* Presence suppression: nothing is sent while any device was at a desk within `quiet_when_device_active_s`
|
|
14
|
+
(default 120 s) — measured as the mtime of `STATE/clients/<device>.<client>.pid` (written only by
|
|
15
|
+
`lanes attach`), or of `STATE/clients/<device>.seen.json` (written by a sidebar when the operator opens a
|
|
16
|
+
lane) for a DESK device only: one with a `<device>.*.pid` of any age whose `STATE/devices/<device>.json` is not
|
|
17
|
+
`mobile: true`. Phone presence is decided by the device's mobile layout, not by how it attached: a Safari phone
|
|
18
|
+
has no `.pid`, and an SSH-only phone that ran `lanes attach phone --mobile` is still mobile, so opening a lane
|
|
19
|
+
from the phone's picker never mutes pushes (DEF-1452). A phone's own attach within the window still counts.
|
|
20
|
+
Typing in a lane touches neither file, so this is "attached or opened a lane at a desk recently", not "is
|
|
21
|
+
looking now" (DEF-1451). A suppressed transition is consumed, never sent later.
|
|
22
|
+
* Payload: `<lane name>: <agent task> — <status>` and, on a second line, the client command that opens the lane
|
|
23
|
+
(`lanes-launch lane <id>`). No client registers a URL scheme for it, so the push carries a tappable action only
|
|
24
|
+
when `action_url` (a template over `{lane}`) is configured.
|
|
25
|
+
* A failed send is one journal line (lane id and the failure class — never the topic or the bearer) and is not
|
|
26
|
+
retried; it never stops the service.
|
|
27
|
+
|
|
28
|
+
Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` overrides the path), `key=value` lines, `#` comments,
|
|
29
|
+
re-read when its mtime changes: `url` (ntfy server, http/https, required), `topic` (`[A-Za-z0-9_-]{1,64}`,
|
|
30
|
+
required), `token_file` (a file holding an ntfy access token, optional — sent as a bearer, never logged; an
|
|
31
|
+
unreadable one disables sending rather than sending unauthenticated), `quiet_when_device_active_s` (default 120;
|
|
32
|
+
0 never suppresses), `action_url` (optional template, e.g. `https://lanes.example/?lane={lane}`). Without a valid
|
|
33
|
+
`url` and `topic` the service only tracks transitions and sends nothing.
|
|
34
|
+
|
|
35
|
+
Answering a `PermissionRequest`-held approval FROM the phone needs a request/response channel back into the hook;
|
|
36
|
+
that is a follow-up design, not part of this module.
|
|
37
|
+
"""
|
|
38
|
+
import argparse
|
|
39
|
+
import glob
|
|
40
|
+
import json
|
|
41
|
+
import os
|
|
42
|
+
import re
|
|
43
|
+
import sys
|
|
44
|
+
import time
|
|
45
|
+
import urllib.error
|
|
46
|
+
import urllib.parse
|
|
47
|
+
import urllib.request
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
51
|
+
import lanes_lib as L # noqa: E402
|
|
52
|
+
import tokens # noqa: E402
|
|
53
|
+
|
|
54
|
+
TRIGGERS = ('needs_input', 'needs_approval', 'errored') # the sidebar's NEEDS_YOU (sidebar.ATTENTION[0][3])
|
|
55
|
+
LABELS = tokens.MOBILE_AGENT_LABELS # the phone's words: Needs you / Needs approval / Error
|
|
56
|
+
PRIORITY = {'needs_approval': 4, 'errored': 4, 'needs_input': 3}
|
|
57
|
+
DEFAULT_QUIET = 120
|
|
58
|
+
DEFAULT_CONF = '~/.config/lanes/notify.conf'
|
|
59
|
+
KEYS = ('url', 'topic', 'token_file', 'quiet_when_device_active_s', 'action_url')
|
|
60
|
+
TOPIC = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
|
|
61
|
+
UNSAFE = re.compile(r'[\x00-\x1f\x7f-\x9f]') # C0, DEL, C1: never inside a push line
|
|
62
|
+
TEXT_MAX = 200 # characters of the first payload line
|
|
63
|
+
TIMEOUT = 5
|
|
64
|
+
PRESENCE = '*.pid' # clients/ files only `lanes attach` writes
|
|
65
|
+
SEEN = '.seen.json' # a sidebar's opened-lane record; desk devices only
|
|
66
|
+
# (a .pid of any age, devices/<device>.json not mobile)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _text(value):
|
|
70
|
+
return UNSAFE.sub('', str(value or ''))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_config(path):
|
|
74
|
+
"""notify.conf -> {url, topic, token_file, token, quiet_when_device_active_s, action_url, enabled}. Never raises."""
|
|
75
|
+
raw = {}
|
|
76
|
+
try:
|
|
77
|
+
lines = Path(os.path.expanduser(str(path))).read_text().splitlines()
|
|
78
|
+
except (OSError, UnicodeDecodeError):
|
|
79
|
+
lines = []
|
|
80
|
+
for line in lines:
|
|
81
|
+
line = line.strip()
|
|
82
|
+
if not line or line.startswith('#') or '=' not in line:
|
|
83
|
+
continue
|
|
84
|
+
key, value = (part.strip() for part in line.split('=', 1))
|
|
85
|
+
if key in KEYS:
|
|
86
|
+
raw[key] = value
|
|
87
|
+
try:
|
|
88
|
+
quiet = max(0, int(raw.get('quiet_when_device_active_s', DEFAULT_QUIET)))
|
|
89
|
+
except ValueError:
|
|
90
|
+
quiet = DEFAULT_QUIET
|
|
91
|
+
url = raw.get('url', '').rstrip('/')
|
|
92
|
+
topic = raw.get('topic', '')
|
|
93
|
+
config = {'url': url, 'topic': topic, 'token_file': raw.get('token_file', ''), 'token': None,
|
|
94
|
+
'quiet_when_device_active_s': quiet, 'action_url': raw.get('action_url', '')}
|
|
95
|
+
enabled = url.startswith(('http://', 'https://')) and bool(TOPIC.match(topic))
|
|
96
|
+
if enabled and config['token_file']:
|
|
97
|
+
try:
|
|
98
|
+
config['token'] = Path(os.path.expanduser(config['token_file'])).read_text().strip() or None
|
|
99
|
+
except (OSError, UnicodeDecodeError):
|
|
100
|
+
config['token'] = None
|
|
101
|
+
enabled = config['token'] is not None
|
|
102
|
+
config['enabled'] = enabled
|
|
103
|
+
return config
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def agent_statuses(document):
|
|
107
|
+
"""status.json -> {lane id: agent status} for lanes with an agent{} (the six-word `state` is never read)."""
|
|
108
|
+
lanes = document.get('lanes') if isinstance(document, dict) else None
|
|
109
|
+
out = {}
|
|
110
|
+
for lane_id, entry in (lanes.items() if isinstance(lanes, dict) else ()):
|
|
111
|
+
agent = entry.get('agent') if isinstance(entry, dict) else None
|
|
112
|
+
if isinstance(agent, dict) and isinstance(agent.get('status'), str):
|
|
113
|
+
out[lane_id] = agent['status']
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def lane_names(registry):
|
|
118
|
+
"""registry.json -> {lane id: display name} over every workspace's lanes."""
|
|
119
|
+
out = {}
|
|
120
|
+
workspaces = registry.get('workspaces') if isinstance(registry, dict) else None
|
|
121
|
+
for ws in workspaces if isinstance(workspaces, list) else ():
|
|
122
|
+
if not isinstance(ws, dict):
|
|
123
|
+
continue
|
|
124
|
+
for lane in ws.get('lanes') or ():
|
|
125
|
+
if isinstance(lane, dict) and isinstance(lane.get('id'), str) and lane.get('name'):
|
|
126
|
+
out[lane['id']] = str(lane['name'])
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_mobile(devices_dir, device):
|
|
131
|
+
"""True when `STATE/devices/<device>.json` says `mobile: true` — the phone layout `lanes prepare|attach
|
|
132
|
+
--mobile` created, the same mark the device's sidebar reads. Missing or unreadable meta is not mobile."""
|
|
133
|
+
meta = L.read_json(Path(devices_dir) / f'{device}.json')
|
|
134
|
+
return isinstance(meta, dict) and bool(meta.get('mobile'))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def device_active(clients_dir, window, now, devices_dir=None):
|
|
138
|
+
"""True when a device attached (`<device>.<client>.pid`) or a desk device opened a lane (`<device>.seen.json`)
|
|
139
|
+
within `window` seconds of `now`. `seen.json` counts only for a device that has a `<device>.*.pid` of any age
|
|
140
|
+
(the mark of `lanes attach`) AND whose `devices_dir/<device>.json` is not `mobile: true`. The mobile flag is
|
|
141
|
+
the authoritative phone mark: a Safari phone never runs `lanes attach`, but an SSH-only phone may
|
|
142
|
+
(`lanes attach phone --mobile`, RUNBOOK) and its pid is never removed — so a lane opened from the phone's picker
|
|
143
|
+
never mutes the phone's own pushes, however it attached (DEF-1452). `devices_dir` defaults to the `devices`
|
|
144
|
+
directory beside `clients_dir` (both live in STATE). A missing directory is nobody present; a window of 0 is
|
|
145
|
+
never present."""
|
|
146
|
+
if window <= 0:
|
|
147
|
+
return False
|
|
148
|
+
devices_dir = Path(clients_dir).parent / 'devices' if devices_dir is None else Path(devices_dir)
|
|
149
|
+
|
|
150
|
+
def recent(path):
|
|
151
|
+
try:
|
|
152
|
+
return now - path.stat().st_mtime <= window
|
|
153
|
+
except OSError:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
directory = Path(clients_dir)
|
|
158
|
+
if any(recent(path) for path in directory.glob(PRESENCE)):
|
|
159
|
+
return True
|
|
160
|
+
for path in directory.glob('*' + SEEN):
|
|
161
|
+
device = path.name[:-len(SEEN)]
|
|
162
|
+
if (device and recent(path) and any(directory.glob(glob.escape(device) + '.' + PRESENCE))
|
|
163
|
+
and not is_mobile(devices_dir, device)):
|
|
164
|
+
return True
|
|
165
|
+
except OSError:
|
|
166
|
+
return False
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _note(lane_id, status, agent, names, config):
|
|
171
|
+
label = LABELS.get(status, status)
|
|
172
|
+
name = _text(names.get(lane_id) or lane_id)
|
|
173
|
+
task = _text(agent.get('name'))
|
|
174
|
+
first = f'{name}: {task} — {label}' if task else f'{name} — {label}'
|
|
175
|
+
if len(first) > TEXT_MAX:
|
|
176
|
+
first = first[:TEXT_MAX - 1] + '…'
|
|
177
|
+
note = {'lane': lane_id, 'status': status, 'title': label,
|
|
178
|
+
'message': f'{first}\nlanes-launch lane {lane_id}', 'priority': PRIORITY.get(status, 3)}
|
|
179
|
+
template = config.get('action_url') or ''
|
|
180
|
+
if template:
|
|
181
|
+
url = template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
|
|
182
|
+
note['click'] = url
|
|
183
|
+
note['actions'] = [{'action': 'view', 'label': 'Open lane', 'url': url}]
|
|
184
|
+
return note
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def notify_changes(prev_status, new_status, config, names=None, now=None, clients_dir=None, devices_dir=None):
|
|
188
|
+
"""The notifications for lanes whose agent status ENTERED needs-you between two status.json documents.
|
|
189
|
+
`prev_status` None = the first read: seed only, nothing is news. Empty when a desk device is present."""
|
|
190
|
+
if prev_status is None:
|
|
191
|
+
return []
|
|
192
|
+
before = agent_statuses(prev_status)
|
|
193
|
+
lanes = new_status.get('lanes') if isinstance(new_status, dict) else None
|
|
194
|
+
changed = [(lane_id, status) for lane_id, status in agent_statuses(new_status).items()
|
|
195
|
+
if status in TRIGGERS and before.get(lane_id) != status]
|
|
196
|
+
if not changed:
|
|
197
|
+
return []
|
|
198
|
+
now = time.time() if now is None else now
|
|
199
|
+
window = int(config.get('quiet_when_device_active_s', DEFAULT_QUIET) or 0)
|
|
200
|
+
if device_active(clients_dir if clients_dir is not None else L.STATE / 'clients', window, now, devices_dir):
|
|
201
|
+
return []
|
|
202
|
+
names = names or {}
|
|
203
|
+
return [_note(lane_id, status, lanes[lane_id]['agent'], names, config) for lane_id, status in sorted(changed)]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
207
|
+
def redirect_request(self, *_args, **_kwargs): # a redirect would carry the bearer elsewhere: refuse it
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def default_opener():
|
|
212
|
+
return urllib.request.build_opener(_NoRedirect()).open
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def send(note, config, opener=None):
|
|
216
|
+
"""POST one notification as ntfy JSON to `config['url']`. True when the server accepted it. Any failure is one
|
|
217
|
+
stderr (journal) line naming the lane and the failure class — never the topic or the bearer — and False."""
|
|
218
|
+
body = {'topic': config['topic'], 'title': note['title'], 'message': note['message'],
|
|
219
|
+
'priority': note.get('priority', 3)}
|
|
220
|
+
for key in ('click', 'actions'):
|
|
221
|
+
if key in note:
|
|
222
|
+
body[key] = note[key]
|
|
223
|
+
headers = {'Content-Type': 'application/json'}
|
|
224
|
+
if config.get('token'):
|
|
225
|
+
headers['Authorization'] = f"Bearer {config['token']}"
|
|
226
|
+
request = urllib.request.Request(config['url'], data=json.dumps(body).encode(), headers=headers, method='POST')
|
|
227
|
+
try:
|
|
228
|
+
with (opener or default_opener())(request, timeout=TIMEOUT) as response:
|
|
229
|
+
status = getattr(response, 'status', 200)
|
|
230
|
+
if not 200 <= status < 300:
|
|
231
|
+
raise urllib.error.HTTPError(config['url'], status, 'not accepted', {}, None)
|
|
232
|
+
return True
|
|
233
|
+
except urllib.error.HTTPError as error:
|
|
234
|
+
why = f'HTTP {error.code}'
|
|
235
|
+
except urllib.error.URLError as error:
|
|
236
|
+
why = f'unreachable ({type(error.reason).__name__})' if not isinstance(error.reason, str) else 'unreachable'
|
|
237
|
+
except TimeoutError:
|
|
238
|
+
why = 'timed out'
|
|
239
|
+
except OSError as error:
|
|
240
|
+
why = type(error).__name__
|
|
241
|
+
except Exception as error: # a push must never stop the service
|
|
242
|
+
why = type(error).__name__
|
|
243
|
+
print(f'notify: send failed for {note.get("lane")}: {why}', file=sys.stderr, flush=True)
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class Watcher:
|
|
248
|
+
"""Tails status.json by mtime; sends what notify_changes returns. The config and registry are re-read when
|
|
249
|
+
their mtime changes. The first readable document seeds the previous one."""
|
|
250
|
+
|
|
251
|
+
def __init__(self, state, conf_path, opener=None, clock=time.time):
|
|
252
|
+
self.state = Path(state)
|
|
253
|
+
self.conf_path = Path(os.path.expanduser(str(conf_path)))
|
|
254
|
+
self.opener, self.clock = opener, clock
|
|
255
|
+
self.prev = None
|
|
256
|
+
self.stamps = {}
|
|
257
|
+
self.config = read_config(self.conf_path)
|
|
258
|
+
self.names = {}
|
|
259
|
+
|
|
260
|
+
def _changed(self, key, path):
|
|
261
|
+
try:
|
|
262
|
+
stamp = path.stat().st_mtime_ns
|
|
263
|
+
except OSError:
|
|
264
|
+
stamp = None
|
|
265
|
+
if self.stamps.get(key, 'unset') == stamp:
|
|
266
|
+
return False
|
|
267
|
+
self.stamps[key] = stamp
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
def tick(self):
|
|
271
|
+
if self._changed('conf', self.conf_path):
|
|
272
|
+
self.config = read_config(self.conf_path)
|
|
273
|
+
if self._changed('registry', self.state / 'registry.json'):
|
|
274
|
+
self.names = lane_names(L.read_json(self.state / 'registry.json'))
|
|
275
|
+
if not self._changed('status', self.state / 'status.json'):
|
|
276
|
+
return []
|
|
277
|
+
document = L.read_json(self.state / 'status.json')
|
|
278
|
+
if not isinstance(document, dict):
|
|
279
|
+
return [] # a torn or missing file: keep the previous document
|
|
280
|
+
notes = notify_changes(self.prev, document, self.config, names=self.names, now=self.clock(),
|
|
281
|
+
clients_dir=self.state / 'clients', devices_dir=self.state / 'devices')
|
|
282
|
+
self.prev = document
|
|
283
|
+
if not self.config['enabled']:
|
|
284
|
+
return []
|
|
285
|
+
for note in notes:
|
|
286
|
+
send(note, self.config, self.opener)
|
|
287
|
+
return notes
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def main(argv=None):
|
|
291
|
+
parser = argparse.ArgumentParser(prog='notify', description='push needs-you transitions to an ntfy topic')
|
|
292
|
+
parser.add_argument('--interval', type=float, default=3.0, help='seconds between status.json checks')
|
|
293
|
+
parser.add_argument('--test', action='store_true', help='send one test notification and exit (1 = not sent)')
|
|
294
|
+
args = parser.parse_args(argv)
|
|
295
|
+
conf = os.environ.get('LANES_NOTIFY_CONF') or DEFAULT_CONF
|
|
296
|
+
state = Path(os.environ.get('LANES_STATE') or L.STATE)
|
|
297
|
+
if args.test:
|
|
298
|
+
config = read_config(conf)
|
|
299
|
+
if not config['enabled']:
|
|
300
|
+
print(f'notify: {conf} has no valid url and topic (or its token_file is unreadable)', file=sys.stderr)
|
|
301
|
+
return 1
|
|
302
|
+
note = {'lane': 'test', 'status': 'test', 'title': 'lanes', 'message': 'lanes notify: test notification',
|
|
303
|
+
'priority': 3}
|
|
304
|
+
return 0 if send(note, config) else 1
|
|
305
|
+
watcher = Watcher(state, conf)
|
|
306
|
+
if not watcher.config['enabled']:
|
|
307
|
+
print(f'notify: {conf} has no valid url and topic; tracking only until it does', file=sys.stderr, flush=True)
|
|
308
|
+
while True:
|
|
309
|
+
try:
|
|
310
|
+
watcher.tick()
|
|
311
|
+
except Exception as error: # one bad tick is a journal line; the next tick tries again
|
|
312
|
+
print(f'notify: {type(error).__name__}: {error}', file=sys.stderr, flush=True)
|
|
313
|
+
time.sleep(max(1.0, args.interval))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
if __name__ == '__main__':
|
|
317
|
+
sys.exit(main())
|
|
@@ -81,7 +81,7 @@ def rename(lane_id, new_name):
|
|
|
81
81
|
"""Display name of one registered session — this file is its only name authority (`apply` takes the lane's
|
|
82
82
|
name from the record). Only `name` changes: the id, binding, workspace and folder stay, so view targets,
|
|
83
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-
|
|
84
|
+
UNREGISTERED session is renamed through are never read for a registered one (DEF-1475)."""
|
|
85
85
|
name = L._title(new_name)
|
|
86
86
|
with L.registry_lock():
|
|
87
87
|
data = load()
|
|
@@ -72,8 +72,9 @@ def make_entry(workspace, container, socket, name, pane, previous=None):
|
|
|
72
72
|
meta = L.read_json(L.STATE / 'user-sessions' / (entry['id'] + '.json'), {}) or {}
|
|
73
73
|
if session_key(meta) == session_key(entry):
|
|
74
74
|
entry['name'] = meta.get('name') or entry['name']
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
# statusd calls this for every unregistered session on every cycle: rewrite only a changed target or name.
|
|
76
|
+
L.atomic_write_if_changed(L.STATE / 'resolve.dynamic' / entry['id'],
|
|
77
|
+
'\n'.join(['tmux', container, socket, name, entry['name']]) + '\n')
|
|
77
78
|
return entry
|
|
78
79
|
|
|
79
80
|
|
|
@@ -178,7 +179,7 @@ def promote_to_registered(workspace_id, entry, registry):
|
|
|
178
179
|
raise L.LanesError('Only tmux sessions can be promoted (zmx and exec holders are not supported)')
|
|
179
180
|
# A binding any board already holds — a repository declaration or an adopted registered session — is
|
|
180
181
|
# 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-
|
|
182
|
+
# re-keying an adopted lane's id and orphaning its view targets (DEF-1474). Nothing is written.
|
|
182
183
|
if entry.get('registered_session') or session_key(entry) in {
|
|
183
184
|
session_key(lane) for w in registry.get('workspaces', []) for lane in w.get('lanes', [])
|
|
184
185
|
if lane.get('holder') == 'tmux'}:
|
|
@@ -244,11 +245,9 @@ def _close_session(workspace, entry):
|
|
|
244
245
|
if not published or session_key(published) != session_key(entry) or not entry.get('closable'):
|
|
245
246
|
raise L.LanesError('Session changed; reload before closing')
|
|
246
247
|
if entry.get('legacy'):
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
(L.STATE / 'resolve.dynamic' / entry['id']).unlink(missing_ok=True)
|
|
251
|
-
return
|
|
248
|
+
# Plan Task 19 retired the pre-persistence Docker-shell tracker (legacy_sessions.py): only a status.json an
|
|
249
|
+
# older statusd wrote can still carry such a row. Refused before any process, tmux or file is touched.
|
|
250
|
+
raise L.LanesError('Legacy shells are retired; nothing was closed')
|
|
252
251
|
registered = {session_key(lane) for w in registry['workspaces'] for lane in w.get('lanes', [])
|
|
253
252
|
if lane.get('holder') == 'tmux'}
|
|
254
253
|
if session_key(entry) in registered:
|