@vibelord/lanes 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -23
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2753 -202
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +147 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1150 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1318 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +793 -16
- package/payload/lanes/docs/DESIGN.md +127 -46
- package/payload/lanes/docs/INSTALL.md +175 -18
- package/payload/lanes/docs/RUNBOOK.md +968 -76
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +967 -150
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +197 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +21 -2
- package/payload/lanes/libexec/notify.py +169 -29
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2232 -365
- package/payload/lanes/libexec/statusd.py +231 -12
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +148 -35
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +1044 -0
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +165 -28
- package/payload/lanes/web/browser.js +4 -8
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +45 -32
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +6 -3
|
@@ -6,16 +6,22 @@ dead sockets are never probed), run one read-only `list-panes -a` per live serve
|
|
|
6
6
|
at most PROBE_WORKERS at once, the whole round cut at PROBE_TIMEOUT + PROBE_GRACE seconds, the servers the last
|
|
7
7
|
round cut started first — and publish ~/.local/state/lanes/status.json
|
|
8
8
|
atomically, plus the one-line ~/.local/state/lanes/health. The sidebar, `lanes status`, `lanes doctor` and the
|
|
9
|
-
future Throttle web harness all read those files.
|
|
9
|
+
future Throttle web harness all read those files. Each workspace container that has the lane contract also gets its
|
|
10
|
+
own slice as ~/.lz/status.json (LNS-32, `write_container_status`), for readers inside it.
|
|
10
11
|
|
|
11
12
|
It never INVENTS a state. "No answer" (a tmux, cgroup, docker or zmx read that timed out or failed on an overloaded
|
|
12
13
|
host) is not an observation: the lane keeps its last published state, marked stale, for at most STALE_MAX seconds
|
|
13
14
|
and is 'unknown' after that. 'absent' / 'missing' are only published when a server answered without the session,
|
|
14
15
|
or positively does not exist.
|
|
16
|
+
|
|
17
|
+
The docs cockpit (plan W10 W7): a lane row carries `docs: {n, at}` while docs/counts.json counts documents for it
|
|
18
|
+
(one stat of that file per cycle), and the document `web_origin`, the https origin the docs page answers on, once
|
|
19
|
+
main() has learnt it between two cycles (refresh_origin).
|
|
15
20
|
"""
|
|
16
21
|
import concurrent.futures
|
|
17
22
|
import json
|
|
18
23
|
import os
|
|
24
|
+
import re
|
|
19
25
|
import subprocess
|
|
20
26
|
import sys
|
|
21
27
|
import time
|
|
@@ -26,6 +32,7 @@ import lanes_lib as L # noqa: E402
|
|
|
26
32
|
import session_actions as A # noqa: E402
|
|
27
33
|
import agent_state as AS # noqa: E402
|
|
28
34
|
import guard as G # noqa: E402
|
|
35
|
+
import docs_store # noqa: E402
|
|
29
36
|
|
|
30
37
|
INTERVAL = float(os.environ.get('LANES_STATUS_INTERVAL', '3'))
|
|
31
38
|
SLOWDOWN = {'ok': 1, 'busy': 2, 'critical': 4} # a host under pressure gets FEWER spawns from us, not the same
|
|
@@ -39,6 +46,7 @@ PROBE_WORKERS = 8
|
|
|
39
46
|
PROBE_GRACE = 2
|
|
40
47
|
CONTAINER_REFRESH = 300 # docker is asked again this often as a safety net; liveness itself comes from /proc/<pid>
|
|
41
48
|
CONTAINER_RETRY = 30 # ... and at most this often while a container is down or docker did not answer
|
|
49
|
+
HOT_SYNC_EVERY_S = 10 # hot_sync starts at most one `lanes sync` per this many seconds (the sidebar's PENDING_S)
|
|
42
50
|
ACTIVE_WINDOW = 20 # seconds since last output for a lane to count as active
|
|
43
51
|
STALE_MAX = 300 # a lane nobody could observe for this long is published as 'unknown', no longer as last seen
|
|
44
52
|
FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_current_command',
|
|
@@ -54,6 +62,16 @@ FIELDS = ['session_name', 'pane_pid', 'pane_dead', 'window_activity', 'pane_curr
|
|
|
54
62
|
'window_bell_flag', 'window_id', 'pane_id', 'pane_title']
|
|
55
63
|
FORMAT = '\t'.join('#{' + f + '}' for f in FIELDS)
|
|
56
64
|
VIEW_PREFIX = 'view-' # a Throttle Studio browser attach: a grouped session named view-<studio>-<ch>
|
|
65
|
+
# The docs cockpit (plan W10 W7, LNS-108). A lane row carries `docs: {n, at}` while docs/counts.json
|
|
66
|
+
# (DM-LANES-DOCS-COUNTS) holds documents for it: ONE stat of that file per cycle, the file read only when it moved.
|
|
67
|
+
# `web_origin` is the https origin Tailscale Serve answers on, the one the enrolment service announces (enrold
|
|
68
|
+
# site_facts' host name: site.conf's LANES_SERVER_NAME, else this node's MagicDNS name), with Serve's port: the sidebar
|
|
69
|
+
# builds its OSC 8 links to /lanes-docs/ on it and never forks to learn it. Resolved outside the cycle (main), at most
|
|
70
|
+
# every ORIGIN_TTL seconds — enrold's own cache of the name — and not again on a host under pressure once known.
|
|
71
|
+
WEB_PORT = 8443 # bin/lanes SERVE_PORT: `tailscale serve --https=8443` -> Caddy :8083 (web/Caddyfile)
|
|
72
|
+
ORIGIN_TTL = 300 # enrold.TAILNET_TTL
|
|
73
|
+
HOST_NAME = re.compile(r'(?=.{1,253}\Z)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?'
|
|
74
|
+
r'(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\Z', re.ASCII)
|
|
57
75
|
|
|
58
76
|
|
|
59
77
|
def log(where, error):
|
|
@@ -236,8 +254,9 @@ def keep(previous, lane_id, outcome):
|
|
|
236
254
|
def lane_entry(pane, now, activity=None, viewing=(), agent=None):
|
|
237
255
|
"""One live session's published row. `activity` is the NEWEST `window_activity` across the session's panes
|
|
238
256
|
(see `newest_activity`) and `viewing` the rows of the sessions that VIEW it (see `split_viewers`); both
|
|
239
|
-
default to "this pane alone", which is what a caller with a single row has. `agent` is
|
|
240
|
-
|
|
257
|
+
default to "this pane alone", which is what a caller with a single row has. `agent` is the lane's settled
|
|
258
|
+
agent{} (`settled`: `agent_state.merge`'s verdict debounced by `agent_state.settle`): published as the additive
|
|
259
|
+
`agent` key, absent when no signal names an agent."""
|
|
241
260
|
if activity is None:
|
|
242
261
|
try:
|
|
243
262
|
activity = int(pane['window_activity'])
|
|
@@ -267,6 +286,15 @@ def lane_entry(pane, now, activity=None, viewing=(), agent=None):
|
|
|
267
286
|
return entry
|
|
268
287
|
|
|
269
288
|
|
|
289
|
+
def settled(previous, lane_id, now, raw):
|
|
290
|
+
"""What a live lane publishes as its agent{} this cycle: `merge`'s verdict `raw` debounced against the agent{} the
|
|
291
|
+
PREVIOUS payload published for the same lane id (`agent_state.settle`, cockpit v3 S1). `previous` is the last
|
|
292
|
+
published payload — `new_context()` seeds it from status.json, so a restarted daemon keeps every lane's
|
|
293
|
+
`stable_since`. Called at both merge sites in `observe`: declared lanes and undeclared sessions alike."""
|
|
294
|
+
old = ((previous.get('lanes') or {}).get(lane_id) or {}) if isinstance(previous, dict) else {}
|
|
295
|
+
return AS.settle(old.get('agent') if isinstance(old, dict) else None, raw, now)
|
|
296
|
+
|
|
297
|
+
|
|
270
298
|
def agent_signals(containers, sessions_by_server):
|
|
271
299
|
"""The per-container inputs `agent_state.merge` needs, read ONCE per container per cycle through the
|
|
272
300
|
container's root as the host sees it (`/proc/<init pid>/root`), so liveness is judged in the container's pid
|
|
@@ -546,6 +574,11 @@ def observe(registry, containers, live, previous=None, report=None):
|
|
|
546
574
|
try:
|
|
547
575
|
pane = own_sessions[name]
|
|
548
576
|
old_ws, old = prior.get((container, socket, name), (None, None))
|
|
577
|
+
if old is None:
|
|
578
|
+
# Listed for the first time: maybe a session `registrations.unregister` just released. It had
|
|
579
|
+
# no listing to carry its id over (it was a lane), and its directory may name another board
|
|
580
|
+
# now — the hand-back keeps the board and id it was registered under (S7 fix, DEF-1826).
|
|
581
|
+
old_ws, old = A.handed_back(container, socket, name, pane)
|
|
549
582
|
board_ids = {w['id'] for w in registry['workspaces']}
|
|
550
583
|
workspace_id = A.workspace_for(registry, container, socket, pane['pane_current_path'],
|
|
551
584
|
owners.get((container, socket), set()),
|
|
@@ -586,8 +619,9 @@ def observe(registry, containers, live, previous=None, report=None):
|
|
|
586
619
|
if not L.TMUX_SESSION.match(name):
|
|
587
620
|
entry['note'] = 'session name outside the lane declaration grammar'
|
|
588
621
|
found = lane_entry(pane, now, activities.get(name), viewers.get(name, []),
|
|
589
|
-
|
|
590
|
-
|
|
622
|
+
settled(previous, entry['id'], now,
|
|
623
|
+
AS.merge(pane, claude.get(container, {}),
|
|
624
|
+
sidecars.get(container, {}).get(entry['id']))))
|
|
591
625
|
except Exception as error:
|
|
592
626
|
log(f'observe {container}/{socket}/{name}', error)
|
|
593
627
|
continue
|
|
@@ -606,8 +640,9 @@ def observe(registry, containers, live, previous=None, report=None):
|
|
|
606
640
|
else:
|
|
607
641
|
try:
|
|
608
642
|
lanes[lane['id']] = lane_entry(pane, now, activities.get(session), viewers.get(session, []),
|
|
609
|
-
|
|
610
|
-
|
|
643
|
+
settled(previous, lane['id'], now,
|
|
644
|
+
AS.merge(pane, claude.get(container, {}),
|
|
645
|
+
sidecars.get(container, {}).get(lane['id']))))
|
|
611
646
|
except Exception as error: # a torn row: the session is there, its details are not readable
|
|
612
647
|
log(f"observe {lane['id']}", error)
|
|
613
648
|
lanes[lane['id']] = keep(previous, lane['id'], 'error')
|
|
@@ -833,6 +868,68 @@ def update_containers(ctx, registry, level):
|
|
|
833
868
|
info['running'] = pid_alive(info.get('pid'))
|
|
834
869
|
|
|
835
870
|
|
|
871
|
+
def docs_counts(ctx):
|
|
872
|
+
"""docs/counts.json as the lane rows carry it -> {lane id: {n, at}} ({} when absent or not valid). ONE os.stat of
|
|
873
|
+
the file per cycle; it is read (docs_store.read_counts: no link followed, no FIFO opened, the shape checked) only
|
|
874
|
+
when its (mtime, inode, size) moved since the last read — docs_store replaces it on every change."""
|
|
875
|
+
try:
|
|
876
|
+
info = os.stat(docs_store.counts_path())
|
|
877
|
+
stamp = (info.st_mtime_ns, info.st_ino, info.st_size)
|
|
878
|
+
except OSError:
|
|
879
|
+
stamp = None
|
|
880
|
+
if stamp != ctx.get('docs_stamp') or 'docs_counts' not in ctx:
|
|
881
|
+
ctx['docs_counts'] = docs_store.read_counts() if stamp else {}
|
|
882
|
+
ctx['docs_stamp'] = stamp
|
|
883
|
+
return ctx['docs_counts']
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def with_docs(lanes, counts):
|
|
887
|
+
"""`lanes` with `docs: {n, at}` on every row counts.json gives documents (n > 0) and on no other row: absent at 0,
|
|
888
|
+
and dropped from a kept row whose lane has none any more. A row that changes is a NEW dict (DEF-1538: a kept row
|
|
889
|
+
may be the very object the published payload holds); the others are passed on as they are."""
|
|
890
|
+
out = {}
|
|
891
|
+
for lane_id, row in lanes.items():
|
|
892
|
+
entry = counts.get(lane_id) if isinstance(counts, dict) else None
|
|
893
|
+
want = {'n': entry['n'], 'at': entry['at']} if isinstance(entry, dict) and entry.get('n') else None
|
|
894
|
+
if not isinstance(row, dict) or row.get('docs') == want:
|
|
895
|
+
out[lane_id] = row
|
|
896
|
+
continue
|
|
897
|
+
row = {key: value for key, value in row.items() if key != 'docs'}
|
|
898
|
+
if want:
|
|
899
|
+
row['docs'] = want
|
|
900
|
+
out[lane_id] = row
|
|
901
|
+
return out
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def resolve_origin():
|
|
905
|
+
"""`https://<name>:8443` — the origin the docs page answers on (Tailscale Serve -> Caddy), by the name enrold
|
|
906
|
+
announces (site_facts: site.conf's LANES_SERVER_NAME, else enrold.tailnet_name(), one bounded `tailscale status
|
|
907
|
+
--json` cached for its TAILNET_TTL), or None when there is no name or it is no DNS host name: it is written into
|
|
908
|
+
terminal OSC strings and links, so nothing but `[A-Za-z0-9.-]` ever reaches them."""
|
|
909
|
+
import enrold # the enrolment service's own reader of the name; imported only when an origin is wanted
|
|
910
|
+
name = enrold.server_site().get('LANES_SERVER_NAME') or enrold.tailnet_name()
|
|
911
|
+
return f'https://{name}:{WEB_PORT}' if isinstance(name, str) and HOST_NAME.match(name) else None
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def refresh_origin(ctx, level, now=None):
|
|
915
|
+
"""main()'s look for `web_origin`, never inside a cycle: at most every ORIGIN_TTL seconds, and on a host that is
|
|
916
|
+
not `ok` only while none is known yet (one bounded spawn, then it waits). -> True when it looked. A failure keeps
|
|
917
|
+
the last origin: an unanswered name is no reason to drop the links."""
|
|
918
|
+
now = time.time() if now is None else now
|
|
919
|
+
known = ctx.get('origin_at') is not None
|
|
920
|
+
if known and (now - ctx['origin_at'] < ORIGIN_TTL or level != 'ok'):
|
|
921
|
+
return False
|
|
922
|
+
ctx['origin_at'] = now
|
|
923
|
+
try:
|
|
924
|
+
origin = resolve_origin()
|
|
925
|
+
except Exception as error:
|
|
926
|
+
log('web origin', error)
|
|
927
|
+
return True
|
|
928
|
+
if origin or not known:
|
|
929
|
+
ctx['web_origin'] = origin
|
|
930
|
+
return True
|
|
931
|
+
|
|
932
|
+
|
|
836
933
|
def build_payload(ctx, level, reconcile=True):
|
|
837
934
|
"""One observation pass -> the payload of status.json (None = there is no registry to observe). Every phase
|
|
838
935
|
fails alone: what it could not observe is republished as last seen (stale), the other phases still run.
|
|
@@ -896,8 +993,15 @@ def build_payload(ctx, level, reconcile=True):
|
|
|
896
993
|
# Outside the payload: it changes every cycle, and status.json is rewritten only on change (plus the heartbeat).
|
|
897
994
|
ctx['zmx_listed_at'] = listed
|
|
898
995
|
lanes.update(zmx_lanes)
|
|
899
|
-
|
|
900
|
-
|
|
996
|
+
try: # plan W10 W7: the docs counts ride on the lane rows (one stat of docs/counts.json per cycle)
|
|
997
|
+
lanes = with_docs(lanes, docs_counts(ctx))
|
|
998
|
+
except Exception as error:
|
|
999
|
+
log('docs', error)
|
|
1000
|
+
payload = {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
|
|
1001
|
+
'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
|
|
1002
|
+
if ctx.get('web_origin'): # additive, absent while no origin is known (refresh_origin, main())
|
|
1003
|
+
payload['web_origin'] = ctx['web_origin']
|
|
1004
|
+
return payload
|
|
901
1005
|
|
|
902
1006
|
|
|
903
1007
|
def new_context():
|
|
@@ -908,8 +1012,116 @@ def new_context():
|
|
|
908
1012
|
written = float(seed.get('updated_at') or 0)
|
|
909
1013
|
for entry in (seed.get('lanes') or {}).values():
|
|
910
1014
|
entry.setdefault('stale_since', int(written))
|
|
1015
|
+
# The docs links' origin survives a restart (re-checked by main()'s first refresh_origin); only a valid one.
|
|
1016
|
+
origin = seed.get('web_origin')
|
|
1017
|
+
valid = (isinstance(origin, str) and origin.startswith('https://') and origin.endswith(f':{WEB_PORT}')
|
|
1018
|
+
and HOST_NAME.match(origin[len('https://'):-len(f':{WEB_PORT}')]))
|
|
911
1019
|
return {'registry': None, 'stamp': 0, 'containers': {}, 'containers_at': 0.0,
|
|
912
|
-
'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': []
|
|
1020
|
+
'payload': seed, 'published': None, 'status_at': written, 'cut': 0, 'unwalkable': [],
|
|
1021
|
+
'container_status': {}, 'container_status_errors': {}, 'web_origin': origin if valid else None}
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
# ------------------------------------------- the in-container projection (LNS-32)
|
|
1025
|
+
# `~/.lz/status.json` in each workspace container: that container's slice of the payload, for the in-container Agent
|
|
1026
|
+
# API (`lane-mcp`, plan Track M), which cannot read the host's status.json. v1 = {v, at, lanes{<id>: {state, agent?,
|
|
1027
|
+
# attached?, viewers?, activity?, size?, stale?, docs?}}, unregistered[{id, name, workspace, boards, tmux, identity}]}.
|
|
1028
|
+
# `docs` (plan W10 W7, additive) is the host row's {n, at} verbatim, absent like there.
|
|
1029
|
+
CONTAINER_STATUS_FIELDS = ('state', 'agent', 'attached', 'viewers', 'activity', 'size', 'stale', 'docs')
|
|
1030
|
+
CONTAINER_STATUS_DIR = tuple(L.CONTAINER_HOME.strip('/').split('/')) + ('.lz',)
|
|
1031
|
+
CONTAINER_STATUS_FILE = 'status.json'
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def container_status(registry, payload):
|
|
1035
|
+
"""{container: {'lanes': …, 'unregistered': […]}} — what each workspace container may read about ITS OWN lanes
|
|
1036
|
+
(pure). `lanes` holds every lane the registry places in the container (declared and registered: the ids its
|
|
1037
|
+
`~/.lz/registry.json` holds) and every unregistered session statusd found there, each reduced to the fields a
|
|
1038
|
+
reader in the container needs; a row a cycle has not observed yet is `unknown`, never invented. The host-side
|
|
1039
|
+
details (cwd, pid, tty, command, where an unanswered probe failed) stay on the host, and `stale` is carried so a
|
|
1040
|
+
kept row is never read as a fresh one. A session listed under several boards is ONE `unregistered` row naming
|
|
1041
|
+
them all in `boards`. zmx lanes are not projected: the container's own `lane ls`/`lane status` read them from
|
|
1042
|
+
zmx and lane-run directly, and their `agent` is lane-run's phase record, not an `agent_state` verdict."""
|
|
1043
|
+
rows = (payload or {}).get('lanes') or {}
|
|
1044
|
+
ids = {}
|
|
1045
|
+
for workspace in (registry or {}).get('workspaces') or []:
|
|
1046
|
+
for lane in workspace.get('lanes') or []:
|
|
1047
|
+
if lane.get('container') and lane.get('id'):
|
|
1048
|
+
ids.setdefault(lane['container'], []).append(lane['id'])
|
|
1049
|
+
ghosts = {}
|
|
1050
|
+
for board, entries in sorted(((payload or {}).get('unregistered') or {}).items()):
|
|
1051
|
+
for entry in entries or []:
|
|
1052
|
+
if not isinstance(entry, dict) or not entry.get('container') or not entry.get('id'):
|
|
1053
|
+
continue
|
|
1054
|
+
ghost = ghosts.setdefault(entry['container'], {}).setdefault(entry['id'], {
|
|
1055
|
+
'id': entry['id'], 'name': entry.get('name'), 'workspace': entry.get('workspace') or board,
|
|
1056
|
+
'boards': [], 'tmux': entry.get('tmux'), 'identity': entry.get('identity')})
|
|
1057
|
+
if board not in ghost['boards']:
|
|
1058
|
+
ghost['boards'].append(board)
|
|
1059
|
+
out = {}
|
|
1060
|
+
for container in sorted(set(ids) | set(ghosts)):
|
|
1061
|
+
found = ghosts.get(container, {})
|
|
1062
|
+
lanes = {}
|
|
1063
|
+
for lane_id in ids.get(container, []) + sorted(found):
|
|
1064
|
+
row = rows.get(lane_id) if isinstance(rows.get(lane_id), dict) else {}
|
|
1065
|
+
lanes[lane_id] = {key: row[key] for key in CONTAINER_STATUS_FIELDS if key in row}
|
|
1066
|
+
lanes[lane_id].setdefault('state', 'unknown')
|
|
1067
|
+
out[container] = {'lanes': lanes, 'unregistered': [found[lane_id] for lane_id in sorted(found)]}
|
|
1068
|
+
return out
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def write_container_status(ctx, payload, now=None):
|
|
1072
|
+
"""Write `container_status` into each running workspace container that has the lane contract (`~/.lz/bin/lane`,
|
|
1073
|
+
the rule `lanes_lib.export_container_registries` follows) -> the containers whose file was written.
|
|
1074
|
+
|
|
1075
|
+
At most one small write per container per cycle, and none while that container's slice is unchanged: `at` is
|
|
1076
|
+
the time of the last write, renewed on a change or once HEARTBEAT has passed, so an unchanged slice renders the
|
|
1077
|
+
same bytes and `atomic_write_if_changed_at` skips it, while a reader can still tell a live statusd from a dead
|
|
1078
|
+
one by the age of `at`. Written through /proc/<init pid>/root like every other read of the container here, into
|
|
1079
|
+
a directory the container owns: `lanes_lib.open_real_dir` refuses a symlinked component and the write replaces
|
|
1080
|
+
whatever is at the name without following or reading it (a planted link or FIFO). A container without the
|
|
1081
|
+
contract is skipped silently; any other failure is logged once per distinct error and costs only that container."""
|
|
1082
|
+
now = time.time() if now is None else now
|
|
1083
|
+
last, errors = ctx.setdefault('container_status', {}), ctx.setdefault('container_status_errors', {})
|
|
1084
|
+
written = []
|
|
1085
|
+
slices = container_status(ctx.get('registry'), payload)
|
|
1086
|
+
for gone in set(last) - set(slices):
|
|
1087
|
+
del last[gone]
|
|
1088
|
+
for container, content in slices.items():
|
|
1089
|
+
info = (ctx.get('containers') or {}).get(container) or {}
|
|
1090
|
+
if not info.get('pid') or not info.get('running'):
|
|
1091
|
+
continue
|
|
1092
|
+
before = last.get(container)
|
|
1093
|
+
fresh = before and before['content'] == content and now - before['at'] <= HEARTBEAT
|
|
1094
|
+
at = before['at'] if fresh else now
|
|
1095
|
+
text = json.dumps({'v': 1, 'at': at, **content}, ensure_ascii=False, indent=1) + '\n'
|
|
1096
|
+
try:
|
|
1097
|
+
fd = L.open_real_dir(Path(L.proc_root()) / str(info['pid']) / 'root', CONTAINER_STATUS_DIR)
|
|
1098
|
+
except FileNotFoundError:
|
|
1099
|
+
continue # no ~/.lz: the lane contract is not installed there
|
|
1100
|
+
except (OSError, L.LanesError) as error:
|
|
1101
|
+
_container_status_error(errors, container, error)
|
|
1102
|
+
continue
|
|
1103
|
+
try:
|
|
1104
|
+
try:
|
|
1105
|
+
os.stat('bin/lane', dir_fd=fd)
|
|
1106
|
+
except FileNotFoundError:
|
|
1107
|
+
continue # a ~/.lz without `lane`: not installed either
|
|
1108
|
+
if L.atomic_write_if_changed_at(fd, CONTAINER_STATUS_FILE, text, mode=0o644):
|
|
1109
|
+
written.append(container)
|
|
1110
|
+
last[container] = {'content': content, 'at': at}
|
|
1111
|
+
errors.pop(container, None)
|
|
1112
|
+
except (OSError, L.LanesError) as error:
|
|
1113
|
+
_container_status_error(errors, container, error)
|
|
1114
|
+
finally:
|
|
1115
|
+
os.close(fd)
|
|
1116
|
+
return written
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _container_status_error(errors, container, error):
|
|
1120
|
+
"""Log a container's projection failure once per distinct error, not every INTERVAL."""
|
|
1121
|
+
message = f'{type(error).__name__}: {error}'
|
|
1122
|
+
if errors.get(container) != message:
|
|
1123
|
+
errors[container] = message
|
|
1124
|
+
log(f'container status {container}', error)
|
|
913
1125
|
|
|
914
1126
|
|
|
915
1127
|
def cycle(ctx, reconcile=True):
|
|
@@ -938,6 +1150,10 @@ def cycle(ctx, reconcile=True):
|
|
|
938
1150
|
ctx['published'], ctx['status_at'] = payload, written
|
|
939
1151
|
except Exception as error:
|
|
940
1152
|
log('status.json', error)
|
|
1153
|
+
try: # LNS-32: each container's own slice, into its ~/.lz (one small write per container at most)
|
|
1154
|
+
write_container_status(ctx, payload)
|
|
1155
|
+
except Exception as error:
|
|
1156
|
+
log('container status', error)
|
|
941
1157
|
try: # "servers alive, sockets deleted" as a flag file: view.sh, the sidebar and doctor read it without parsing
|
|
942
1158
|
lost = sorted(key for key, server in ((payload or {}).get('servers') or {}).items() if server.get('outcome') == 'nosocket')
|
|
943
1159
|
flag = L.STATE / 'flags' / 'sockets-lost'
|
|
@@ -1014,15 +1230,18 @@ def hot_sync(ctx, source_mtimes):
|
|
|
1014
1230
|
|
|
1015
1231
|
|
|
1016
1232
|
def main():
|
|
1233
|
+
L.record_tree('statusd') # W9 U0: the tree this daemon runs from (`lanes version`, doctor); --once never records
|
|
1017
1234
|
ctx = new_context()
|
|
1018
1235
|
last_triggered_sync = 0.0
|
|
1019
1236
|
source_mtimes = {} # host path of a terminals.json -> its mtime_ns at last check
|
|
1020
1237
|
while True:
|
|
1021
1238
|
started = time.time()
|
|
1022
1239
|
level, _payload = cycle(ctx)
|
|
1240
|
+
# The docs links' origin (plan W10 W7): a bounded look between two cycles, never inside one.
|
|
1241
|
+
refresh_origin(ctx, level)
|
|
1023
1242
|
# Hot-reload: check if any terminals.json changed since we last synced.
|
|
1024
|
-
# Rate-limited to at most one triggered sync per
|
|
1025
|
-
if time.time() - last_triggered_sync >
|
|
1243
|
+
# Rate-limited to at most one triggered sync per HOT_SYNC_EVERY_S to avoid thrashing.
|
|
1244
|
+
if time.time() - last_triggered_sync > HOT_SYNC_EVERY_S and ctx['registry']:
|
|
1026
1245
|
if hot_sync(ctx, source_mtimes):
|
|
1027
1246
|
last_triggered_sync = time.time()
|
|
1028
1247
|
ctx['children'] = [p for p in ctx.get('children', []) if p.poll() is None] # reap finished syncs
|
|
@@ -39,6 +39,9 @@ import tempfile
|
|
|
39
39
|
import time
|
|
40
40
|
from pathlib import Path
|
|
41
41
|
|
|
42
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
43
|
+
import lanes_lib as L # noqa: E402
|
|
44
|
+
|
|
42
45
|
UNIT = 'lanes-studio-host.service'
|
|
43
46
|
ENROL_SERVICE = 'lanes-studio-host-enrol.service'
|
|
44
47
|
ENROL_TIMER = 'lanes-studio-host-enrol.timer'
|
|
@@ -1040,7 +1043,10 @@ def _enrol(layout, options, plan, record, receipt, key_file):
|
|
|
1040
1043
|
|
|
1041
1044
|
|
|
1042
1045
|
def _units(layout, plan):
|
|
1043
|
-
"""Step 6: render every lanes unit
|
|
1046
|
+
"""Step 6: render every lanes unit (services.py), then enable the Host and its retry timer. A re-render, never an
|
|
1047
|
+
install: services.register(install=False) renders the release `current` names and moves no link, so this step run
|
|
1048
|
+
from a tree an update has moved past cannot put the old release back (DEF-1835); it links `current` to this tree
|
|
1049
|
+
only where it names none yet."""
|
|
1044
1050
|
repo = Path(__file__).resolve().parent.parent
|
|
1045
1051
|
|
|
1046
1052
|
def render():
|
|
@@ -1169,6 +1175,7 @@ def enable(argv, layout=None):
|
|
|
1169
1175
|
backup = _ensure_config(layout, options, plan)
|
|
1170
1176
|
if not options['dry_run']:
|
|
1171
1177
|
_remember_paths(layout, options, record)
|
|
1178
|
+
L.record_tree('studio_host', layout.state) # W9 U0: the enrol unit's tree, past every static refusal
|
|
1172
1179
|
replaced = [] # set once the hand-built unit is saved, disabled and stopped
|
|
1173
1180
|
|
|
1174
1181
|
def give_back():
|