@vibelord/lanes 1.0.1 → 1.0.3

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 (52) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +37 -7
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +664 -50
  5. package/payload/lanes/client/ghostty/common.conf +3 -0
  6. package/payload/lanes/client/ghostty/lanes-palette-light.conf +25 -0
  7. package/payload/lanes/client/ghostty/lanes-palette.conf +25 -0
  8. package/payload/lanes/client/ghostty/omarchy.conf +3 -2
  9. package/payload/lanes/client/lanes-launch +18 -3
  10. package/payload/lanes/client/lanes-window +17 -1
  11. package/payload/lanes/container/env.sh +1 -1
  12. package/payload/lanes/container/hooks/claude-settings.json +10 -0
  13. package/payload/lanes/container/hooks/codex-hooks.json +22 -0
  14. package/payload/lanes/container/lane +310 -26
  15. package/payload/lanes/container/lane-signal +173 -0
  16. package/payload/lanes/docs/ARCHITECTURE.md +87 -13
  17. package/payload/lanes/docs/DESIGN.md +182 -4
  18. package/payload/lanes/docs/INSTALL.md +37 -2
  19. package/payload/lanes/docs/RUNBOOK.md +458 -39
  20. package/payload/lanes/docs/SHARING.md +240 -5
  21. package/payload/lanes/holders/exec.sh +2 -5
  22. package/payload/lanes/holders.lock +13 -0
  23. package/payload/lanes/install/enable-studio-host.sh +14 -0
  24. package/payload/lanes/install/host-install.sh +15 -3
  25. package/payload/lanes/install/services.py +128 -4
  26. package/payload/lanes/install.sh +108 -3
  27. package/payload/lanes/libexec/agent_state.py +32 -0
  28. package/payload/lanes/libexec/cursor_sync.py +10 -2
  29. package/payload/lanes/libexec/economy_poll.py +685 -0
  30. package/payload/lanes/libexec/guard.py +27 -1
  31. package/payload/lanes/libexec/lanes_lib.py +285 -38
  32. package/payload/lanes/libexec/notify.py +317 -0
  33. package/payload/lanes/libexec/registrations.py +1 -1
  34. package/payload/lanes/libexec/session_actions.py +7 -8
  35. package/payload/lanes/libexec/sidebar.py +911 -218
  36. package/payload/lanes/libexec/statusd.py +258 -74
  37. package/payload/lanes/libexec/studio_host.py +1608 -0
  38. package/payload/lanes/libexec/tokens.py +38 -7
  39. package/payload/lanes/libexec/view.sh +13 -3
  40. package/payload/lanes/systemd/lanes-economy.service +20 -0
  41. package/payload/lanes/systemd/lanes-notify.service +19 -0
  42. package/payload/lanes/systemd/lanes-studio-host-enrol.service +11 -0
  43. package/payload/lanes/systemd/lanes-studio-host-enrol.timer +10 -0
  44. package/payload/lanes/systemd/lanes-studio-host.service +37 -0
  45. package/payload/lanes/systemd/lanes-sync.service +3 -0
  46. package/payload/lanes/web/README.md +83 -4
  47. package/payload/lanes/web/index.html +2 -1
  48. package/payload/lanes/web/mobile.css +53 -198
  49. package/payload/lanes/web/mobile.js +87 -55
  50. package/payload/lanes/zellij/config.kdl +67 -16
  51. package/payload/manifest.json +5 -3
  52. package/payload/lanes/libexec/legacy_sessions.py +0 -130
@@ -30,9 +30,10 @@ An existing in-container Studio Host reads `~/.lz/registry.json` by default.
30
30
  That is the registered-lane projection written by `lanes sync`. No additional
31
31
  configuration is needed for this source.
32
32
 
33
- For a Lanes server hosting several workspace containers, run Studio Host on
34
- that server and add these settings to its
35
- `~/.config/throttle-rc/studio-host.toml`:
33
+ For a Lanes server hosting several workspace containers, prefer the installer-owned
34
+ [co-located Studio Host](#co-located-studio-host-one-host-per-lanes-server) below. Hand-run, the server's
35
+ `~/.config/throttle-rc/studio-host.toml` reads (add `mode = "lanes-server"` under `[inventory]` for the
36
+ interactive adapter; without it the Host serves the read-only sharing capture only):
36
37
 
37
38
  ```toml
38
39
  server = "https://throttle.example.com"
@@ -72,6 +73,236 @@ eight-hour Host session. If you deployed that candidate, use the
72
73
  before deploying current builds. The normal migrator correctly refuses that
73
74
  journal; do not edit its digest or reset the database.
74
75
 
76
+ ## Co-located Studio Host (one Host per lanes server)
77
+
78
+ On a lanes server — the account that runs `lanes-statusd` and reaches every workspace container's tmux sockets
79
+ through `/proc/<container-pid>/root/tmp/tmux-<uid>/` — ONE Studio Host serves every board, installed and supervised
80
+ by the lanes installer as `lanes-studio-host.service` (design: `docs/design/throttle-studio.md` §8.10). It replaces
81
+ one Host per workspace container and the hand-built `lanes-sharing-host.service`. Laptops keep the in-container Host
82
+ and `throttle-rc studio service`; never run both on one machine (the enable step refuses).
83
+
84
+ **Status of the pieces.** Assembled on the integrated tree (2026-10-04): the installer below
85
+ (`tests/unit/test_studio_host.py`), the Host's interactive `lanes-server` adapter, `studio publish-key` /
86
+ `adopt-receipt` and the platform's `server studio-adopt-host`. Only a `throttle-rc` built before 2026-10-04 refuses
87
+ those verbs `not_assembled`, and the enable step then stops with exit 1 before enabling anything. A live lanes
88
+ server and a live deploy are operator-gated; see "Deploy-provisioned enrolment" for the handoff's precondition.
89
+
90
+ ### 1. Install the binary
91
+
92
+ No `node/<sha12>` release that carries the co-location CLI is pinned yet, so install a locally built binary. It is
93
+ hashed, copied to `~/.local/share/lanes/bin/throttle-rc-local-<sha12>`, re-hashed, and selected through the link
94
+ `~/.local/share/lanes/bin/throttle-rc` that the unit runs; `throttle-rc.pin.json` beside it records the digest:
95
+
96
+ ```sh
97
+ cargo build --release --locked -p throttle-rc # on a build machine; copy the file to the server
98
+ lanes studio-host install --from ./throttle-rc --sha256 <the sha256 you computed where you built it>
99
+ ```
100
+
101
+ Once a release is tagged, its line goes into `holders.lock` as an `opt-in` pin (the file's comment has the
102
+ template) and `lanes studio-host install` with no `--from` downloads it — `gh release download` for the private
103
+ repository, else `curl` — and refuses unless BOTH the tarball and the binary inside it match their digests.
104
+ `./install.sh --server` never downloads an opt-in pin. Nothing is restarted by `install`.
105
+
106
+ ### 2. Enable
107
+
108
+ ```sh
109
+ sh install/enable-studio-host.sh --server https://throttle.example.com --dry-run # the plan; changes nothing
110
+ sh install/enable-studio-host.sh --server https://throttle.example.com
111
+ ```
112
+
113
+ In order, and refusing (exit 3) before anything changes when another Host unit is enabled or running
114
+ (`another_host_unit`), the hand-built unit exists (`hand_built_unit_present`, below), no binary is installed
115
+ (`no_binary`) or an existing config is not in lanes-server mode (`config_mode`, fixed by `--set-mode`):
116
+
117
+ 1. writes `~/.config/throttle-rc/studio-host.toml` (0600) in lanes-server mode if there is none —
118
+ `[inventory] mode = "lanes-server"`, `interactive = true`, `sharing_state_dir = $LANES_STATE`,
119
+ `sharing_container_uid = 1000` (`--container-uid`), `machine_id` from `--machine-id` or `<hostname>-lanes`.
120
+ An existing file is never rewritten except, with `--set-mode`, its one `mode` key (a `.bak-<time>` is kept);
121
+ 2. runs `throttle-rc studio publish-key --out ~/.local/state/lanes/studio-host/public.jwk` — the machine key is
122
+ created HERE if absent and only its PUBLIC half (0644) is written for the deploy;
123
+ 3. enrols, if it can: a deploy receipt at `~/.local/state/lanes/studio-host/receipt.json` (`--receipt PATH`, which is
124
+ remembered in the enrolment record so the enrol timer and `status` watch that path too) is installed
125
+ with `throttle-rc studio adopt-receipt --config <the config>`, which refuses a receipt issued for another key
126
+ (`receipt_key_mismatch`) or by another server URL (`receipt_server_mismatch`); else a registration key
127
+ file at `~/.config/throttle-rc/registration.key` (`--registration-key-file`, 0600, remembered like `--receipt`) is
128
+ handed to `throttle-rc studio enroll <server> --registration-key-file` — the script names the file and never opens
129
+ it (its readability is access(2) and the timer's memory is the file's stat identity, never a digest of the secret;
130
+ review round 5). A refused or failed receipt/key does not stop the next step: the units are still rendered and enabled, and the
131
+ run exits with the refusal (3, or 1 for an operational failure) saying the unit is waiting;
132
+ 4. renders the lanes units (`install/services.py`), enables `lanes-studio-host.service` and
133
+ `lanes-studio-host-enrol.timer`;
134
+ 5. enrolled: runs `throttle-rc studio host --check`, then starts the Host (restarts it when it was running, so a new
135
+ config or binary is in force) and exits 0 only when it is still running `--settle` seconds (default 5) later.
136
+ Not enrolled (and nothing refused): exits 0 **waiting for enrolment** — the unit is enabled and exits 3 (a clean
137
+ stop, never a restart loop) until a receipt or key arrives.
138
+
139
+ `lanes-studio-host-enrol.timer` runs `enable-studio-host.sh --retry` every 5 minutes. It acts only on something
140
+ new — a receipt it has not adopted and has not refused (typed, exit 3) within the last hour under the same config, a
141
+ key file not tried in the last hour (a chmod/chown of it counts as new), a machine key that is gone (it publishes a
142
+ new one — over an existing `credential.json` only as described below), an enrolment the Host has not been started with — so a Host that stopped on a revoked enrolment is not restarted into the same refusal. The
143
+ hour applies to the timer only: a manual run of the script always tries the registration key and always tries a
144
+ refused receipt — enrolled or not (review round 4: an enrolled machine used to leave a refused NEW receipt alone for
145
+ ever). A receipt refused with `receipt_server_mismatch` is tried again by the timer as soon as `studio-host.toml`
146
+ changes (the deploy re-emits the SAME bytes, so fixing the Host's `server` is enough); an operational failure of a
147
+ RECEIPT (exit 1/2: a read-only disk, an unloadable config, a timeout) is never remembered. A registration key is
148
+ remembered for the hour after ANY attempt (each try may spend one of its uses), with what `studio enroll` answered:
149
+ `status` says `key-refused` (code, exit, time, next retry) instead of `waiting-for-enrolment`, and a refused key
150
+ SOURCE (`registration_key_source_refused`, the binary's exit 2) is the installer's typed exit 3. A new receipt for
151
+ THIS machine's key is adopted over an existing `credential.json` for the same key (a relabel, a new host row after a
152
+ platform reset); `adopt-receipt` refuses one that would replace a receipt issued for ANOTHER key
153
+ (`receipt_replaces_enrolment`) unless `--new-key` says so, and refuses a receipt for an enrolment this machine
154
+ MEASURED revoked (`receipt_for_revoked_key`). `status` says `receipt-refused` for a refused receipt on disk whether
155
+ or not an older credential exists. A `credential.json` whose machine key is gone, or is not the key this machine
156
+ published, is an enrolment only when it binds the published key (`status --json` `key_binding`). The installer
157
+ re-keys over it — publishes a new key, then `adopt-receipt --new-key` / `studio enroll --new-key` — ONLY when this
158
+ machine MEASURED that host row revoked (the Host's `state.json`); otherwise it refuses `credential_for_gone_key`,
159
+ publishes nothing, and `status` says `credential-for-gone-key`: revoke the row (console, or `throttle-rc studio revoke
160
+ --id <host>`), then `lanes studio-host re-key --yes` — the explicit decision to enrol this machine under a new key
161
+ (review round 6: the installer used to decide that itself). A receipt, key or config this account cannot read is a typed refusal
162
+ (`receipt_unreadable`/`key_unreadable`/`config_unreadable`, exit 3) naming the file's owner and mode — never a
163
+ traceback; `status` says `receipt-unreadable`/`key-unreadable`/`config-unreadable`. Both studio units ALWAYS pin `XDG_CONFIG_HOME`/`XDG_DATA_HOME` — the roots this enable step ran with
164
+ (recorded in `~/.local/state/lanes/studio-host/xdg-roots.json`; a later `./install.sh --server` renders the studio
165
+ units from that record, never from its own shell, and warns when they differ), else `%h/.config` and
166
+ `%h/.local/share` — and every `throttle-rc` child the installer runs gets the same two, so neither the user manager's
167
+ environment nor a reinstall from another shell points the supervised Host at another `credential.json`. A manual run
168
+ on an enrolled machine whose `studio host --check` is red ONLY on the refusal the Host's own last run recorded (the
169
+ owner's tunnel cap, a build mismatch fixed by `lanes studio-host install`, three displacements) starts the Host
170
+ anyway — its first phase replaces that record, and the settle window reports a refusal that still holds. The unit files are
171
+ written where the user MANAGER reads them: `$XDG_CONFIG_HOME/systemd/user` when `systemctl --user show-environment`
172
+ carries it, else `~/.config/systemd/user`. The adopt refusal's code is read from `throttle-rc studio adopt-receipt`'s
173
+ own line (`throttle-rc studio adopt-receipt: refused <code>: …`); a line with no code is remembered as `unknown`.
174
+
175
+ ### Deploy-provisioned enrolment (no human step)
176
+
177
+ The platform deploy binds the published PUBLIC key to its owner (`throttle-rc server studio-adopt-host`, design
178
+ §8.10 C4) and writes the receipt where this server finds it:
179
+
180
+ ```sh
181
+ THROTTLE_RC_STUDIO_ADOPT_JWK=/home/dev/.local/state/lanes/studio-host/public.jwk
182
+ THROTTLE_RC_STUDIO_ADOPT_OWNER=owner@example.com
183
+ THROTTLE_RC_STUDIO_ADOPT_RECEIPT=/home/dev/.local/state/lanes/studio-host/receipt.json
184
+ ```
185
+
186
+ The step is `deploy/studio-adopt-host.sh`, sourced by `deploy/deploy-ceobox-rc.sh`; a receipt path whose directory is
187
+ absent or not writable is refused `receipt_dir_unusable` (exit 2) before the platform is asked.
188
+
189
+ Run the deploy **as the lanes account**: `~/.local/state/lanes/studio-host/` is kept at 0700 and the deploy writes
190
+ the receipt 0600 as whoever ran it, so a deploy run by another account cannot read `public.jwk` and leaves a receipt
191
+ the Host cannot read (DEF-1429 in `docs/defect-ledger.md`; a group-readable handoff directory is the unbuilt
192
+ alternative). `--receipt PATH` points the installer at another path the lanes account can read; it is remembered,
193
+ so the enrol timer adopts a receipt that appears there later and `status` reports it (naming the default path again
194
+ forgets it). The receipt carries no secret.
195
+
196
+ **Precondition — one filesystem (DEF-1441).** The handoff is a file exchange, so the deploy must run where both
197
+ paths above exist: on the docker host, as the lanes account, from a checkout of this repository (the compose build
198
+ context), with an env file that account can read named by `THROTTLE_RC_ENV_FILE` — the script's default is the
199
+ literal `/home/coder/.config/throttle-rc/ceobox.env`, which a lanes account named anything else does not have:
200
+
201
+ ```sh
202
+ THROTTLE_RC_ENV_FILE=~/.config/throttle-rc/ceobox.env bash deploy/deploy-ceobox-rc.sh # as the lanes account
203
+ ```
204
+
205
+ Copying that env file (it holds the database password) to the lanes account is a secret-custody step the operator
206
+ owns. On ceobox the deploy is normally run from the Coder workspace container, which has neither `/home/dev` nor the
207
+ lanes account, so the zero-step path above does NOT execute there as-is (`[INFERRED-NOT-EXECUTABLE]`, design §8.10
208
+ C4 "Topology precondition"); until one of those two is arranged, use the registration-key path. An owner who has not signed in yet makes the adopt exit 3 and the deploy
209
+ retries next time; the timer picks the receipt up within 5 minutes of it appearing. `lanes studio-host status` then
210
+ says `enrolment: via deploy`.
211
+
212
+ ### After the owner revokes this machine
213
+
214
+ A revoked key is never adopted again: the Host stops (exit 3) and later deploys WARN `host_revoked` and finish. Give
215
+ the machine a new key:
216
+
217
+ ```sh
218
+ lanes studio-host re-key # the plan; changes nothing (exit 2)
219
+ lanes studio-host re-key --yes # stop the Host; remove the machine key, credential.json, public.jwk, receipt.json
220
+ # and the Host's refused state; publish a NEW public key
221
+ ```
222
+
223
+ Then deploy again: the deploy adopts the new key, and the timer installs the receipt and starts the Host. A new key
224
+ is a new machine in the console (design §8.7 "What a new key costs"); any `throttle-rc` login session on this
225
+ account was bound to the old key and must log in again.
226
+
227
+ ### Check it
228
+
229
+ ```sh
230
+ lanes studio-host status # state: running | running-unenrolled | waiting-for-enrolment | receipt-refused | key-refused | credential-for-gone-key | receipt-unreadable | key-unreadable | config-unreadable | not-enabled | stopped | failed | conflict | not-installed | unknown
231
+ lanes studio-host status --check # also runs `throttle-rc studio host --check`; its exit binds (below)
232
+ journalctl --user -u lanes-studio-host.service -n 30
233
+ journalctl --user -u lanes-studio-host-enrol.service -n 20 # the enrol timer's one line per tick
234
+ ```
235
+
236
+ `waiting-for-enrolment` is said only while the Host unit is enabled AND the enrol timer is active; otherwise the
237
+ machine is `not-enabled` (nothing retries: re-run the enable script). `receipt-refused` names a receipt on disk the
238
+ installer recorded as refused (its code, when, and when the timer tries it again — an hour, or at once after
239
+ `studio-host.toml` changes); its remedy is the code's (`receipt_server_mismatch`: the config's `server`;
240
+ `receipt_key_mismatch`: redeploy with this machine's public key), then `sh install/enable-studio-host.sh` to try now.
241
+ `key-refused` is the registration key's twin (`studio enroll`'s code and exit; fix the file, its mode or the key, then
242
+ re-run the script — the timer also retries a changed file on its next tick). `credential-for-gone-key` is a receipt
243
+ on disk for a key this machine no longer holds, for a row it has not measured revoked: revoke it, then
244
+ `lanes studio-host re-key --yes`.
245
+
246
+ `status` re-hashes the selected binary against its record, names the config's mode, the enrolment's `via`, both
247
+ units and any other Host unit, and says `unknown` — never a guess — when the user manager does not answer. It exits
248
+ 0 only when the Host runs enrolled (an active unit with no enrolment is `running-unenrolled`, exit 1; with
249
+ `--check`, an active unit whose `studio host --check` is non-zero is `running-check-failed`, exit 1). A caller without
250
+ `XDG_RUNTIME_DIR` (a `sudo -u` or cron shell) reaches the manager at `/run/user/<uid>` when that exists, and
251
+ `re-key` refuses `manager_unreachable` rather than remove the key under a Host whose state the manager did not
252
+ answer, and `another_host_running` (exit 3, nothing changed) while `lanes-sharing-host.service` or
253
+ `throttle-rc-studio-host.service` is active: every Host unit of this account signs with the same machine key, so
254
+ re-keying under the hand-built Host would end its sessions. Migrate (or stop) that unit first. It refuses
255
+ `rekey_path_unremovable` (exit 3, nothing changed) when a file it must remove — e.g. a remembered `--receipt` in the
256
+ deploying account's directory — sits in a directory this account cannot write, and it removes the machine key LAST.
257
+ `status` also reports an installed unit that has lost a load-bearing line
258
+ (`SuccessExitStatus=3`, `KillSignal=SIGINT`, `KillMode=process`, the slice, the statusd ordering).
259
+
260
+ ### Migrating the hand-built `lanes-sharing-host.service`
261
+
262
+ ceobox's first Host was a hand-written unit running `~/.local/bin/throttle-rc-lanes studio host` with a sharing-only
263
+ config (no `mode`, so in-container). That Host predates enrolment receipts: it authenticated with a stored human
264
+ login SESSION beside its machine key, and holds NO `credential.json` (docs/handoffs/2026-09-22-lanes-sharing.md).
265
+ The lanes unit never uses a session, so such a machine is enrolled as part of the migration. Read the unit before
266
+ replacing it:
267
+
268
+ ```sh
269
+ systemctl --user cat lanes-sharing-host.service
270
+ lanes studio-host install --from ./throttle-rc --sha256 <digest>
271
+ sh install/enable-studio-host.sh --migrate-hand-built --dry-run
272
+ sh install/enable-studio-host.sh --migrate-hand-built
273
+ ```
274
+
275
+ `--migrate-hand-built` refuses a unit that does not run `studio host`, or that names a `--config` other than
276
+ `~/.config/throttle-rc/studio-host.toml` (`%h`, `${HOME}`, `$HOME` and `~` are resolved to the account's home before
277
+ the comparison). Otherwise it sets `mode = "lanes-server"` in the existing config (keeping a `.bak-<time>`), runs the
278
+ replacement's own `throttle-rc studio host --check` on that config (an enrolled machine), and only when it passes
279
+ saves the unit's text to `~/.local/state/lanes/studio-host/migrated/`, runs
280
+ `systemctl --user disable --now lanes-sharing-host.service`, and then starts the new unit. A refusing check
281
+ (`host_check_failed`; e.g. a version-1 credential whose key binding is `unchecked`) leaves the hand-built unit
282
+ running and enabled, restores the config from the `.bak`, and names both — as does ANY failure before the
283
+ hand-built unit is stopped (a failed `publish-key`, an unreadable receipt or key, a failed `disable --now`; review
284
+ round 5: its older binary refuses the `mode` key at its next start). A RUNNING hand-built unit on a machine with no
285
+ `credential.json` (ceobox's shape) is enrolled first, and the **published public key is the first artifact**:
286
+
287
+ 1. `sh install/enable-studio-host.sh --migrate-hand-built` with nothing to enrol from installs the binary, publishes
288
+ this machine's PUBLIC key to `~/.local/state/lanes/studio-host/public.jwk` (the existing machine key is loaded,
289
+ never rewritten; the hand-built Host, its unit and its config are untouched) and refuses
290
+ `hand_built_host_unenrolled` (exit 3), naming the `THROTTLE_RC_STUDIO_ADOPT_*` values for the deploy;
291
+ 2. the deploy adopts that key and writes the receipt to the receipt path (see the runbook's one-filesystem
292
+ precondition; a deploy that runs before step 1 defers — "not published yet" — and finishes) — or put the owner's
293
+ registration key in `~/.config/throttle-rc/registration.key` (0600) instead;
294
+ 3. re-run `sh install/enable-studio-host.sh --migrate-hand-built`: it installs the receipt (or enrols) FIRST, with the
295
+ same machine key, while the hand-built Host still runs, and only then checks, stops and replaces it (a refused
296
+ enrolment leaves it running with its config restored). The machine key (`~/.local/share/throttle-rc/machine/key.json`)
297
+ is never read or moved, and an existing enrolment receipt is kept: the new Host serves as the same machine (the same
298
+ key, so the same thumbprint and machine id once enrolled). The old unit file and the old binary are left for
299
+ you to delete. A later run treats the disabled, stopped unit as migrated while the enrolment record under
300
+ `~/.local/state/lanes/studio-host/` survives; if that record is lost, the enrol timer still never refuses on the
301
+ stopped, disabled unit, and a manual run asks for `--migrate-hand-built` again (idempotent: it saves the text again).
302
+
303
+ **Rollback:** `systemctl --user disable --now lanes-studio-host.service lanes-studio-host-enrol.timer`, restore the
304
+ config's `.bak-<time>`, then `systemctl --user enable --now lanes-sharing-host.service`.
305
+
75
306
  ## Enable publishers, then expose workspaces
76
307
 
77
308
  1. Sign in to Throttle and open **Shared lanes → Manage sharing**.
@@ -91,7 +322,7 @@ clears on its next refresh. Previously read output cannot be recalled.
91
322
  ## What viewers can see
92
323
 
93
324
  - The selected workspace's current and future **registered lanes**. Unregistered
94
- sessions, legacy shells, extra/tool tabs and unrelated workspaces are excluded.
325
+ sessions, tool tabs and unrelated workspaces are excluded.
95
326
  - Lane names and observed activity; “active” describes recent terminal output,
96
327
  not a build verdict.
97
328
  - For tmux lanes, a selectable pane and its latest 2,000 lines, capped at
@@ -112,7 +343,11 @@ interactive `/phone` endpoint.
112
343
 
113
344
  Focused regression coverage lives in the platform's `pg_http_studio` sharing
114
345
  tests, `pg_studio_schema`, the Host's `studio_host_sharing` real-tmux test, and
115
- `views/shared-lanes/view.test.mjs`. The Node tests run without an npm install:
346
+ `views/shared-lanes/view.test.mjs`. The co-located Host's installer pieces (the
347
+ rendered unit against `tests/unit/golden/lanes-studio-host.service`, the opt-in
348
+ pin, `lanes studio-host`, `install/enable-studio-host.sh` and the hand-built
349
+ migration) are `tests/unit/test_studio_host.py`, run with the other lanes unit
350
+ tests: `python3 -m unittest discover -s lanes/tests/unit`. The Node tests run without an npm install:
116
351
 
117
352
  ```sh
118
353
  node --test crates/throttle-platform/src/http/console/views/shared-lanes/view.test.mjs
@@ -1,13 +1,10 @@
1
1
  #!/bin/sh
2
2
  # View-owned displays; interactive shells are persistent tmux sessions created by New +.
3
- # usage: exec.sh <container> monitor|legacy [cwd-or-id]
3
+ # usage: exec.sh <container> monitor [cwd] (the `legacy` view of pre-persistence Docker shells was retired by
4
+ # plan Task 19: a leftover `exec … legacy` resolve file now ends as an unknown helper, and `lanes gc` removes it)
4
5
  set -u
5
6
  container=${1:-} kind=${2:-} cwd=${3:-/home/coder}
6
7
  case "$container" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid container' >&2; exit 64 ;; esac
7
- if [ "$kind" = legacy ]; then
8
- repo=$(cd "$(dirname "$0")/.." && pwd)
9
- exec python3 -S -E "$repo/libexec/legacy_sessions.py" view "$container" "$cwd"
10
- fi
11
8
  case "$cwd" in /*) ;; *) cwd=/home/coder ;; esac
12
9
  case "$cwd" in *[!A-Za-z0-9_./-]*) cwd=/home/coder ;; esac
13
10
  common="-it -u coder -e TERM=xterm-256color -e COLORTERM=truecolor -e LANG=C.UTF-8"
@@ -3,3 +3,16 @@
3
3
  # zellij 0.45.1: tarball digest matches GitHub's asset digest; inner binary d006c521…19dd matches the published .sha256sum.
4
4
  zellij 0.45.1 40bcc2e03f5d5ae8e054e39f676081fe12ab70871506996ba595834c3718eefc https://github.com/zellij-org/zellij/releases/download/v0.45.1/zellij-x86_64-unknown-linux-musl.tar.gz
5
5
  zmx 0.8.1 dfd75720b942466f28870731cc86dbc07afa72fb8f3bd5eeb4ff707e4eecebe8 https://github.com/neurosnap/zmx/releases/download/v0.8.1/zmx-0.8.1-linux-x86_64.tar.gz
6
+ # glow 3.0.0 (markdown preview, `lanes preview`): tarball digest matches GitHub's asset digest and the release's checksums.txt;
7
+ # the binary sits in glow_3.0.0_Linux_x86_64/. Installed only when no glow is on PATH; without either, the preview uses less -R.
8
+ glow 3.0.0 13e05e4b2acc18d2aee44291aefe6325b077ec321b631a0cfa780e8e3bc33f78 https://github.com/charmbracelet/glow/releases/download/v3.0.0/glow_3.0.0_Linux_x86_64.tar.gz
9
+ #
10
+ # OPT-IN pins carry three more fields and are skipped by host-install.sh; the step that needs one installs it:
11
+ # name version sha256(tarball) url opt-in member-inside-the-tarball sha256(member)
12
+ # throttle-rc is the co-located Studio Host (docs/SHARING.md "Co-located Studio Host"; `lanes studio-host install`
13
+ # downloads, checks BOTH digests and selects it). It comes from a `node/<sha12>` GitHub prerelease
14
+ # (docs/runbooks/node-release-asset.md): version = node-<sha12>, the asset sidecars give both digests. No
15
+ # node/<sha12> release containing the co-location CLI exists yet, so no line is active: until one is tagged, install
16
+ # a locally built binary with `lanes studio-host install --from PATH [--sha256 HEX]` (also sha256-verified).
17
+ # Template (fill from the release's two .sha256 sidecars, then uncomment):
18
+ # throttle-rc node-<sha12> <tarball-sha256> https://github.com/bwllaming/bw-tools/releases/download/node/<sha12>/throttle-rc-x86_64-unknown-linux-gnu.tar.gz opt-in throttle-rc-x86_64-unknown-linux-gnu/throttle-rc <binary-sha256>
@@ -0,0 +1,14 @@
1
+ #!/bin/sh
2
+ # Opt-in: run the Throttle Studio Host co-located on this lanes server (docs/SHARING.md "Co-located Studio Host").
3
+ # As the workspace account. Writes the Host config in lanes-server mode if absent, publishes this machine's PUBLIC
4
+ # key for the deploy, adopts a deploy receipt or enrols with a registration key file, and enables
5
+ # lanes-studio-host.service — or leaves it enabled and WAITING for enrolment (exit 0; exit 3 when a receipt or key
6
+ # was refused, the units still enabled so lanes-studio-host-enrol.timer retries).
7
+ # Never prints a key. --dry-run changes nothing. --help lists the options.
8
+ set -eu
9
+ repo=$(cd "$(dirname "$0")/.." && pwd)
10
+ case "$(uname -s)" in Linux) ;; *) echo 'The co-located Studio Host runs on a Linux lanes server.' >&2; exit 1 ;; esac
11
+ [ "$(id -u)" != 0 ] || { echo 'Run as the workspace account, not root.' >&2; exit 1; }
12
+ command -v python3 >/dev/null 2>&1 || { echo 'Missing prerequisite: python3 (see docs/INSTALL.md)' >&2; exit 1; }
13
+ export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
14
+ exec python3 "$repo/libexec/studio_host.py" enable "$@"
@@ -22,7 +22,7 @@ for command in python3 curl tar sha256sum infocmp tic install systemctl docker t
22
22
  command -v "$command" >/dev/null 2>&1 || { echo "Missing prerequisite: $command (see docs/INSTALL.md)" >&2; exit 1; }
23
23
  done
24
24
  python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else "Python 3.9+ is required")'
25
- export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
25
+ export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
26
26
  if [ "$start" = 1 ]; then
27
27
  systemctl --user show-environment >/dev/null
28
28
  python3 -c 'import subprocess; subprocess.run(["docker", "info", "--format", "{{.ServerVersion}}"], check=True, timeout=15)'
@@ -34,10 +34,18 @@ fi
34
34
  share=${LANES_SHARE:-$HOME/.local/share/lanes}
35
35
  state=${LANES_STATE:-$HOME/.local/state/lanes}
36
36
  mkdir -p "$share/bin" "$share/dl" "$state/flags"
37
- grep -v '^#' "$repo/holders.lock" | while read -r name version sha url; do
37
+ # The view server's PATH (bin/lanes clean_env): the Run pane that renders `lanes preview` sees this, never the
38
+ # installer's PATH, so a glow in ~/.local/bin or ~/go/bin does not count as installed.
39
+ pane_path=$share/bin:$repo/bin:/usr/local/bin:/usr/bin:/bin
40
+ grep -v '^#' "$repo/holders.lock" | while read -r name version sha url flag _rest; do
38
41
  [ -n "$name" ] || continue
42
+ # An `opt-in` pin (the co-located Studio Host's throttle-rc) is installed by the opt-in step that needs it
43
+ # (install/enable-studio-host.sh, `lanes studio-host install`), never by every server install.
44
+ [ "$flag" != opt-in ] || continue
39
45
  target=$share/bin/$name-$version
40
46
  if [ -x "$target" ]; then echo "ok $name $version"; continue; fi
47
+ # The markdown preview's renderer is optional: a glow the pane can find serves `lanes preview` as well.
48
+ if [ "$name" = glow ] && system_glow=$(PATH=$pane_path; command -v glow); then echo "ok glow (system: $system_glow)"; continue; fi
41
49
  archive=$share/dl/$(basename "$url")
42
50
  curl -fsSL --retry 3 -o "$archive" "$url"
43
51
  actual=$(sha256sum "$archive" | cut -d' ' -f1)
@@ -45,7 +53,11 @@ grep -v '^#' "$repo/holders.lock" | while read -r name version sha url; do
45
53
  temporary=$(mktemp -d)
46
54
  trap 'rm -rf "$temporary"' EXIT HUP INT TERM
47
55
  tar -xzf "$archive" -C "$temporary"
48
- install -m 0755 "$temporary/$name" "$target"
56
+ binary=$temporary/$name
57
+ # zellij and zmx ship the binary at the top of the tarball; glow nests it in glow_<version>_Linux_x86_64/.
58
+ [ -f "$binary" ] || binary=$(find "$temporary" -type f -name "$name" | head -n 1)
59
+ [ -n "$binary" ] && [ -f "$binary" ] || { echo "no $name binary in $archive" >&2; exit 1; }
60
+ install -m 0755 "$binary" "$target"
49
61
  rm -rf "$temporary"
50
62
  trap - EXIT HUP INT TERM
51
63
  echo "installed $target"
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env python3
2
2
  """Register this checkout's user services without starting or restarting them."""
3
+ import json
3
4
  import os
4
5
  import shutil
6
+ import sys
7
+ import subprocess
5
8
  from pathlib import Path
6
9
  import tempfile
7
10
 
@@ -14,8 +17,116 @@ def unit_value(value, command=False):
14
17
  return value.replace('$', '$$') if command else value
15
18
 
16
19
 
17
- def register(repo, home, share, state, workspaces=None):
20
+ # The opt-in economy poller's settings (libexec/economy_poll.py). Rendered into lanes-economy.service only, and only
21
+ # when set in the installer's environment; the unit itself is never enabled by an install (operator gate).
22
+ ECONOMY_SETTINGS = ('LANES_THROTTLE_CONTROL', 'LANES_THROTTLE_TOKEN_FILE', 'LANES_THROTTLE_LOG_DIR',
23
+ 'LANES_THROTTLE_CONTAINER')
24
+ ECONOMY_UNITS = ('lanes-economy.service',)
25
+ # The opt-in push notifier's config path (libexec/notify.py), rendered into lanes-notify.service only and only when
26
+ # set in the installer's environment (a config FILE path; the ntfy token stays in the file its token_file names).
27
+ NOTIFY_SETTINGS = ('LANES_NOTIFY_CONF',)
28
+ NOTIFY_UNITS = ('lanes-notify.service',)
29
+
30
+
31
+ def economy_settings(environ):
32
+ """The non-empty LANES_THROTTLE_* settings of `environ` (a token FILE path, never a token)."""
33
+ return {name: environ[name] for name in ECONOMY_SETTINGS if environ.get(name)}
34
+
35
+
36
+ def notify_settings(environ):
37
+ """The non-empty LANES_NOTIFY_* settings of `environ`."""
38
+ return {name: environ[name] for name in NOTIFY_SETTINGS if environ.get(name)}
39
+
40
+
41
+ def throttle_rc_path(share):
42
+ """The co-located Studio Host's binary: a stable link the opt-in installer points at a sha256-verified file
43
+ (`lanes studio-host install`, libexec/studio_host.py). Rendered whether or not it exists yet, like @CADDY@."""
44
+ return Path(share) / 'bin' / 'throttle-rc'
45
+
46
+
47
+ # Units that run throttle-rc (or the installer that resolves its paths): they must see the SAME XDG roots the
48
+ # installer used, or the config and credential it wrote are not where the supervised Host looks (review round 1).
49
+ # Review round 3: BOTH roots are pinned ALWAYS — the installer's own, else the defaults as systemd specifiers — never
50
+ # left to the user manager's environment block, which can carry roots the installing shell did not.
51
+ STUDIO_HOST_UNITS = ('lanes-studio-host.service', 'lanes-studio-host-enrol.service')
52
+ XDG_ROOTS = ('XDG_CONFIG_HOME', 'XDG_DATA_HOME')
53
+ XDG_DEFAULTS = {'XDG_CONFIG_HOME': '%h/.config', 'XDG_DATA_HOME': '%h/.local/share'}
54
+
55
+
56
+ # Review round 4: the roots the opt-in enable step (install/enable-studio-host.sh) rendered the studio units with,
57
+ # recorded beside its enrolment record. A later server install (re-run for every update, possibly from a shell that
58
+ # does not carry them, e.g. `sudo -u <account> sh` without -i) renders the studio units from THIS record, never from
59
+ # its own environment, so it cannot move the supervised Host away from the config and credential enable wrote.
60
+ XDG_RECORD = Path('studio-host') / 'xdg-roots.json'
61
+
62
+
63
+ def recorded_xdg(state):
64
+ """The roots enable recorded under $LANES_STATE, or None when it never ran (or the record is unreadable)."""
65
+ try:
66
+ doc = json.loads((Path(state) / XDG_RECORD).read_text())
67
+ except (OSError, ValueError):
68
+ return None
69
+ if not isinstance(doc, dict) or not isinstance(doc.get('roots'), dict):
70
+ return None
71
+ return {name: value for name, value in doc['roots'].items()
72
+ if name in XDG_ROOTS and isinstance(value, str) and value.startswith('/')}
73
+
74
+
75
+ def record_xdg(state, xdg):
76
+ """Record the roots enable renders the studio units with (an empty dict = the defaults)."""
77
+ path = Path(state) / XDG_RECORD
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ fd, temporary = tempfile.mkstemp(prefix='.xdg-roots-', dir=path.parent)
80
+ try:
81
+ with os.fdopen(fd, 'w') as handle:
82
+ json.dump({'schema': 'lanes.studio-host-xdg-roots', 'version': 1,
83
+ 'roots': {name: str(Path(xdg[name]).resolve()) for name in XDG_ROOTS if xdg.get(name)}},
84
+ handle, sort_keys=True)
85
+ handle.write('\n')
86
+ os.chmod(temporary, 0o600)
87
+ os.replace(temporary, path)
88
+ finally:
89
+ if os.path.exists(temporary):
90
+ os.unlink(temporary)
91
+
92
+
93
+ def studio_host_config(home, xdg):
94
+ """The Host's config path as libexec/studio_host.py's Layout resolves it: $XDG_CONFIG_HOME, else %h/.config
95
+ (left as the systemd specifier so the unit stays the golden when no XDG root is set)."""
96
+ if xdg.get('XDG_CONFIG_HOME'):
97
+ return unit_value(Path(xdg['XDG_CONFIG_HOME']) / 'throttle-rc/studio-host.toml', command=True)
98
+ return '%h/.config/throttle-rc/studio-host.toml'
99
+
100
+
101
+ def manager_unit_dir(home):
102
+ """Where the user MANAGER reads unit files (systemd.unit(5)): $XDG_CONFIG_HOME/systemd/user when the manager's own
103
+ environment sets it, else ~/.config/systemd/user. Asked of the manager, never of this shell (review round 2);
104
+ no answer keeps the default. libexec/studio_host.py's manager_units_dir is the same rule."""
105
+ default = Path(home) / '.config/systemd/user'
106
+ try:
107
+ result = subprocess.run(['systemctl', '--user', 'show-environment'], capture_output=True, text=True,
108
+ timeout=10, stdin=subprocess.DEVNULL)
109
+ except (OSError, subprocess.TimeoutExpired):
110
+ return default
111
+ if result.returncode == 0:
112
+ for line in result.stdout.splitlines():
113
+ if line.startswith('XDG_CONFIG_HOME=') and line.split('=', 1)[1].startswith('/'):
114
+ return Path(line.split('=', 1)[1]) / 'systemd/user'
115
+ return default
116
+
117
+
118
+ def register(repo, home, share, state, workspaces=None, economy=None, notify=None, xdg=None, units_dir=None):
18
119
  repo, home, share, state = (Path(p).resolve() for p in (repo, home, share, state))
120
+ if xdg is None:
121
+ ambient = {name: os.environ[name] for name in XDG_ROOTS if os.environ.get(name)}
122
+ xdg = recorded_xdg(state)
123
+ if xdg is None:
124
+ xdg = ambient
125
+ elif {k: str(Path(v).resolve()) for k, v in ambient.items()} != {k: str(Path(v).resolve()) for k, v in xdg.items()}:
126
+ print(f'services.py: the studio units keep the XDG roots install/enable-studio-host.sh recorded in '
127
+ f'{state / XDG_RECORD} ({xdg or "the defaults"}), not this shell\'s ({ambient or "none"}); re-run '
128
+ 'install/enable-studio-host.sh from the shell whose roots should hold', file=sys.stderr)
129
+ xdg = {name: str(Path(value).resolve()) for name, value in xdg.items() if name in XDG_ROOTS and value}
19
130
  current = home / '.local/share/lanes/current'
20
131
  links = ((current, repo), (home / '.local/bin/lanes', repo / 'bin/lanes'))
21
132
  for link, _ in links:
@@ -25,6 +136,13 @@ def register(repo, home, share, state, workspaces=None):
25
136
  if workspaces:
26
137
  settings.append(('LANES_WORKSPACES', Path(workspaces).resolve()))
27
138
  environment = ''.join(f'Environment="{name}={unit_value(value)}"\n' for name, value in settings)
139
+ economy_environment = ''.join(f'Environment="{name}={unit_value(value)}"\n'
140
+ for name, value in sorted((economy or {}).items()) if name in ECONOMY_SETTINGS)
141
+ notify_environment = ''.join(f'Environment="{name}={unit_value(value)}"\n'
142
+ for name, value in sorted((notify or {}).items()) if name in NOTIFY_SETTINGS)
143
+ studio_environment = environment + ''.join(
144
+ f'Environment="{name}={unit_value(xdg[name]) if name in xdg else XDG_DEFAULTS[name]}"\n'
145
+ for name in XDG_ROOTS)
28
146
  rendered = {}
29
147
  for source in sorted((repo / 'systemd').iterdir()):
30
148
  if source.name.startswith('.') or source.suffix not in ('.service', '.timer', '.slice') or not source.is_file():
@@ -32,10 +150,15 @@ def register(repo, home, share, state, workspaces=None):
32
150
  content = source.read_text().replace('@REPO@', unit_value(repo, command=True))
33
151
  content = content.replace('@SHARE@', unit_value(share, command=True))
34
152
  content = content.replace('@CADDY@', unit_value(shutil.which('caddy') or '/usr/bin/caddy', command=True))
153
+ content = content.replace('@THROTTLE_RC@', unit_value(throttle_rc_path(share), command=True))
154
+ content = content.replace('@STUDIO_HOST_CONFIG@', studio_host_config(home, xdg))
35
155
  if '[Service]\n' in content:
36
- content = content.replace('[Service]\n', '[Service]\n' + environment, 1)
156
+ lines = studio_environment if source.name in STUDIO_HOST_UNITS else environment
157
+ extra = (economy_environment if source.name in ECONOMY_UNITS else
158
+ notify_environment if source.name in NOTIFY_UNITS else '')
159
+ content = content.replace('[Service]\n', '[Service]\n' + lines + extra, 1)
37
160
  rendered[source.name] = '# Generated by lanes/install/services.py; re-run the server installer to update.\n' + content
38
- units = home / '.config/systemd/user'
161
+ units = Path(units_dir) if units_dir else home / '.config/systemd/user'
39
162
  units.mkdir(parents=True, exist_ok=True)
40
163
  for name, content in rendered.items():
41
164
  fd, temporary = tempfile.mkstemp(prefix='.lanes-', dir=units)
@@ -58,4 +181,5 @@ if __name__ == '__main__':
58
181
  register(Path(__file__).resolve().parents[1], Path.home(),
59
182
  os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'),
60
183
  os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'),
61
- os.environ.get('LANES_WORKSPACES'))
184
+ os.environ.get('LANES_WORKSPACES'), economy_settings(os.environ), notify_settings(os.environ),
185
+ units_dir=manager_unit_dir(Path.home()))