@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.
- package/package.json +1 -1
- package/payload/lanes/README.md +7 -5
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +1091 -76
- 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 +55 -8
- package/payload/lanes/container/lane-mcp +120 -22
- package/payload/lanes/container/lane-signal +86 -23
- package/payload/lanes/container/lane_core.py +524 -36
- package/payload/lanes/container/throttle-pulse +456 -0
- package/payload/lanes/docs/ARCHITECTURE.md +357 -34
- package/payload/lanes/docs/DESIGN.md +13 -3
- package/payload/lanes/docs/INSTALL.md +47 -10
- package/payload/lanes/docs/RUNBOOK.md +761 -12
- package/payload/lanes/docs/USERGUIDE.md +421 -46
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/agent_state.py +604 -12
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -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/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +626 -25
- package/payload/lanes/libexec/statusd.py +530 -32
- package/payload/lanes/libexec/tokens.py +42 -9
- package/payload/lanes/libexec/update.py +171 -26
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +31 -3
- package/payload/lanes/site/site.example.conf +4 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +5 -3
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""The Lanes design tokens: ONE table, rendered
|
|
3
|
-
none); `--kdl`, `--css`, `--sh`, `--md`, `--js
|
|
4
|
-
CSS, the viewer's shell colour sets, the docs tables, the phone's terminal palette
|
|
5
|
-
files between markers; `--check` fails when any committed block
|
|
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-
|
|
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
|
|
24
|
-
`partial` the release is installed but a step after the commit failed (also while the post-commit
|
|
25
|
-
before the first step); `refused` a prerequisite, no installed release, or another install holding
|
|
26
|
-
said no and nothing changed; `failed` nothing was installed. A warning (WARN_RC) is a step's own
|
|
27
|
-
the update neither caused nor can mend — `lanes sync` exiting 1 for a board with an error
|
|
28
|
-
|
|
29
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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)
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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}",
|
|
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
|
|
854
|
-
|
|
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
|
|