@vibelord/lanes 1.2.0 → 1.5.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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +7 -5
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +1091 -76
  5. package/payload/lanes/container/hooks/grok-cli-hooks.json +8 -0
  6. package/payload/lanes/container/hooks/grok-hooks.json +12 -0
  7. package/payload/lanes/container/hooks/kimi-hooks.toml +49 -0
  8. package/payload/lanes/container/lane +55 -8
  9. package/payload/lanes/container/lane-mcp +120 -22
  10. package/payload/lanes/container/lane-signal +86 -23
  11. package/payload/lanes/container/lane_core.py +524 -36
  12. package/payload/lanes/container/throttle-pulse +456 -0
  13. package/payload/lanes/docs/ARCHITECTURE.md +357 -34
  14. package/payload/lanes/docs/DESIGN.md +13 -3
  15. package/payload/lanes/docs/INSTALL.md +47 -10
  16. package/payload/lanes/docs/RUNBOOK.md +761 -12
  17. package/payload/lanes/docs/USERGUIDE.md +421 -46
  18. package/payload/lanes/holders.lock +5 -0
  19. package/payload/lanes/install/enable-docs.sh +78 -0
  20. package/payload/lanes/install/host-install.sh +39 -5
  21. package/payload/lanes/libexec/agent_state.py +604 -12
  22. package/payload/lanes/libexec/docs_store.py +995 -0
  23. package/payload/lanes/libexec/docsd.py +2010 -0
  24. package/payload/lanes/libexec/economy_poll.py +6 -2
  25. package/payload/lanes/libexec/harness_grok.py +742 -0
  26. package/payload/lanes/libexec/lanes_lib.py +143 -20
  27. package/payload/lanes/libexec/mdlite.py +575 -0
  28. package/payload/lanes/libexec/members.py +5 -0
  29. package/payload/lanes/libexec/notify.py +65 -5
  30. package/payload/lanes/libexec/sidebar.py +626 -25
  31. package/payload/lanes/libexec/statusd.py +530 -32
  32. package/payload/lanes/libexec/tokens.py +42 -9
  33. package/payload/lanes/libexec/update.py +171 -26
  34. package/payload/lanes/libexec/uploads.py +538 -0
  35. package/payload/lanes/libexec/users.py +31 -3
  36. package/payload/lanes/site/site.example.conf +4 -0
  37. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  38. package/payload/lanes/web/Caddyfile +24 -1
  39. package/payload/lanes/web/README.md +86 -8
  40. package/payload/lanes/web/browser.js +2 -4
  41. package/payload/lanes/web/docs.css +52 -0
  42. package/payload/lanes/web/docs.js +36 -0
  43. package/payload/lanes/web/mobile.js +3 -2
  44. package/payload/lanes/web/upload.css +17 -0
  45. package/payload/lanes/web/upload.js +84 -0
  46. package/payload/manifest.json +5 -3
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env python3
2
- """The Lanes design tokens: ONE table, rendered seven ways. The sidebar imports `sgr_table` (truecolor, 256, 16 or
3
- none); `--kdl`, `--css`, `--sh`, `--md`, `--js` and `--ghostty-dark|--ghostty-light` write the Zellij theme, the phone
4
- CSS, the viewer's shell colour sets, the docs tables, the phone's terminal palette and the optional Ghostty palette
5
- files between markers; `--check` fails when any committed block differs. Spec:
2
+ """The Lanes design tokens: ONE table, rendered eight ways. The sidebar imports `sgr_table` (truecolor, 256, 16 or
3
+ none); `--kdl`, `--css`, `--sh`, `--md`, `--js`, `--ghostty-dark|--ghostty-light` and `--docs-css` write the Zellij
4
+ theme, the phone CSS, the viewer's shell colour sets, the docs tables, the phone's terminal palette, the optional
5
+ Ghostty palette files and the docs page's CSS (plan W10 W6) between markers; `--check` fails when any committed block
6
+ differs. Spec:
6
7
  docs/superpowers/specs/2026-10-03-lanes-cockpit-design.md; palette v2 + the 256-colour set: cockpit v3 P1 (LNS-69).
7
8
  No third-party code; importable with no side effects."""
8
9
  import difflib
@@ -89,7 +90,21 @@ MARKS = {'cursor': ('▌', '|', 'accent'), 'viewing': ('›', '=', 'accent'), 'v
89
90
  # more`. The lowest of column 3's marks: pending, the viewports and the viewing mark win over it. Seven-bit
90
91
  # `^` is free in column 3 (`=` `2` `3` `~` are the others; the upload strip's `^` is no column-3 mark).
91
92
  'pinned': ('▲', '^', 'accent'),
92
- 'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent')}
93
+ 'ok': ('✓', 'OK', 'tone-done'), 'fail': ('✗', 'X', 'tone-urgent'),
94
+ # The docs cockpit on the upload strip (` ↑ uploads · ▤ docs 3 (U)`, plan W10 W7): the strip's own tone.
95
+ # Seven-bit `#` is free on that row (`^` uploads, `v` armed, `-` sep are the others).
96
+ 'docs': ('▤', '#', 'ink-dim'),
97
+ # The fleet block (W11, spec 2026-10-11 §3), pinned above the upload strip: column 2 of each row says what
98
+ # the row is about — Throttle ⚙, a provider's subscription pool ◆, the host and its containers ▣ — in the
99
+ # row's worst tone; then a provider's availability bar, ten cells of usable ▰ · cooling ▱ · benched ▪ ·
100
+ # dead ·. The seven-bit twins are unique among the block's own marks and against every other mark its rows
101
+ # draw (`-` sep, `?` stale, `OK` / `!` / `X` the folded line's verdict). `#` stays the docs twin alone; the
102
+ # unicode `·` of a dead cell is the spec's (it shares the separator's glyph, never its place: a bar has no
103
+ # blank inside it).
104
+ 'fleet-throttle': ('⚙', '@', 'ink-dim'), 'fleet-provider': ('◆', '*', 'ink-dim'),
105
+ 'fleet-host': ('▣', '[', 'ink-dim'),
106
+ 'bar-usable': ('▰', '=', 'tone-done'), 'bar-cooling': ('▱', '~', 'ink-dim'),
107
+ 'bar-benched': ('▪', ':', 'tone-attention'), 'bar-dead': ('·', '.', 'ink-faint')}
93
108
  ECONOMY_TAIL = {'starving': ('⚠', '!', 'tone-attention'), 'downgraded': ('↓', 'v', 'tone-attention'), 'paid': ('$', '$', 'ink-dim')}
94
109
  MOBILE_AGENT_LABELS = {'needs_approval': 'Needs approval', 'needs_input': 'Needs you', 'working': 'Working',
95
110
  'done': 'Done', 'errored': 'Error', 'shell': 'Shell', 'idle': 'Idle'}
@@ -242,6 +257,22 @@ def render_css():
242
257
  return '\n'.join(out) + '\n'
243
258
 
244
259
 
260
+ # The docs page (plan W10 W6, LNS-107; web/docs.css) is its own HTML document, served by lanes-docsd, not the phone
261
+ # shell: no html.lanes-mobile, no Zellij data-zj-theme. Its block puts the roles it paints on :root, dark by default and
262
+ # light under the system's prefers-color-scheme, and only those roles (docs.css has a 2.5 KB cap; the full --css block
263
+ # alone is 2 KB). test_docs_assets pins that docs.css uses exactly these.
264
+ DOCS_ROLES = ('surface', 'surface-raised', 'line', 'ink', 'ink-dim', 'accent')
265
+
266
+
267
+ def render_docs_css():
268
+ out = ['/* BEGIN lanes tokens (generated by libexec/tokens.py --docs-css; do not edit by hand) */', ':root {']
269
+ out += [f' --lanes-{role}: {TOKENS["dark"][role]};' for role in DOCS_ROLES]
270
+ out += [' color-scheme: dark;', '}', '@media (prefers-color-scheme: light) {', ' :root {']
271
+ out += [f' --lanes-{role}: {TOKENS["light"][role]};' for role in DOCS_ROLES]
272
+ out += [' color-scheme: light;', ' }', '}', '/* END lanes tokens */']
273
+ return '\n'.join(out) + '\n'
274
+
275
+
245
276
  # The painted roles as the viewer's shell variables (`tokens.sh`), in PAINTED order. LZ_DIM kept its v1 name.
246
277
  SH_NAMES = {'ink': 'LZ_INK', 'ink-dim': 'LZ_DIM', 'ink-faint': 'LZ_FAINT', 'accent': 'LZ_ACCENT',
247
278
  'tone-urgent': 'LZ_URGENT', 'tone-attention': 'LZ_ATTENTION', 'tone-working': 'LZ_WORKING',
@@ -333,14 +364,16 @@ def render_ghostty(variant):
333
364
 
334
365
 
335
366
  RENDERERS = {'kdl': render_kdl, 'css': render_css, 'sh': render_sh, 'md': render_md, 'js': render_js,
336
- 'ghostty-dark': lambda: render_ghostty('dark'), 'ghostty-light': lambda: render_ghostty('light')}
367
+ 'ghostty-dark': lambda: render_ghostty('dark'), 'ghostty-light': lambda: render_ghostty('light'),
368
+ 'docs-css': render_docs_css}
337
369
  TARGETS = {'kdl': 'zellij/config.kdl', 'css': 'web/mobile.css', 'sh': 'libexec/tokens.sh', 'md': 'docs/DESIGN.md',
338
370
  'js': 'web/mobile.js', 'ghostty-dark': 'client/ghostty/lanes-palette.conf',
339
- 'ghostty-light': 'client/ghostty/lanes-palette-light.conf'}
371
+ 'ghostty-light': 'client/ghostty/lanes-palette-light.conf', 'docs-css': 'web/docs.css'}
340
372
  MARKERS = {'kdl': ('// BEGIN lanes tokens', '// END lanes tokens'), 'css': ('/* BEGIN lanes tokens', '/* END lanes tokens */'),
341
373
  'sh': ('# BEGIN lanes tokens', '# END lanes tokens'), 'md': ('<!-- BEGIN lanes tokens', '<!-- END lanes tokens -->'),
342
374
  'js': ('// BEGIN lanes tokens', '// END lanes tokens'),
343
- 'ghostty-dark': ('# BEGIN lanes tokens', '# END lanes tokens'), 'ghostty-light': ('# BEGIN lanes tokens', '# END lanes tokens')}
375
+ 'ghostty-dark': ('# BEGIN lanes tokens', '# END lanes tokens'), 'ghostty-light': ('# BEGIN lanes tokens', '# END lanes tokens'),
376
+ 'docs-css': ('/* BEGIN lanes tokens', '/* END lanes tokens */')}
344
377
 
345
378
 
346
379
  def render(kind):
@@ -392,7 +425,7 @@ def main(argv):
392
425
  return 0
393
426
  kind = argv[0].lstrip('-') if argv else ''
394
427
  if kind not in RENDERERS:
395
- sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--js|--ghostty-dark|--ghostty-light|--check|--write')
428
+ sys.exit('usage: tokens.py --kdl|--css|--sh|--md|--js|--ghostty-dark|--ghostty-light|--docs-css|--check|--write')
396
429
  sys.stdout.write(render(kind))
397
430
  return 0
398
431
 
@@ -14,19 +14,33 @@ catalog — a failure there leaves the installed release running as it was. comm
14
14
  installed. Its steps run in this order, each whatever the ones before it returned, each bounded by STEP_TIMEOUT seconds
15
15
  (its whole process group is stopped when it does not finish):
16
16
  systemctl --user daemon-reload · lanes sync · systemctl --user restart lanes-statusd.service ·
17
- systemctl --user try-restart <unit> for each optional service in TRY_RESTART (restarted only when it runs) ·
18
- lanes install-container <name> for each container `lanes sync` recorded with a home volume (containers.json) ·
17
+ systemctl --user try-restart <unit> for each optional service in TRY_RESTART (restarted only when it runs), and
18
+ right after lanes-docsd's, `sh install/enable-docs.sh` of the tree (the docs page's pinned Mermaid, plan W10 W8:
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), 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 ·
19
26
  lanes reload-views (the viewports; sidebars swap by themselves, W9 U5) · lanes ensure --if-layout-changed (W9 U6).
20
- Step output goes to the log (`update/last.log`); stdout gets one line per step and a summary.
27
+ Step output goes to the log (`update/last.log`); stdout gets one line per step and a summary, which names the Mermaid
28
+ outcome (`mermaid ok`, `mermaid skipped: <reason>`, …) whenever its step ran.
21
29
 
22
30
  The record, `<state>/update/last.json` (DM-LANES-UPDATE-RECORD), is one JSON object {checked_at, latest, installed,
23
- previous, result, steps: [{name, rc[, warning]}], log}. result: `ok` every phase and step exited 0 or only warned;
24
- `partial` the release is installed but a step after the commit failed (also while the post-commit runs: it is written
25
- before the first step); `refused` a prerequisite, no installed release, or another install holding the install lock
26
- said no and nothing changed; `failed` nothing was installed. A warning (WARN_RC) is a step's own report of a condition
27
- the update neither caused nor can mend — `lanes sync` exiting 1 for a board with an error — recorded with its rc and
28
- `"warning": true`, never a failure. Keys it does not own (a rollback's mark, plan W9 U3) are kept. host-install.sh
29
- writes `refused`, `failed` and a `partial` commit through `record`; `post-commit` writes its own.
31
+ previous, result, steps: [{name, rc[, warning][, outcome, reason]}], log}. result: `ok` every phase and step exited 0
32
+ or only warned; `partial` the release is installed but a step after the commit failed (also while the post-commit
33
+ runs: it is written before the first step); `refused` a prerequisite, no installed release, or another install holding
34
+ the install lock said no and nothing changed; `failed` nothing was installed. A warning (WARN_RC) is a step's own
35
+ report of a condition the update neither caused nor can mend — `lanes sync` exiting 1 for a board with an error;
36
+ enable-docs exiting 1 (a prerequisite or the download), 3 (refused: no valid pin, a digest mismatch) or not finishing
37
+ in STEP_TIMEOUT (the download) — recorded with its rc and `"warning": true`, never a failure. A step with outcomes
38
+ (STEP_OUTCOMES: enable-docs) that did not exit 0 also records `outcome` (`skipped`, `refused`, or `failed` for any
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
42
+ own (a rollback's mark, plan W9 U3) are kept. host-install.sh writes `refused`, `failed` and a `partial` commit through
43
+ `record`; `post-commit` writes its own.
30
44
 
31
45
  The run lock, `<state>/update/.lock` (fix cycle 1 of W9 U2): every server install and update takes it before it writes
32
46
  anything — the log and the record included — and holds it to its end, so two runs never interleave a post-commit or
@@ -77,8 +91,8 @@ STEP_TIMEOUT = 300 # seconds per post-commit step; then its process gro
77
91
  STOP_WAIT = 5 # seconds between SIGTERM and SIGKILL of a step's process group, and after it
78
92
  # The optional services a post-commit restarts only when they run (`systemctl try-restart`), in this order. Each keeps
79
93
  # its enable state: an update never switches on a unit the operator left off (economy, notify, web: operator gates).
80
- TRY_RESTART = ('lanes-economy.service', 'lanes-notify.service', 'lanes-enrold.service', 'lanes-mobile.service',
81
- 'lanes-web.service', 'lanes-studio-host.service')
94
+ TRY_RESTART = ('lanes-economy.service', 'lanes-notify.service', 'lanes-enrold.service', 'lanes-docsd.service',
95
+ 'lanes-mobile.service', 'lanes-web.service', 'lanes-studio-host.service')
82
96
  RESULTS = ('ok', 'partial', 'refused', 'failed')
83
97
  OWNED = ('checked_at', 'latest', 'installed', 'previous', 'result', 'steps', 'log') # the keys every record rewrites
84
98
  PARTIAL = 4 # post-commit's exit status when a step failed (host-install.sh --update-only's as well)
@@ -90,7 +104,19 @@ REFUSED = 3 # `lock`'s exit status when another run holds the ru
90
104
  # cmd_sync: `return 1 if failed`); counted as a failure, it would make every update `partial` for as long as such a
91
105
  # board exists, and `lanes update` re-run the whole post-commit on every tick (fix cycle 1 of W9 U2). Any other status
92
106
  # of the step — 124 no answer, 127 could not start, 128+N a signal — is a failure like any other.
93
- WARN_RC = {'sync': 1}
107
+ # The docs page's pinned Mermaid (plan W10 W8, LNS-109) is the other: install/enable-docs.sh of the tree just installed,
108
+ # run right after lanes-docsd's try-restart (the full install runs it right after lanes-docsd's restart). Never fatal:
109
+ # Mermaid is optional (without it every fence shows its source), so its exit statuses 1 (a prerequisite is missing or
110
+ # the download failed: skipped), 3 (refused: no valid pin in holders.lock, a digest mismatch; nothing installed) and
111
+ # 124 (the download did not finish in STEP_TIMEOUT: skipped) are warnings; any other status (2 usage, 127 could not
112
+ # start, a signal) is a failure like any other step's. An installed file with the pinned digest is kept: no download.
113
+ DOCS_UNIT = 'lanes-docsd.service'
114
+ DOCS_STEP = 'enable-docs'
115
+ DOCS_SCRIPT = 'install/enable-docs.sh'
116
+ WARN_RC = {'sync': (1,), DOCS_STEP: (1, 3, 124)}
117
+ # The steps whose outcome the record and the summary name (any status not listed: `failed`).
118
+ STEP_OUTCOMES = {DOCS_STEP: {0: 'ok', 1: 'skipped', 3: 'refused', 124: 'skipped'}}
119
+ REASON_LIMIT = 300 # characters of a step's last line kept as its `reason` (a refusal names two digests)
94
120
  LOCK = 'update/.lock' # the run lock, under the state directory
95
121
  MODES = ('update', 'install')
96
122
  RELEASE = re.compile(r'[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.+-]+)?\Z')
@@ -111,11 +137,14 @@ def utc(at=None):
111
137
 
112
138
 
113
139
  def step_entry(step):
114
- """A record's step from (name, rc) or (name, rc, warning): {name, rc}, plus `"warning": true` for a warning."""
140
+ """A record's step from (name, rc), (name, rc, warning) or (name, rc, warning, extra): {name, rc}, plus
141
+ `"warning": true` for a warning, plus `extra` ({outcome, reason} of a step with outcomes that did not exit 0)."""
115
142
  name, rc, *rest = step
116
143
  entry = {'name': name, 'rc': int(rc)}
117
144
  if rest and rest[0]:
118
145
  entry['warning'] = True
146
+ if len(rest) > 1 and rest[1]:
147
+ entry.update(rest[1])
119
148
  return entry
120
149
 
121
150
 
@@ -128,6 +157,44 @@ def warned_steps(steps):
128
157
  return [entry['name'] for entry in map(step_entry, steps) if entry.get('warning')]
129
158
 
130
159
 
160
+ def step_outcome(name, rc, said, timeout):
161
+ """{} for a step without outcomes (STEP_OUTCOMES) or one that exited 0, else {outcome, reason}: the outcome its
162
+ status names (`failed` when it names none) and `reason`, the last line `said` (its own output) holds, else what
163
+ the status says (describe)."""
164
+ outcomes = STEP_OUTCOMES.get(name)
165
+ if outcomes is None or rc == 0:
166
+ return {}
167
+ lines = [line for line in said.splitlines() if line.strip()]
168
+ reason = printable(lines[-1], REASON_LIMIT) if lines and rc not in (124, 127) else describe(rc, timeout)
169
+ return {'outcome': outcomes.get(rc, 'failed'), 'reason': reason}
170
+
171
+
172
+ def step_said(log, start):
173
+ """What a step wrote to the log after byte `start`: run_step's `== argv` line and its `-> …` line left out."""
174
+ try:
175
+ with open(log, 'rb') as handle:
176
+ handle.seek(start)
177
+ text = handle.read().decode('utf-8', 'replace')
178
+ except OSError:
179
+ return ''
180
+ lines = text.splitlines()
181
+ if lines and lines[0].startswith('== '):
182
+ lines = lines[1:]
183
+ if lines and lines[-1].startswith('-> '):
184
+ lines = lines[:-1]
185
+ return '\n'.join(lines)
186
+
187
+
188
+ def outcome_clause(steps):
189
+ """The summary's words for the steps with outcomes that ran: `; mermaid ok`, `; mermaid skipped: <reason>`, …"""
190
+ words = []
191
+ for entry in map(step_entry, steps):
192
+ if entry['name'] == DOCS_STEP:
193
+ outcome = entry.get('outcome') or ('ok' if entry['rc'] == 0 else 'failed')
194
+ words.append(f"; mermaid {outcome}" + (f": {entry['reason']}" if entry.get('reason') else ''))
195
+ return ''.join(words)
196
+
197
+
131
198
  def installed_tree(home=None):
132
199
  """The existing tree the `current` link names now (resolved), else None."""
133
200
  try:
@@ -225,6 +292,57 @@ def recorded_containers(state):
225
292
  if L.IDENT.match(name) and isinstance(info, dict) and info.get('home'))
226
293
 
227
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
+
228
346
  def describe(rc, timeout):
229
347
  return {124: f'no answer in {timeout:g} s', 127: 'could not start'}.get(rc, f'exit {rc}')
230
348
 
@@ -238,11 +356,12 @@ def count(names, noun):
238
356
 
239
357
 
240
358
  def post_commit(tree, state, log, steps=(), before=None, started=None, out=None, run=None, timeout=STEP_TIMEOUT,
241
- systemctl='systemctl', environ=None, home=None):
359
+ systemctl='systemctl', environ=None, home=None, inspect=None):
242
360
  """Run the post-commit steps from `tree` (whose bin/lanes runs them) -> the record written. `steps` are the phases
243
361
  that ran before, [(name, rc)]. The record says `partial` before the first step, so a run killed half-way stays
244
362
  `partial`; after the last step (or an interrupt: a SystemExit or KeyboardInterrupt is recorded as the step's rc,
245
- 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."""
246
365
  out = sys.stdout if out is None else out
247
366
  run = run_step if run is None else run
248
367
  steps = list(steps)
@@ -252,40 +371,66 @@ def post_commit(tree, state, log, steps=(), before=None, started=None, out=None,
252
371
  Path(log).parent.mkdir(parents=True, exist_ok=True)
253
372
  with open(log, 'a') as handle:
254
373
  def step(name, argv):
374
+ handle.flush()
375
+ start = os.fstat(handle.fileno()).st_size # where this step's lines begin (O_APPEND: the end)
255
376
  try:
256
377
  rc = run(argv, handle, timeout)
257
378
  except (KeyboardInterrupt, SystemExit) as error:
258
379
  steps.append((name, interrupted_rc(error)))
259
380
  print(f'FAIL {name} (interrupted)', file=out, flush=True)
260
381
  raise
261
- warning = rc != 0 and WARN_RC.get(name) == rc
262
- steps.append((name, rc, warning))
382
+ warning = rc != 0 and rc in WARN_RC.get(name, ())
383
+ handle.flush()
384
+ extra = step_outcome(name, rc, step_said(log, start) if rc and name in STEP_OUTCOMES else '', timeout)
385
+ steps.append((name, rc, warning, extra))
263
386
  print('ok ' + name if rc == 0 else f"{'WARN' if warning else 'FAIL'} {name} ({describe(rc, timeout)})",
264
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)
265
394
  try:
266
395
  step('daemon-reload', [systemctl, '--user', 'daemon-reload'])
267
396
  step('sync', [lanes, 'sync'])
268
397
  step('restart lanes-statusd', [systemctl, '--user', 'restart', 'lanes-statusd.service'])
269
398
  for unit in TRY_RESTART:
270
399
  step('try-restart ' + unit[:-len('.service')], [systemctl, '--user', 'try-restart', unit])
271
- for container in recorded_containers(state):
400
+ if unit == DOCS_UNIT and (Path(tree) / DOCS_SCRIPT).is_file():
401
+ # `sh`, as the RUNBOOK runs it: a tree whose script lost its mode bit still installs Mermaid. A
402
+ # tree from before W10 W6 (a rollback's) has no script: nothing to run, no step.
403
+ step(DOCS_STEP, ['sh', str(Path(tree) / DOCS_SCRIPT)])
404
+ containers = recorded_containers(state)
405
+ for container in containers:
272
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])
273
414
  step('reload-views', [lanes, 'reload-views'])
274
415
  step('ensure', [lanes, 'ensure', '--if-layout-changed'])
275
416
  finished = True
276
417
  finally:
277
418
  ok = finished and not failed_steps(steps)
278
419
  document = write_record(state, 'ok' if ok else 'partial', steps, tree, before, log, started, environ, home)
279
- failed, warned = failed_steps(steps), warned_steps(steps)
420
+ # A step with outcomes is named by its own clause (`; mermaid skipped: …`), not again among the warnings.
421
+ failed, warned = failed_steps(steps), [name for name in warned_steps(steps) if name not in STEP_OUTCOMES]
280
422
  was = f" (was {document['previous']})" if document['previous'] else ''
281
423
  warnings = f"; {count(warned, 'warning')}: {', '.join(warned)}" if warned else ''
282
- if ok and not warned:
283
- print(f"ok: lanes {document['installed']} installed{was}; every step ran — {record_path(state)}", file=out)
424
+ outcomes = outcome_clause(steps)
425
+ if ok and not warned_steps(steps):
426
+ print(f"ok: lanes {document['installed']} installed{was}; every step ran{outcomes} — {record_path(state)}",
427
+ file=out)
284
428
  elif ok:
285
- print(f"ok: lanes {document['installed']} installed{was}; every step ran{warnings} — log {log}", file=out)
429
+ print(f"ok: lanes {document['installed']} installed{was}; every step ran{warnings}{outcomes} — log {log}",
430
+ file=out)
286
431
  else:
287
432
  print(f"partial: lanes {document['installed']} installed{was}; {count(failed, 'step')} failed: "
288
- f"{', '.join(failed)}{warnings} — log {log}", file=out)
433
+ f"{', '.join(failed)}{warnings}{outcomes} — log {log}", file=out)
289
434
  return document
290
435
 
291
436
 
@@ -850,8 +995,8 @@ def update_main(args, key, out=None, environ=None, home=None, clock=None, output
850
995
 
851
996
  def timer_enabled(units):
852
997
  """Whether lanes-update.timer is enabled in one of the unit folders `units`: `<folder>/timers.target.wants/
853
- lanes-update.timer`, the link `systemctl --user enable` makes, names an existing unit. A file read, as every other
854
- doctor line is (doctor never asks systemctl)."""
998
+ lanes-update.timer`, the link `systemctl --user enable` makes, names an existing unit. A file read: this line
999
+ never asks systemctl (the web and docs lines do, one bounded `systemctl --user show` each)."""
855
1000
  return any((Path(folder) / TIMER_WANTS / TIMER).exists() for folder in units)
856
1001
 
857
1002