@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.
Files changed (71) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. 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 `agent_state.merge`'s
240
- verdict for the session's pane: published as the additive `agent` key, absent when no signal names an agent."""
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
- AS.merge(pane, claude.get(container, {}),
590
- sidecars.get(container, {}).get(entry['id'])))
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
- AS.merge(pane, claude.get(container, {}),
610
- sidecars.get(container, {}).get(lane['id'])))
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
- return {'lanes': lanes, 'servers': servers, 'unregistered': unregistered, 'zmx': zmx_groups,
900
- 'containers': {k: {'running': v.get('running', False)} for k, v in containers.items()}}
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 10 s to avoid thrashing.
1025
- if time.time() - last_triggered_sync > 10 and ctx['registry']:
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 for this checkout (services.py), then enable the Host and its retry timer."""
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():