@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.
@@ -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). Keys it does not
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
- for container in recorded_containers(state):
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); `notify` holds `quiet_s` only when set; `last_opened` is null until a lane is opened. Lane ids are
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
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vibelord/lanes",
3
- "version": "1.3.0",
4
- "commit": "cf8bfb3df12fb6210350adb6b3e481f29051ffae",
5
- "digest": "sha256:7e3cce373458d09b77c588dfb98b00777ceda08df504b319bcdf7d26cc3cc206",
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",