@vibelord/lanes 1.1.1 → 1.3.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 +47 -23
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2753 -202
- 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 +147 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1150 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1318 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +793 -16
- package/payload/lanes/docs/DESIGN.md +127 -46
- package/payload/lanes/docs/INSTALL.md +175 -18
- package/payload/lanes/docs/RUNBOOK.md +968 -76
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +967 -150
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +197 -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/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- 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/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +21 -2
- package/payload/lanes/libexec/notify.py +169 -29
- 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 +2232 -365
- package/payload/lanes/libexec/statusd.py +231 -12
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +148 -35
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +1044 -0
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- 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/Caddyfile +24 -1
- package/payload/lanes/web/README.md +165 -28
- package/payload/lanes/web/browser.js +4 -8
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +45 -32
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +6 -3
|
@@ -19,12 +19,29 @@ NEEDS_YOU set. The six-word `state` is never read: the agent status is the only
|
|
|
19
19
|
from the phone's picker never mutes pushes (DEF-1533). A phone's own attach within the window still counts.
|
|
20
20
|
Typing in a lane touches neither file, so this is "attached or opened a lane at a desk recently", not "is
|
|
21
21
|
looking now" (DEF-1532). A suppressed transition is consumed, never sent later.
|
|
22
|
+
* Since cockpit v3 R7 (LNS-80) a person's profile `STATE/users/<login>.json` counts as well when its
|
|
23
|
+
`updated_by_device` is a desk device (same rule) and its mtime is within the window: the sidebar writes the mark
|
|
24
|
+
of an opened lane there too, so presence never hangs on `clients/<device>.seen.json` staying the place it is
|
|
25
|
+
kept; a phone's write, and a change no device made, never count: the CLI's writes are stamped `cli:<command>`
|
|
26
|
+
(`lanes workspaces|lane …`) or `cli:migration` (`lanes device claim`, `lanes keys adopt --login`, `lanes keys
|
|
27
|
+
owner`), so an admin over SSH naming a desk device mutes nobody (W9 review F3). Each mark belongs to a person (the
|
|
28
|
+
device's owner, users.login_for over STATE/members; the profile's login) and is recent within that person's own
|
|
29
|
+
window: their profile's `notify.quiet_s` when set, else the host's `quiet_when_device_active_s`. Recent marks of
|
|
30
|
+
one person alone mute; recent marks of several people fall back to the host window for all (presence_marks,
|
|
31
|
+
device_active).
|
|
22
32
|
* Payload: `<lane name>: <agent task> — <status>` and, on a second line, the client command that opens the lane
|
|
23
33
|
(`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.
|
|
34
|
+
when `action_url` (a template over `{lane}`, and `{doc}` for a `published` event — `action_url()`) is configured.
|
|
25
35
|
* A failed send is one journal line (lane id and the failure class — never the topic or the bearer) and is not
|
|
26
36
|
retried; it never stops the service.
|
|
27
37
|
|
|
38
|
+
Published documents (plan W10 W4): the docs service's publish pick-up (`lanes-docsd`, docsd.Pickup), not this
|
|
39
|
+
service, calls `notify_published` once a document an agent published from a lane is stored — the same config, the
|
|
40
|
+
same desk-presence rule (device_active) and the same `send`, at most one push per lane per minute (docsd's
|
|
41
|
+
PUBLISHED_PUSH_S), best effort. Its payload is `{title: 'Published', message: '<lane name> published a document (<kind>,
|
|
42
|
+
<size>)\n/lanes-docs/d/<id>', priority: 2}` and, when `action_url` is set, the tap target with `{doc}` resolved —
|
|
43
|
+
never the document's title, name or content: the topic is the host's, and the document may be for one login only.
|
|
44
|
+
|
|
28
45
|
Enrolment requests (W8 A5, LNS-44): the same service also watches `STATE/enrol-requests/` (lanes-enrold's store;
|
|
29
46
|
read through `enrold.load_requests`, so a request announced is one `lanes enrol list` shows). A request that is
|
|
30
47
|
pending — not approved, denied or past `expires_at` — and has not been announced yet yields ONE push:
|
|
@@ -45,7 +62,9 @@ Config `~/.config/lanes/notify.conf` (`LANES_NOTIFY_CONF` overrides the path), `
|
|
|
45
62
|
`url` (ntfy server, http/https, required), `topic` (`[A-Za-z0-9_-]{1,64}`, required), `token_file` (a file holding
|
|
46
63
|
an ntfy access token, optional — sent as a bearer, never logged; an unreadable one disables sending rather than
|
|
47
64
|
sending unauthenticated), `quiet_when_device_active_s` (default 120; 0 never suppresses), `action_url` (optional
|
|
48
|
-
template, e.g. `https://lanes.example/?lane={lane}
|
|
65
|
+
template, e.g. `https://lanes.example/?lane={lane}`; plan W10 W7 adds `{doc}`, the page `/lanes-docs/d/<id>` of the
|
|
66
|
+
document a `published` push names — empty for a needs-you push; the docs service sends those, W4). Without a valid
|
|
67
|
+
`url` and `topic` the service only tracks
|
|
49
68
|
transitions and sends nothing. The config is re-read when notify.conf OR its `token_file` changes (inode, size,
|
|
50
69
|
mtime or ctime, so a token written, rotated, removed or chmod-ed after notify.conf counts), and on every tick while
|
|
51
70
|
sending is disabled, so a token that becomes readable switches the pushes on within one interval.
|
|
@@ -66,9 +85,11 @@ import urllib.request
|
|
|
66
85
|
from pathlib import Path
|
|
67
86
|
|
|
68
87
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
88
|
+
import docs_store # noqa: E402
|
|
69
89
|
import enrold as E # noqa: E402
|
|
70
90
|
import lanes_lib as L # noqa: E402
|
|
71
91
|
import tokens # noqa: E402
|
|
92
|
+
import users as U # noqa: E402
|
|
72
93
|
|
|
73
94
|
TRIGGERS = ('needs_input', 'needs_approval', 'errored') # the sidebar's NEEDS_YOU (sidebar.ATTENTION[0][3])
|
|
74
95
|
LABELS = tokens.MOBILE_AGENT_LABELS # the phone's words: Needs you / Needs approval / Error
|
|
@@ -83,6 +104,9 @@ TIMEOUT = 5
|
|
|
83
104
|
PRESENCE = '*.pid' # clients/ files only `lanes attach` writes
|
|
84
105
|
SEEN = '.seen.json' # a sidebar's opened-lane record; desk devices only
|
|
85
106
|
# (a .pid of any age, devices/<device>.json not mobile)
|
|
107
|
+
# users/<login>.json written by a desk device counts too
|
|
108
|
+
PUBLISHED_TITLE = 'Published' # a document published from a lane (notify_published)
|
|
109
|
+
PUBLISHED_PRIORITY = 2
|
|
86
110
|
ENROL_DIR = 'enrol-requests' # STATE/<this>/<id>.json: lanes-enrold's requests
|
|
87
111
|
ENROL_SEEN = 'notify.enrol-seen.json' # STATE/<this>: the request ids already announced
|
|
88
112
|
ENROL_TITLE = 'Enrol request'
|
|
@@ -163,37 +187,116 @@ def is_mobile(devices_dir, device):
|
|
|
163
187
|
return isinstance(meta, dict) and bool(meta.get('mobile'))
|
|
164
188
|
|
|
165
189
|
|
|
166
|
-
def
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
never mutes the phone's own pushes, however it attached (DEF-1533). `devices_dir` defaults to the `devices`
|
|
173
|
-
directory beside `clients_dir` (both live in STATE). A missing directory is nobody present; a window of 0 is
|
|
174
|
-
never present."""
|
|
175
|
-
if window <= 0:
|
|
176
|
-
return False
|
|
177
|
-
devices_dir = Path(clients_dir).parent / 'devices' if devices_dir is None else Path(devices_dir)
|
|
190
|
+
def _names(directory, suffix):
|
|
191
|
+
try:
|
|
192
|
+
return {name[:-len(suffix)] for name in os.listdir(directory) if name.endswith(suffix)}
|
|
193
|
+
except OSError:
|
|
194
|
+
return set()
|
|
195
|
+
|
|
178
196
|
|
|
179
|
-
|
|
197
|
+
def presence_marks(clients_dir, devices_dir=None, users_dir=None, members_dir=None, profiles=None):
|
|
198
|
+
"""[(login or None, mtime)]: every mark of someone at a device, whoever's window decides about it.
|
|
199
|
+
* `clients/<device>.<client>.pid` (`lanes attach`, any device, a phone included);
|
|
200
|
+
* `clients/<device>.seen.json` of a DESK device (a lane opened from its sidebar);
|
|
201
|
+
* `users/<login>.json` whose `updated_by_device` is a DESK device (cockpit v3 R7, LNS-80): the sidebar's mark
|
|
202
|
+
of a lane opened there and every other profile write made at that device (`lanes attach`, `lanes next|prev`,
|
|
203
|
+
`lanes here`) — so presence does not hang on the seen file staying in clients/. A change no device made is
|
|
204
|
+
nobody's: a CLI writer (`cli:<command>`, `cli:migration`: users.writer_device is None) or null (W9 review F3).
|
|
205
|
+
A desk device has a `<device>.*.pid` of any age and a `devices/<device>.json` that is not `mobile: true`
|
|
206
|
+
(DEF-1533: a phone's picker never counts, however the phone attached). The login of a device mark is its owner
|
|
207
|
+
(users.login_for over `members/` and `devices/<device>.json`; a pid file belongs to the longest known device name
|
|
208
|
+
it starts with, None when none or nobody owns it); a profile mark's is the profile's. The directories default to
|
|
209
|
+
the siblings of `clients_dir` (all four live in STATE); `profiles` is users.load_all(users_dir)'s list when the
|
|
210
|
+
caller has read it. A directory that cannot be read gives no marks."""
|
|
211
|
+
directory = Path(clients_dir)
|
|
212
|
+
state = directory.parent
|
|
213
|
+
devices_dir = state / 'devices' if devices_dir is None else Path(devices_dir)
|
|
214
|
+
users_dir = state / 'users' if users_dir is None else Path(users_dir)
|
|
215
|
+
members_dir = state / 'members' if members_dir is None else Path(members_dir)
|
|
216
|
+
owners, desks = {}, {}
|
|
217
|
+
|
|
218
|
+
def owner(device):
|
|
219
|
+
if device is None:
|
|
220
|
+
return None
|
|
221
|
+
if device not in owners:
|
|
222
|
+
meta = L.read_json(devices_dir / f'{device}.json')
|
|
223
|
+
owners[device] = U.login_for(device, meta if isinstance(meta, dict) else {}, members_dir)
|
|
224
|
+
return owners[device]
|
|
225
|
+
|
|
226
|
+
def desk(device):
|
|
227
|
+
if device not in desks:
|
|
228
|
+
desks[device] = (bool(device) and any(directory.glob(glob.escape(device) + '.' + PRESENCE))
|
|
229
|
+
and not is_mobile(devices_dir, device))
|
|
230
|
+
return desks[device]
|
|
231
|
+
|
|
232
|
+
def mtime(path):
|
|
180
233
|
try:
|
|
181
|
-
return
|
|
234
|
+
return path.stat().st_mtime
|
|
182
235
|
except OSError:
|
|
183
|
-
return
|
|
236
|
+
return None
|
|
184
237
|
|
|
238
|
+
marks = []
|
|
239
|
+
known = {name for name in _names(devices_dir, '.json') | _names(members_dir, '.json') if L.IDENT.match(name)}
|
|
185
240
|
try:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
241
|
+
for path in directory.glob(PRESENCE):
|
|
242
|
+
at = mtime(path)
|
|
243
|
+
device = max((name for name in known if path.name.startswith(name + '.')
|
|
244
|
+
and len(path.name) > len(name) + len('..pid')), key=len, default=None)
|
|
245
|
+
if at is not None:
|
|
246
|
+
marks.append((owner(device), at))
|
|
189
247
|
for path in directory.glob('*' + SEEN):
|
|
190
|
-
device = path.name[:-len(SEEN)]
|
|
191
|
-
if
|
|
192
|
-
|
|
193
|
-
return True
|
|
248
|
+
device, at = path.name[:-len(SEEN)], mtime(path)
|
|
249
|
+
if at is not None and desk(device):
|
|
250
|
+
marks.append((owner(device), at))
|
|
194
251
|
except OSError:
|
|
195
|
-
|
|
196
|
-
|
|
252
|
+
pass
|
|
253
|
+
for profile in U.load_all(users_dir)[0] if profiles is None else profiles:
|
|
254
|
+
device = U.writer_device(profile['updated_by_device'])
|
|
255
|
+
if device and desk(device):
|
|
256
|
+
at = mtime(U.path(profile['login'], users_dir))
|
|
257
|
+
if at is not None:
|
|
258
|
+
marks.append((profile['login'], at))
|
|
259
|
+
return marks
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def quiet_windows(profiles):
|
|
263
|
+
"""{login: notify.quiet_s} of the profiles that set their own quiet window (DM-LANES-USER-PROFILE `notify`)."""
|
|
264
|
+
return {profile['login']: profile['notify']['quiet_s'] for profile in profiles if 'quiet_s' in profile['notify']}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def device_active(clients_dir, window, now, devices_dir=None, users_dir=None, members_dir=None):
|
|
268
|
+
"""True when someone was at a device recently enough to mute a push (presence_marks). `window` is the host's
|
|
269
|
+
`quiet_when_device_active_s`. Each mark is recent within its person's own window — their profile's
|
|
270
|
+
`notify.quiet_s` when set, else `window`; a device nobody owns always `window`. When the recent marks all
|
|
271
|
+
belong to one person (or all to devices nobody owns) that is presence; when they belong to several, the host
|
|
272
|
+
`window` decides for all of them (any mark within it). A window of 0 makes nothing recent: a person's
|
|
273
|
+
`quiet_s` 0 never mutes their own pushes, a host `window` of 0 never mutes anyone's but a person present alone
|
|
274
|
+
who set a `quiet_s` of their own. A missing directory is nobody present. Cockpit v3 R7 (LNS-80)."""
|
|
275
|
+
state = Path(clients_dir).parent
|
|
276
|
+
users_dir = state / 'users' if users_dir is None else Path(users_dir)
|
|
277
|
+
|
|
278
|
+
def within(at, span):
|
|
279
|
+
return span > 0 and now - at <= span
|
|
280
|
+
|
|
281
|
+
profiles = U.load_all(users_dir)[0]
|
|
282
|
+
marks = presence_marks(clients_dir, devices_dir, users_dir, members_dir, profiles)
|
|
283
|
+
quiet = quiet_windows(profiles)
|
|
284
|
+
recent = {login for login, at in marks if within(at, quiet.get(login, window) if login else window)}
|
|
285
|
+
if len(recent) <= 1:
|
|
286
|
+
return bool(recent)
|
|
287
|
+
return any(within(at, window) for _login, at in marks)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def action_url(template, lane_id, doc_id=None):
|
|
291
|
+
"""notify.conf's `action_url` template made a push's tap target -> the URL, or '' without a template. `{lane}` is
|
|
292
|
+
the lane id; `{doc}` (plan W10 W7) the page of the document a `published` event is about, `/lanes-docs/d/<id>`
|
|
293
|
+
(docs_store.URL; a docs_store id, else nothing), and nothing for an event without one (a needs-you push) — so
|
|
294
|
+
`https://<host>:8443{doc}` opens the document. Both are percent-quoted."""
|
|
295
|
+
if not template:
|
|
296
|
+
return ''
|
|
297
|
+
doc = docs_store.URL.format(doc_id) if isinstance(doc_id, str) and docs_store.ID_RE.match(doc_id) else ''
|
|
298
|
+
return (template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
|
|
299
|
+
.replace('{doc}', urllib.parse.quote(doc, safe='/')))
|
|
197
300
|
|
|
198
301
|
|
|
199
302
|
def _note(lane_id, status, agent, names, config):
|
|
@@ -203,14 +306,50 @@ def _note(lane_id, status, agent, names, config):
|
|
|
203
306
|
first = _line(f'{name}: {task} — {label}' if task else f'{name} — {label}')
|
|
204
307
|
note = {'lane': lane_id, 'status': status, 'title': label,
|
|
205
308
|
'message': f'{first}\nlanes-launch lane {lane_id}', 'priority': PRIORITY.get(status, 3)}
|
|
206
|
-
|
|
207
|
-
if
|
|
208
|
-
url = template.replace('{lane}', urllib.parse.quote(lane_id, safe=''))
|
|
309
|
+
url = action_url(config.get('action_url') or '', lane_id)
|
|
310
|
+
if url:
|
|
209
311
|
note['click'] = url
|
|
210
312
|
note['actions'] = [{'action': 'view', 'label': 'Open lane', 'url': url}]
|
|
211
313
|
return note
|
|
212
314
|
|
|
213
315
|
|
|
316
|
+
def _size(size):
|
|
317
|
+
if size < 1024:
|
|
318
|
+
return f'{size} B'
|
|
319
|
+
return f'{size / 1024:.1f} KiB' if size < 1024 * 1024 else f'{size / (1024 * 1024):.1f} MiB'
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def published_note(lane_id, meta, names, config):
|
|
323
|
+
"""The push for a document stored from lane `lane_id` (`meta` its docs_store meta): the lane's name, the kind and
|
|
324
|
+
the size, and the document's page (module docstring) — never its title, name or content."""
|
|
325
|
+
name = _text(names.get(lane_id) or lane_id)
|
|
326
|
+
first = _line(f"{name} published a document ({_text(meta.get('kind'))}, {_size(int(meta.get('bytes') or 0))})")
|
|
327
|
+
note = {'lane': lane_id, 'status': 'published', 'doc': meta['id'], 'title': PUBLISHED_TITLE,
|
|
328
|
+
'message': f"{first}\n{docs_store.URL.format(meta['id'])}", 'priority': PUBLISHED_PRIORITY}
|
|
329
|
+
url = action_url(config.get('action_url') or '', lane_id, meta['id'])
|
|
330
|
+
if url:
|
|
331
|
+
note['click'] = url
|
|
332
|
+
note['actions'] = [{'action': 'view', 'label': 'Open document', 'url': url}]
|
|
333
|
+
return note
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def notify_published(lane_id, meta, names=None, conf_path=None, state=None, now=None, opener=None):
|
|
337
|
+
"""Send the `published` push for a stored document (the docs service's pick-up calls it, W4) -> the note sent, or
|
|
338
|
+
None: sending disabled (no valid notify.conf at `conf_path`, else $LANES_NOTIFY_CONF, else the default), somebody
|
|
339
|
+
at a desk (device_active over `state`, default L.STATE), or the send failed (one journal line). Never raises for a
|
|
340
|
+
failed send; the caller rate-limits."""
|
|
341
|
+
config = read_config(conf_path or os.environ.get('LANES_NOTIFY_CONF') or DEFAULT_CONF)
|
|
342
|
+
if not config['enabled']:
|
|
343
|
+
return None
|
|
344
|
+
state = Path(state) if state is not None else L.STATE
|
|
345
|
+
now = time.time() if now is None else now
|
|
346
|
+
window = int(config.get('quiet_when_device_active_s', DEFAULT_QUIET) or 0)
|
|
347
|
+
if device_active(state / 'clients', window, now, state / 'devices'):
|
|
348
|
+
return None
|
|
349
|
+
note = published_note(lane_id, meta, names or {}, config)
|
|
350
|
+
return note if send(note, config, opener) else None
|
|
351
|
+
|
|
352
|
+
|
|
214
353
|
def notify_changes(prev_status, new_status, config, names=None, now=None, clients_dir=None, devices_dir=None):
|
|
215
354
|
"""The notifications for lanes whose agent status ENTERED needs-you between two status.json documents.
|
|
216
355
|
`prev_status` None = the first read: seed only, nothing is news. Empty when a desk device is present."""
|
|
@@ -427,6 +566,7 @@ def main(argv=None):
|
|
|
427
566
|
note = {'lane': 'test', 'status': 'test', 'title': 'lanes', 'message': 'lanes notify: test notification',
|
|
428
567
|
'priority': 3}
|
|
429
568
|
return 0 if send(note, config) else 1
|
|
569
|
+
L.record_tree('notify', state) # W9 U0: the unit's tree; `--test` (a one-shot) never records
|
|
430
570
|
watcher = Watcher(state, conf)
|
|
431
571
|
if not watcher.config['enabled']:
|
|
432
572
|
print(f'notify: {conf} has no valid url and topic; tracking only until it does', file=sys.stderr, flush=True)
|
|
@@ -3,9 +3,25 @@ import json
|
|
|
3
3
|
import time
|
|
4
4
|
|
|
5
5
|
import lanes_lib as L
|
|
6
|
+
import session_actions as A
|
|
6
7
|
|
|
7
8
|
FILENAME = 'registered-sessions.json'
|
|
8
9
|
VERSION = 1
|
|
10
|
+
# What tmux names a session by — the identity statusd publishes on an unregistered row and `register` checks.
|
|
11
|
+
IDENTITY_FORMAT = '#{session_id}\t#{session_created}'
|
|
12
|
+
# tmux saying in words that no session has the name (`_answer`): a target that may not fail ("can't find session"), or
|
|
13
|
+
# a targeted verb on a server holding no session at all ("no current target", `L.tmux_probe`'s DEF-967 note).
|
|
14
|
+
ABSENT_WORDS = ("can't find session", 'no current target')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Unpublished(L.LanesError):
|
|
18
|
+
"""The file was written; handing the session back or publishing the registry then failed (`lanes sync` publishes).
|
|
19
|
+
`record` is what the call returns when nothing fails — for `unregister`, the record with its `identity`, so the
|
|
20
|
+
undo still stands although the call raised (fix cycle 1 of S7, DEF-1826)."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message, record):
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.record = record
|
|
9
25
|
|
|
10
26
|
|
|
11
27
|
def key(entry):
|
|
@@ -13,6 +29,50 @@ def key(entry):
|
|
|
13
29
|
return entry.get('container'), binding.get('socket'), binding.get('session')
|
|
14
30
|
|
|
15
31
|
|
|
32
|
+
def _identity_query(session):
|
|
33
|
+
return ['display-message', '-p', '-t', '=' + session + ':', IDENTITY_FORMAT]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ready(identity):
|
|
37
|
+
"""`register`'s identity gate: a tmux session id `$<n>` and a creation time in digits -> (sid, created)."""
|
|
38
|
+
identity = identity if isinstance(identity, dict) else {}
|
|
39
|
+
sid, created = identity.get('session_id', ''), identity.get('created', '')
|
|
40
|
+
if not isinstance(sid, str) or not isinstance(created, str) or not sid.startswith('$') or not sid[1:].isdigit() or not created.isdigit():
|
|
41
|
+
raise L.LanesError('Session identity is not ready; wait for the next status update')
|
|
42
|
+
return sid, created
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _probe(container, socket, session):
|
|
46
|
+
"""tmux's answer for one binding's identity: (outcome, text) of `L.tmux_probe` (never starts a server)."""
|
|
47
|
+
info = (L.read_json(L.STATE / 'containers.json', {}) or {}).get(container) or {}
|
|
48
|
+
return L.tmux_probe(info, socket, _identity_query(session), 5)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _confirm(container, socket, session, identity):
|
|
52
|
+
"""`register`'s live check: tmux must name this binding's session with exactly this identity, now."""
|
|
53
|
+
sid, created = _ready(identity)
|
|
54
|
+
outcome, observed = _probe(container, socket, session)
|
|
55
|
+
if outcome != 'ok' or observed.strip() != sid + '\t' + created:
|
|
56
|
+
raise L.LanesError('Session changed or could not be checked; nothing was registered')
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _answer(outcome, text):
|
|
60
|
+
"""What tmux's answer to `_identity_query` says about the binding -> ('live', {session_id, created}),
|
|
61
|
+
('absent', None) or ('unknown', None). Absent: no server (`noserver`), or tmux 3.4's answer for a name no session
|
|
62
|
+
has — exit 0 with every field empty, because display-message's target may fail (MEASURED 2026-10-07, tmux 3.4,
|
|
63
|
+
with other sessions on the server and with none) — or `ABSENT_WORDS`. Unknown: a timeout or any other error (they
|
|
64
|
+
say NOTHING about the server, `L.tmux_probe`), or an answer that is no identity."""
|
|
65
|
+
if outcome == 'noserver' or (outcome == 'ok' and not text.strip()):
|
|
66
|
+
return 'absent', None
|
|
67
|
+
if outcome == 'ok':
|
|
68
|
+
parts = text.strip().split('\t')
|
|
69
|
+
if len(parts) == 2 and parts[0][:1] == '$' and parts[0][1:].isdigit() and parts[1].isdigit():
|
|
70
|
+
return 'live', {'session_id': parts[0], 'created': parts[1]}
|
|
71
|
+
elif outcome == 'error' and any(words in text.lower() for words in ABSENT_WORDS):
|
|
72
|
+
return 'absent', None
|
|
73
|
+
return 'unknown', None
|
|
74
|
+
|
|
75
|
+
|
|
16
76
|
def eligible(entry):
|
|
17
77
|
return entry.get('holder') == 'tmux' and bool(entry.get('tmux')) and not entry.get('legacy')
|
|
18
78
|
|
|
@@ -77,6 +137,17 @@ def apply(registry, data=None):
|
|
|
77
137
|
return registry
|
|
78
138
|
|
|
79
139
|
|
|
140
|
+
def _publish(registry, what):
|
|
141
|
+
"""`publish_registry` for an action a person just took -> the containers whose own `~/.lz/registry.json` it
|
|
142
|
+
refused (DEF-1796), which the caller raises as `L.ProjectionRefused` once it holds its result. A publish that
|
|
143
|
+
raises (registry.json itself not written) is the same `<what> saved; run lanes sync …` refusal, without a result:
|
|
144
|
+
the change is saved but nothing shows it yet."""
|
|
145
|
+
try:
|
|
146
|
+
return L.publish_registry(registry) or []
|
|
147
|
+
except OSError as error:
|
|
148
|
+
raise L.LanesError(f'{what} saved; run lanes sync to finish publishing: {error}') from error
|
|
149
|
+
|
|
150
|
+
|
|
80
151
|
def rename(lane_id, new_name):
|
|
81
152
|
"""Display name of one registered session — this file is its only name authority (`apply` takes the lane's
|
|
82
153
|
name from the record). Only `name` changes: the id, binding, workspace and folder stay, so view targets,
|
|
@@ -92,12 +163,12 @@ def rename(lane_id, new_name):
|
|
|
92
163
|
registry = apply(L.load_registry(), data)
|
|
93
164
|
registry['generated_at'] = time.time()
|
|
94
165
|
L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
|
95
|
-
|
|
96
|
-
|
|
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),
|
|
166
|
+
refused = _publish(registry, 'Rename')
|
|
167
|
+
lane = next((lane for w in registry['workspaces'] for lane in w['lanes'] if lane['id'] == lane_id),
|
|
100
168
|
dict(record))
|
|
169
|
+
if refused:
|
|
170
|
+
raise L.ProjectionRefused('Rename', refused, lane)
|
|
171
|
+
return lane
|
|
101
172
|
|
|
102
173
|
|
|
103
174
|
def register(workspace, entry, destination=None):
|
|
@@ -123,26 +194,153 @@ def register(workspace, entry, destination=None):
|
|
|
123
194
|
L._require(L.IDENT, container, 'container')
|
|
124
195
|
L._require(L.IDENT, socket, 'tmux socket')
|
|
125
196
|
L._require(L.TMUX_SESSION, name, 'tmux session')
|
|
126
|
-
|
|
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')
|
|
197
|
+
_confirm(container, socket, name, actual.get('identity') or {})
|
|
135
198
|
record = {'id': actual['id'], 'workspace': destination, 'name': L._title(actual['name']),
|
|
136
199
|
'container': container, 'tmux': dict(actual['tmux']),
|
|
137
200
|
'folder': L._require(L.SAFE_PATH, board.get('cwd') or L.CONTAINER_HOME, 'working directory')}
|
|
138
201
|
if any(item['id'] == record['id'] or key(item) == key(record) for item in data['sessions']):
|
|
139
202
|
raise L.LanesError(f'Registration already saved; check {FILENAME} and run lanes sync')
|
|
140
203
|
data['sessions'].append(record)
|
|
141
|
-
|
|
204
|
+
return _save(registry, data, board, record['id'])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _save(registry, data, board, lane_id):
|
|
208
|
+
"""Write the file, then publish the registry it gives -> the lane as `board` lists it. Holding registry_lock().
|
|
209
|
+
A publication that fails raises `Unpublished` carrying that lane: the record is saved, `lanes sync` publishes it.
|
|
210
|
+
A publication that a container refused its own `~/.lz/registry.json` (DEF-1796, `publish_registry`'s answer)
|
|
211
|
+
raises `L.ProjectionRefused` carrying that lane too: saved and published everywhere else, and said at once."""
|
|
212
|
+
registry = apply(registry, data)
|
|
213
|
+
registry['generated_at'] = time.time()
|
|
214
|
+
L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
|
215
|
+
lane = next(lane for lane in board['lanes'] if lane['id'] == lane_id)
|
|
216
|
+
try:
|
|
217
|
+
refused = L.publish_registry(registry) or []
|
|
218
|
+
except OSError as error:
|
|
219
|
+
raise Unpublished('Registration saved; run lanes sync to finish publishing: ' + str(error), lane) from error
|
|
220
|
+
if refused:
|
|
221
|
+
raise L.ProjectionRefused('Registration', refused, lane)
|
|
222
|
+
return lane
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def ghost(record):
|
|
226
|
+
"""The row statusd lists for this session once it is unregistered again — `session_actions.make_entry`'s shape:
|
|
227
|
+
the record's id and board (statusd's first listing of it takes both from `handback/`, whatever board its directory
|
|
228
|
+
names now — `session_actions.handed_back`), its name (handed back through `user-sessions/`) and the identity
|
|
229
|
+
`unregister` observed. The sidebar shows it under Unregistered at once, so the cursor that was on the lane stays
|
|
230
|
+
on it. None when `unregister` observed no live session (statusd will list nothing). Pure: no file is read or
|
|
231
|
+
written."""
|
|
232
|
+
identity = record.get('identity')
|
|
233
|
+
if not identity:
|
|
234
|
+
return None
|
|
235
|
+
return {'id': record['id'], 'name': record['name'], 'holder': 'tmux', 'container': record['container'],
|
|
236
|
+
'workspace': record['workspace'], 'tmux': dict(record['tmux']), 'color': None, 'icon': '👻',
|
|
237
|
+
'closable': True, 'identity': dict(identity)}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _listed(data, lane_id):
|
|
241
|
+
"""Whether `apply` lists this record as a lane. It does not when the record's workspace left the catalog, uses
|
|
242
|
+
another container, or a repository declares the binding: such a record is in the file and nowhere else."""
|
|
243
|
+
try:
|
|
244
|
+
registry = apply(L.load_registry(), data)
|
|
245
|
+
except L.LanesError:
|
|
246
|
+
return False
|
|
247
|
+
return any(lane['id'] == lane_id and lane.get('registered_session')
|
|
248
|
+
for board in registry['workspaces'] for lane in board['lanes'])
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def unregister(lane_id):
|
|
252
|
+
"""Take one added session out of registered-sessions.json — sidebar `u`, `lanes unregister <id>` (cockpit v3 S7,
|
|
253
|
+
LNS-65) — and publish the registry without it. Nothing is started, stopped, renamed or reattached: a session that
|
|
254
|
+
runs goes on as an UNREGISTERED one, under the same id, the same name and the same views. Declared lanes are not
|
|
255
|
+
here (their file is a repository's terminals.json: `session_actions.unregister_declared`).
|
|
256
|
+
|
|
257
|
+
tmux is asked first which session holds the binding (`_answer`). A live one is handed back
|
|
258
|
+
(`session_actions.hand_back`: its name, its board and id for statusd, its views — all before `publish_registry`
|
|
259
|
+
removes `resolve/<id>`, so a view that re-attaches in between still resolves). No session there (ended, container
|
|
260
|
+
down): nothing is handed back, statusd will list nothing. No usable answer (a timeout, an error that is not tmux
|
|
261
|
+
saying the session is absent): refused, nothing written — a session that runs would otherwise go back with no
|
|
262
|
+
identity, unnamed, and the undo would refuse it (fix cycle 1, DEF-1826). A record `apply` does not list (`_listed`)
|
|
263
|
+
is removed without asking tmux: nothing listed it, nothing moves.
|
|
264
|
+
|
|
265
|
+
-> the record exactly as it was stored, plus `identity` ({session_id, created}) when a session answered: what
|
|
266
|
+
`reregister` checks before it saves the record again. Once the record is out of the file, a failure to hand back or
|
|
267
|
+
publish raises `Unpublished` carrying that same result, so the undo still stands."""
|
|
268
|
+
with L.registry_lock():
|
|
269
|
+
data = load()
|
|
270
|
+
record = next((item for item in data['sessions'] if item['id'] == lane_id), None)
|
|
271
|
+
if record is None:
|
|
272
|
+
raise L.LanesError(f'{lane_id} is not a registered session in {FILENAME}; reload')
|
|
273
|
+
result, identity = dict(record), None
|
|
274
|
+
if _listed(data, lane_id):
|
|
275
|
+
state, identity = _answer(*_probe(*key(record)))
|
|
276
|
+
if state == 'unknown':
|
|
277
|
+
raise L.LanesError('Session could not be checked; nothing was unregistered')
|
|
278
|
+
if identity:
|
|
279
|
+
result['identity'] = identity
|
|
280
|
+
data['sessions'] = [item for item in data['sessions'] if item['id'] != lane_id]
|
|
281
|
+
registry = apply(L.load_registry(), data)
|
|
142
282
|
registry['generated_at'] = time.time()
|
|
143
283
|
L.atomic_write(L.workspaces_dir() / FILENAME, json.dumps(data, ensure_ascii=False, indent=2) + '\n')
|
|
144
284
|
try:
|
|
145
|
-
|
|
285
|
+
if identity:
|
|
286
|
+
A.hand_back(record, identity)
|
|
287
|
+
refused = L.publish_registry(registry) or []
|
|
146
288
|
except OSError as error:
|
|
147
|
-
raise
|
|
148
|
-
|
|
289
|
+
raise Unpublished('Unregistered; run lanes sync to finish publishing: ' + str(error), result) from error
|
|
290
|
+
if refused: # out of the file and registry.json; a container's own projection still lists it (DEF-1796)
|
|
291
|
+
raise L.ProjectionRefused('Unregistered', refused, result)
|
|
292
|
+
return result
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def reregister(record):
|
|
296
|
+
"""The undo of `unregister` — sidebar `z` within 10 s (cockpit v3 S7): save the record it returned again, the same
|
|
297
|
+
id, workspace, name, binding and folder, byte for byte, after the checks `register` makes. Refused, writing
|
|
298
|
+
nothing, when the binding is registered again meanwhile (a repository declaration or an added session), when its
|
|
299
|
+
workspace no longer uses that container, or when the session is not the one that was unregistered:
|
|
300
|
+
|
|
301
|
+
- `identity` given (a session ran when it was unregistered): `register`'s identity gate, then statusd's listing —
|
|
302
|
+
a session it lists under that binding must carry that identity — then tmux, now, must name exactly that session
|
|
303
|
+
(a session closed and re-created under the same name in the window is a different one);
|
|
304
|
+
- no `identity` (nothing ran there): it is restored only while nothing runs there now, which is the state it was
|
|
305
|
+
unregistered in — `_answer` says absent; a live session, or no usable answer, refuses.
|
|
306
|
+
|
|
307
|
+
A publication that fails after the record is saved raises `Unpublished` (the undo is done; `lanes sync` publishes)."""
|
|
308
|
+
saved = {'id': record.get('id'), 'workspace': record.get('workspace'), 'name': record.get('name'),
|
|
309
|
+
'container': record.get('container'), 'tmux': dict(record.get('tmux') or {}),
|
|
310
|
+
'folder': record.get('folder')}
|
|
311
|
+
with L.registry_lock():
|
|
312
|
+
data = load()
|
|
313
|
+
registry = apply(L.load_registry(), data)
|
|
314
|
+
for field in ('id', 'workspace', 'container'):
|
|
315
|
+
L._require(L.IDENT, saved[field], field)
|
|
316
|
+
L._title(saved['name'])
|
|
317
|
+
container, socket, session = key(saved)
|
|
318
|
+
L._require(L.IDENT, socket, 'tmux socket')
|
|
319
|
+
L._require(L.TMUX_SESSION, session, 'tmux session')
|
|
320
|
+
L._require(L.SAFE_PATH, saved['folder'], 'working directory')
|
|
321
|
+
entry = dict(saved, holder='tmux')
|
|
322
|
+
for board in registry['workspaces']:
|
|
323
|
+
if any(key(lane) == key(entry) for lane in board['lanes'] if lane.get('holder') == 'tmux'):
|
|
324
|
+
raise L.LanesError('Already registered in ' + (board.get('title') or board['id']))
|
|
325
|
+
board = next((w for w in destinations(registry, entry) if w['id'] == saved['workspace']), None)
|
|
326
|
+
if not board:
|
|
327
|
+
raise L.LanesError('Choose a workspace that uses this session’s container')
|
|
328
|
+
if any(item['id'] == saved['id'] or key(item) == key(saved) for item in data['sessions']):
|
|
329
|
+
raise L.LanesError(f'Registration already saved; check {FILENAME} and run lanes sync')
|
|
330
|
+
identity = record.get('identity')
|
|
331
|
+
if identity:
|
|
332
|
+
_ready(identity)
|
|
333
|
+
published = (L.read_json(L.STATE / 'status.json', {}) or {}).get('unregistered') or {}
|
|
334
|
+
if any(isinstance(item, dict) and key(item) == key(saved) and item.get('identity') != identity
|
|
335
|
+
for entries in published.values() for item in entries or ()):
|
|
336
|
+
raise L.LanesError('Session changed; nothing was registered')
|
|
337
|
+
_confirm(container, socket, session, identity)
|
|
338
|
+
else:
|
|
339
|
+
state, _identity = _answer(*_probe(container, socket, session))
|
|
340
|
+
if state == 'live':
|
|
341
|
+
raise L.LanesError('A session runs under that name now; add it with a (lanes register); '
|
|
342
|
+
'nothing was registered')
|
|
343
|
+
if state == 'unknown':
|
|
344
|
+
raise L.LanesError('Session could not be checked; nothing was registered')
|
|
345
|
+
data['sessions'].append(saved)
|
|
346
|
+
return _save(registry, data, board, saved['id'])
|