@vibelord/lanes 1.3.0 → 1.5.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 +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +797 -34
- package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
- package/payload/lanes/container/hooks/grok-hooks.json +12 -0
- package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
- package/payload/lanes/container/lane +13 -7
- package/payload/lanes/container/lane-mcp +56 -17
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +346 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +182 -14
- package/payload/lanes/docs/DESIGN.md +7 -0
- package/payload/lanes/docs/INSTALL.md +23 -3
- package/payload/lanes/docs/RUNBOOK.md +402 -5
- package/payload/lanes/docs/USERGUIDE.md +222 -23
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/attach.py +181 -0
- package/payload/lanes/libexec/economy_poll.py +6 -2
- package/payload/lanes/libexec/harness_grok.py +742 -0
- package/payload/lanes/libexec/lanes_lib.py +143 -20
- package/payload/lanes/libexec/sidebar.py +522 -14
- package/payload/lanes/libexec/statusd.py +434 -28
- package/payload/lanes/libexec/tokens.py +12 -1
- package/payload/lanes/libexec/update.py +78 -5
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/manifest.json +4 -3
|
@@ -17,7 +17,12 @@ installed. Its steps run in this order, each whatever the ones before it returne
|
|
|
17
17
|
systemctl --user try-restart <unit> for each optional service in TRY_RESTART (restarted only when it runs), and
|
|
18
18
|
right after lanes-docsd's, `sh install/enable-docs.sh` of the tree (the docs page's pinned Mermaid, plan W10 W8:
|
|
19
19
|
DOCS_STEP; skipped for a tree that has no such script) ·
|
|
20
|
-
lanes install-container <name> for each container `lanes sync` recorded with a home volume (containers.json)
|
|
20
|
+
lanes install-container <name> for each container `lanes sync` recorded with a home volume (containers.json), then
|
|
21
|
+
for the Throttle daemon's container when sync did not record it (daemon_container: site.conf's
|
|
22
|
+
LANES_THROTTLE_CONTAINER, else the Throttle tab's; DEF-1961) — not run, a warning with `outcome: skipped`, when sync
|
|
23
|
+
or docker said that container is gone or has no home volume (daemon_gone) — run by the tree just
|
|
24
|
+
installed — it re-ships the container contract files, the Throttle pulse among them since 1.4.0 (plan W11 Track D:
|
|
25
|
+
so the pulse updates with the host; spec §2.1 has where its loop runs), after statusd's restart ·
|
|
21
26
|
lanes reload-views (the viewports; sidebars swap by themselves, W9 U5) · lanes ensure --if-layout-changed (W9 U6).
|
|
22
27
|
Step output goes to the log (`update/last.log`); stdout gets one line per step and a summary, which names the Mermaid
|
|
23
28
|
outcome (`mermaid ok`, `mermaid skipped: <reason>`, …) whenever its step ran.
|
|
@@ -31,7 +36,9 @@ report of a condition the update neither caused nor can mend — `lanes sync` ex
|
|
|
31
36
|
enable-docs exiting 1 (a prerequisite or the download), 3 (refused: no valid pin, a digest mismatch) or not finishing
|
|
32
37
|
in STEP_TIMEOUT (the download) — recorded with its rc and `"warning": true`, never a failure. A step with outcomes
|
|
33
38
|
(STEP_OUTCOMES: enable-docs) that did not exit 0 also records `outcome` (`skipped`, `refused`, or `failed` for any
|
|
34
|
-
other status, which is a failure) and `reason`, the last line it wrote (else what its status says).
|
|
39
|
+
other status, which is a failure) and `reason`, the last line it wrote (else what its status says). The daemon
|
|
40
|
+
container's install-container step, when not run (daemon_gone), records rc 1 (DAEMON_SKIP_RC), `"warning": true`,
|
|
41
|
+
`outcome: skipped` and the reason. Keys it does not
|
|
35
42
|
own (a rollback's mark, plan W9 U3) are kept. host-install.sh writes `refused`, `failed` and a `partial` commit through
|
|
36
43
|
`record`; `post-commit` writes its own.
|
|
37
44
|
|
|
@@ -285,6 +292,57 @@ def recorded_containers(state):
|
|
|
285
292
|
if L.IDENT.match(name) and isinstance(info, dict) and info.get('home'))
|
|
286
293
|
|
|
287
294
|
|
|
295
|
+
THROTTLE_MONITOR = 'tools--throttle-monitor' # lanes_lib.build_registry's Throttle tab; bin/lanes THROTTLE_MONITOR
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def daemon_container(state, home=None):
|
|
299
|
+
"""The container the Throttle daemon (and so the Throttle pulse, plan W11) runs in, as bin/lanes throttle_container
|
|
300
|
+
names it — the one install-container starts the pulse in: LANES_THROTTLE_CONTAINER in
|
|
301
|
+
`<home>/.config/lanes/site.conf` (L.site_conf), else the container of `<state>/registry.json`'s Throttle tab; None
|
|
302
|
+
when neither names an identifier. Never this process's environment: a timer's update, a re-run and a rollback carry
|
|
303
|
+
none of it, and site.conf is where the operator names it. `lanes sync` records only the containers a board's repositories name (refresh_containers), so
|
|
304
|
+
a daemon container that hosts no board repository is not among recorded_containers (DEF-1961): post_commit adds it,
|
|
305
|
+
or the pulse there would keep the release it was first installed with."""
|
|
306
|
+
named = L.site_conf(home).get('LANES_THROTTLE_CONTAINER')
|
|
307
|
+
if named and L.IDENT.fullmatch(named):
|
|
308
|
+
return named
|
|
309
|
+
registry = L.read_json(Path(state) / 'registry.json', {})
|
|
310
|
+
tools = registry.get('tools') if isinstance(registry, dict) else None
|
|
311
|
+
named = next((tool.get('container') for tool in tools if isinstance(tool, dict)
|
|
312
|
+
and tool.get('id') == THROTTLE_MONITOR and isinstance(tool.get('container'), str)
|
|
313
|
+
and L.IDENT.fullmatch(tool['container'])), None) if isinstance(tools, list) else None
|
|
314
|
+
return named
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# The rc recorded for the daemon container's install-container step when it is not run (daemon_gone): the status
|
|
318
|
+
# install-container exits with for that container (bin/lanes cmd_install_container: a LanesError, exit 1). Recorded as
|
|
319
|
+
# a warning with `outcome: skipped` and the reason, never a failure.
|
|
320
|
+
DAEMON_SKIP_RC = 1
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def daemon_gone(state, container, inspect=None):
|
|
324
|
+
"""Why install-container cannot ship to the daemon's `container` (daemon_container) -> a reason, or None when it
|
|
325
|
+
may: nobody said it cannot (W11 Track D review). A name in site.conf or the Throttle tab outlives the container it
|
|
326
|
+
named (a `docker rm`, a rename); run, its install-container would exit 1 on every update, timer re-run and rollback,
|
|
327
|
+
leave each `partial` and the timer re-running the post-commit — the very thing recorded_containers keeps a gone
|
|
328
|
+
board container out of the steps for. From what `lanes sync` recorded when it recorded that container (no home
|
|
329
|
+
volume: it said so already), else from ONE docker inspect (`inspect`, default L.containers_info): docker answered it
|
|
330
|
+
does not exist, or it has no home volume. Docker giving no answer says nothing about it: None, and the step runs and
|
|
331
|
+
reports for itself, as a recorded container's does."""
|
|
332
|
+
document = L.read_json(Path(state) / 'containers.json', {})
|
|
333
|
+
if isinstance(document, dict) and isinstance(document.get(container), dict):
|
|
334
|
+
return None if document[container].get('home') else 'lanes sync recorded no home volume for it'
|
|
335
|
+
try:
|
|
336
|
+
answers = (L.containers_info if inspect is None else inspect)([container])
|
|
337
|
+
except (L.LanesError, subprocess.SubprocessError, OSError, ValueError):
|
|
338
|
+
return None
|
|
339
|
+
if not isinstance(answers, dict) or container not in answers:
|
|
340
|
+
return None
|
|
341
|
+
if answers[container] is None:
|
|
342
|
+
return 'docker has no such container'
|
|
343
|
+
return None if answers[container].get('home') else 'home volume not found'
|
|
344
|
+
|
|
345
|
+
|
|
288
346
|
def describe(rc, timeout):
|
|
289
347
|
return {124: f'no answer in {timeout:g} s', 127: 'could not start'}.get(rc, f'exit {rc}')
|
|
290
348
|
|
|
@@ -298,11 +356,12 @@ def count(names, noun):
|
|
|
298
356
|
|
|
299
357
|
|
|
300
358
|
def post_commit(tree, state, log, steps=(), before=None, started=None, out=None, run=None, timeout=STEP_TIMEOUT,
|
|
301
|
-
systemctl='systemctl', environ=None, home=None):
|
|
359
|
+
systemctl='systemctl', environ=None, home=None, inspect=None):
|
|
302
360
|
"""Run the post-commit steps from `tree` (whose bin/lanes runs them) -> the record written. `steps` are the phases
|
|
303
361
|
that ran before, [(name, rc)]. The record says `partial` before the first step, so a run killed half-way stays
|
|
304
362
|
`partial`; after the last step (or an interrupt: a SystemExit or KeyboardInterrupt is recorded as the step's rc,
|
|
305
|
-
then raised again) it says `ok` when every phase and step exited 0 or only warned (WARN_RC), else `partial`.
|
|
363
|
+
then raised again) it says `ok` when every phase and step exited 0 or only warned (WARN_RC), else `partial`.
|
|
364
|
+
`inspect` (default L.containers_info) is how daemon_gone asks docker about the daemon's container."""
|
|
306
365
|
out = sys.stdout if out is None else out
|
|
307
366
|
run = run_step if run is None else run
|
|
308
367
|
steps = list(steps)
|
|
@@ -326,6 +385,12 @@ def post_commit(tree, state, log, steps=(), before=None, started=None, out=None,
|
|
|
326
385
|
steps.append((name, rc, warning, extra))
|
|
327
386
|
print('ok ' + name if rc == 0 else f"{'WARN' if warning else 'FAIL'} {name} ({describe(rc, timeout)})",
|
|
328
387
|
file=out, flush=True)
|
|
388
|
+
|
|
389
|
+
def skip(name, reason):
|
|
390
|
+
# Not run: install-container would exit 1 for it (no such container, no home volume), every run, a
|
|
391
|
+
# condition the update neither caused nor can mend (DAEMON_SKIP_RC).
|
|
392
|
+
steps.append((name, DAEMON_SKIP_RC, True, {'outcome': 'skipped', 'reason': reason}))
|
|
393
|
+
print(f'WARN {name} (skipped: {reason})', file=out, flush=True)
|
|
329
394
|
try:
|
|
330
395
|
step('daemon-reload', [systemctl, '--user', 'daemon-reload'])
|
|
331
396
|
step('sync', [lanes, 'sync'])
|
|
@@ -336,8 +401,16 @@ def post_commit(tree, state, log, steps=(), before=None, started=None, out=None,
|
|
|
336
401
|
# `sh`, as the RUNBOOK runs it: a tree whose script lost its mode bit still installs Mermaid. A
|
|
337
402
|
# tree from before W10 W6 (a rollback's) has no script: nothing to run, no step.
|
|
338
403
|
step(DOCS_STEP, ['sh', str(Path(tree) / DOCS_SCRIPT)])
|
|
339
|
-
|
|
404
|
+
containers = recorded_containers(state)
|
|
405
|
+
for container in containers:
|
|
340
406
|
step('install-container ' + container, [lanes, 'install-container', container])
|
|
407
|
+
daemon = daemon_container(state, home)
|
|
408
|
+
if daemon and daemon not in containers:
|
|
409
|
+
gone = daemon_gone(state, daemon, inspect)
|
|
410
|
+
if gone:
|
|
411
|
+
skip('install-container ' + daemon, gone)
|
|
412
|
+
else:
|
|
413
|
+
step('install-container ' + daemon, [lanes, 'install-container', daemon])
|
|
341
414
|
step('reload-views', [lanes, 'reload-views'])
|
|
342
415
|
step('ensure', [lanes, 'ensure', '--if-layout-changed'])
|
|
343
416
|
finished = True
|
|
@@ -4,7 +4,7 @@ One JSON file per person under ~/.local/state/lanes/users/ (directory 0700, file
|
|
|
4
4
|
person owns (members.owner_for decides ownership, as devices/<device>.json records it):
|
|
5
5
|
|
|
6
6
|
{"version": 1, "login": "anna@example.com", "name": "Anna Berg",
|
|
7
|
-
"look": {"theme": "light", "color": "256", "glyphs": "ascii"},
|
|
7
|
+
"look": {"theme": "light", "color": "256", "glyphs": "ascii", "fleet_collapsed": true},
|
|
8
8
|
"seen": {"bw-tools--lanes-zellij": 1791000000.25},
|
|
9
9
|
"workspaces": {"order": ["throttle", "bw-tools"], "hidden": []},
|
|
10
10
|
"lanes": {"pins": ["bw-tools--lanes-zellij"], "hidden": []},
|
|
@@ -16,7 +16,8 @@ person owns (members.owner_for decides ownership, as devices/<device>.json recor
|
|
|
16
16
|
A login whose quoted name would not fit a file name (MAX_STEM) has no profile.
|
|
17
17
|
* Times are epoch seconds. `seen` is DM-LANES-CLIENT-SEEN's {lane id: when it was last opened} for the person (the
|
|
18
18
|
newest MAX_SEEN marks); `look` holds only the keys set, with bin/lanes LOOK_KEYS' values (`notify`/`dim` stay
|
|
19
|
-
per-device)
|
|
19
|
+
per-device), and the sidebar's LOOK_FLAGS as JSON booleans (`fleet_collapsed`, the fleet block's `i`, W11);
|
|
20
|
+
`notify` holds `quiet_s` only when set; `last_opened` is null until a lane is opened. Lane ids are
|
|
20
21
|
L.IDENT (at most MAX_LANE_ID characters), workspace ids L.IDENT at most 64, devices members.device_name, `name`
|
|
21
22
|
one printable line (members._text: no control, format, surrogate, private-use or unassigned character).
|
|
22
23
|
* Readers are tolerant and never raise: an invalid entry is dropped on its own; unknown top-level keys (a newer
|
|
@@ -49,7 +50,9 @@ person owns (members.owner_for decides ownership, as devices/<device>.json recor
|
|
|
49
50
|
(load_all): one written by a desk device within the quiet window is presence, and `notify.quiet_s` is that
|
|
50
51
|
person's own window. `updated_by_device` names the device a change was made AT (a sidebar, `lanes attach`, `lanes
|
|
51
52
|
next|prev`, `lanes here`); the arranging verbs stamp `cli:<command>` and migrate() `cli:migration` instead
|
|
52
|
-
(writers that are no device, so an admin's verb over SSH is never somebody at a desk: W9 review F3).
|
|
53
|
+
(writers that are no device, so an admin's verb over SSH is never somebody at a desk: W9 review F3). Since W11
|
|
54
|
+
(spec 2026-10-11 §3) the sidebar's `i` writes `look.fleet_collapsed` (set_look_flag) and every sidebar of the
|
|
55
|
+
person folds or unfolds its fleet block from the profile on its next tick (look_flag).
|
|
53
56
|
"""
|
|
54
57
|
import copy
|
|
55
58
|
import fcntl
|
|
@@ -71,6 +74,13 @@ FIELDS = ('version', 'login', 'name', 'look', 'seen', 'workspaces', 'lanes', 'la
|
|
|
71
74
|
# bin/lanes LOOK_KEYS' theme/color/glyphs, value for value (test_users pins the equality).
|
|
72
75
|
LOOK = {'theme': ('dark', 'light', 'auto'), 'color': ('truecolor', '256', '16', 'none'),
|
|
73
76
|
'glyphs': ('unicode', 'ascii', 'emoji')}
|
|
77
|
+
# The person's sidebar switches beside LOOK (W11, spec 2026-10-11 §3): JSON booleans in the profile's `look`, written
|
|
78
|
+
# only by a sidebar key (set_look_flag) — never by `lanes attach`, never in a device file, never part of LOOK (so
|
|
79
|
+
# layered_look, device_only, unfolded and migrate's fold never see one). `fleet_collapsed` is the fleet block folded
|
|
80
|
+
# to its one line (`i`); absent is unfolded. A release before W11 drops the key when it normalizes a profile — its
|
|
81
|
+
# writer then stores the profile without it (DEF-1950): a sidebar still running the previous tree during an update
|
|
82
|
+
# unfolds the block for every device of the person once, until the next `i`.
|
|
83
|
+
LOOK_FLAGS = ('fleet_collapsed',)
|
|
74
84
|
MAX_STEM = 200 # '.' + stem + '.json.' + 8 (L.atomic_write's temp name) stays under NAME_MAX 255
|
|
75
85
|
MAX_LANE_ID = 256 # L.TRACE's cap: a longer lane id cannot be traced either
|
|
76
86
|
MAX_SEEN = 1024 # newest marks kept; an older mark only re-shows a Done lane until it is opened
|
|
@@ -213,6 +223,7 @@ def normalize(document, login=None):
|
|
|
213
223
|
if isinstance(look, dict):
|
|
214
224
|
profile['look'] = {key: look[key] for key, allowed in LOOK.items() if isinstance(look.get(key), str)
|
|
215
225
|
and look[key] in allowed}
|
|
226
|
+
profile['look'].update({key: look[key] for key in LOOK_FLAGS if isinstance(look.get(key), bool)})
|
|
216
227
|
seen = document.get('seen')
|
|
217
228
|
if isinstance(seen, dict):
|
|
218
229
|
profile['seen'] = _bounded({lane: at for lane, at in seen.items() if _valid(check_lane, lane) and _time(at)})
|
|
@@ -409,6 +420,23 @@ def update(login, device, fn, now=None, directory=None, timeout=LOCK_S):
|
|
|
409
420
|
os.close(fd)
|
|
410
421
|
|
|
411
422
|
|
|
423
|
+
def look_flag(profile, key):
|
|
424
|
+
"""The LOOK_FLAGS boolean `key` of a normalized profile (read()), False when the profile or the key is absent."""
|
|
425
|
+
return bool(profile) and profile['look'].get(key) is True
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def set_look_flag(login, device, key, value, directory=None, timeout=LOCK_S):
|
|
429
|
+
"""Set one LOOK_FLAGS boolean in `login`'s profile look through update() — its lock, its merge, its dropped-write
|
|
430
|
+
rule (False: the lock was busy within `timeout`, or the profile is not one this release writes). Raises
|
|
431
|
+
L.LanesError for a key that is no flag or a value that is no bool (a caller's bug, never a person's input)."""
|
|
432
|
+
if key not in LOOK_FLAGS or not isinstance(value, bool):
|
|
433
|
+
raise L.LanesError('not a profile look flag')
|
|
434
|
+
|
|
435
|
+
def change(profile):
|
|
436
|
+
profile['look'][key] = value
|
|
437
|
+
return update(login, device, change, directory=directory, timeout=timeout)
|
|
438
|
+
|
|
439
|
+
|
|
412
440
|
# ------------------------------------------------------------------------------------------------------- migration
|
|
413
441
|
|
|
414
442
|
def _names(directory, suffix):
|
|
@@ -13,3 +13,7 @@ LANES_SERVER_NAME=workspace-server.example.ts.net
|
|
|
13
13
|
LANES_SERVER_USER=workspace
|
|
14
14
|
# Optional public SSH fallback (use only if your server exposes SSH there):
|
|
15
15
|
# LANES_SERVER_PUBLIC=ssh.example.com
|
|
16
|
+
# Optional, read only on the server (its ~/.config/lanes/site.conf): the fleet dashboard the cockpit's host row and
|
|
17
|
+
# `lanes doctor` link to. https only (https://host[:port][/path]); anything else is ignored and doctor says why. Leave
|
|
18
|
+
# it unset until an authenticated Grafana deployment exists — no Lanes command ever writes it.
|
|
19
|
+
# LANES_FLEET_DASHBOARD_URL=https://grafana.example.ts.net/d/fleet
|
package/payload/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibelord/lanes",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"commit": "
|
|
5
|
-
"digest": "sha256:
|
|
3
|
+
"version": "1.5.1",
|
|
4
|
+
"commit": "3b7ac2e84a23ed47f2ab3f404232286c47d3f2e8",
|
|
5
|
+
"digest": "sha256:1647256dba4c8075b45ba74c973e4ccaf00ae74e2f572f5678c16e7ad1f682bc",
|
|
6
6
|
"executables": [
|
|
7
7
|
"bin/lanes",
|
|
8
8
|
"client/install.sh",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"container/lane-mcp",
|
|
16
16
|
"container/lane-run",
|
|
17
17
|
"container/lane-signal",
|
|
18
|
+
"container/throttle-pulse",
|
|
18
19
|
"holders/exec.sh",
|
|
19
20
|
"holders/ssh.sh",
|
|
20
21
|
"holders/tmux.sh",
|