@vibelord/lanes 1.2.0 → 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/package.json +1 -1
- package/payload/lanes/README.md +4 -3
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +301 -45
- package/payload/lanes/container/lane +42 -1
- package/payload/lanes/container/lane-mcp +65 -6
- package/payload/lanes/container/lane_core.py +178 -0
- package/payload/lanes/docs/ARCHITECTURE.md +192 -20
- package/payload/lanes/docs/DESIGN.md +6 -3
- package/payload/lanes/docs/INSTALL.md +25 -8
- package/payload/lanes/docs/RUNBOOK.md +369 -8
- package/payload/lanes/docs/USERGUIDE.md +201 -25
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/host-install.sh +39 -5
- package/payload/lanes/libexec/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- package/payload/lanes/libexec/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +5 -0
- package/payload/lanes/libexec/notify.py +65 -5
- package/payload/lanes/libexec/sidebar.py +106 -13
- package/payload/lanes/libexec/statusd.py +97 -5
- package/payload/lanes/libexec/tokens.py +31 -9
- package/payload/lanes/libexec/update.py +94 -22
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- package/payload/lanes/web/Caddyfile +24 -1
- package/payload/lanes/web/README.md +86 -8
- package/payload/lanes/web/browser.js +2 -4
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.js +3 -2
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/manifest.json +4 -3
package/package.json
CHANGED
package/payload/lanes/README.md
CHANGED
|
@@ -139,16 +139,17 @@ The installer starts an empty catalog safely; it does not assume anyone's contai
|
|
|
139
139
|
| Rename a lane | `R` in the sidebar (Enter saves, Esc cancels; arrows and Tab type nothing in the name, a click on the list cancels it first), or `lanes rename <lane-id> <name>` on the server; the lane id never changes, and your name wins over a `displayName` the repository's `terminals.json` declares |
|
|
140
140
|
| Add a workspace (board) | `lanes workspaces add <id> --container <name> --repo /home/coder/<repo> [--title T]` on the server prints the catalog and manifest diff; add `--write` to apply, then `lanes sync` ([install](docs/INSTALL.md#2-configure-workspaces-and-editor-sync)) |
|
|
141
141
|
| Onboard a user's container | `lanes onboard <login> --container <name> --workspace <id> --repo /home/coder/<repo>` on the server prints every step (board, contract, Claude and Codex hooks, Throttle attachment, canonical recipes, sync, doctor); add `--write` to apply them and keep a receipt ([install](docs/INSTALL.md#onboard-a-users-container)) |
|
|
142
|
-
| Let an agent see and manage its lanes | The container's agents get the Lanes MCP server, `lane-mcp`, from `lanes install-container <name>` (on by default; `--no-mcp` leaves it out). `lanes_whoami`, `lanes_list`, `lanes_status` and `lanes_capture` read the lanes of that container. `lanes_register`, `lanes_rename`, `lanes_unregister` and `lanes_new` change a lane's declaration, shown on the boards within about 15 s. `lanes_send` types into a sibling lane,
|
|
142
|
+
| Let an agent see and manage its lanes | The container's agents get the Lanes MCP server, `lane-mcp`, from `lanes install-container <name>` (on by default; `--no-mcp` leaves it out). `lanes_whoami`, `lanes_list`, `lanes_status` and `lanes_capture` read the lanes of that container. `lanes_register`, `lanes_rename`, `lanes_unregister` and `lanes_new` change a lane's declaration, shown on the boards within about 15 s. `lanes_send` types into a sibling lane, `lanes_signal` reports the agent's own status, and `lanes_publish` puts a file on the docs page (`lane publish <file>` in a shell). Ending, interrupting or attaching a lane stays yours ([architecture](docs/ARCHITECTURE.md#agent-api), [guide](docs/USERGUIDE.md#agent-tools-mcp)) |
|
|
143
143
|
| See which agents need you | The sidebar sorts **Needs you** (◆ approval, ✗ error, ◇ waiting for a prompt), **Working** (▸) and **Done** (✓, until you open the lane) above the rest; the header counts them in the same glyphs (`◆1 ◇2 ▸6 ✓1`, zeros left out; `N lanes` when no lane runs an agent). The queue holds still: each section keeps the order its lanes entered it, a lane moves up a section at once but down only after the new state has held 8 s (leaving Needs you) or 5 s (leaving Working or Done), so an agent that asks again within seconds of an answer does not jump; the Needs you and Working headers stay drawn, `(0)` included, and the cursor stays on its lane ([how](docs/ARCHITECTURE.md#attention-queue)) |
|
|
144
144
|
| Read a sidebar row | `▌` cursor · agent glyph · `›` the lane this pane shows (`²`/`³`: shown in pane 2/3; `▲` pinned) · the name (`·` per other viewer, `…` when clipped) · an **age column** on every row (agent turn, else last output: `45s`, ` 3m`, ` 2h`, ` 4d`; amber in **Needs you**) · stream glyph · one tail cell by priority (`?` not observed right now > `⚠` starving: producing output but no request for over 10 min, or a 429 > `↓` downgraded > `$` mostly paid — the economy cells need the opt-in economy poller; a dim `⚠`/`↓` is a frozen last-known figure, not a live alarm) · `×` close. `⋮ N more` ends a list that continues below |
|
|
145
145
|
| End an unregistered session and its jobs | **×** beside the session, or select it and press `x`: ` › close ‘name’ — y⏎ confirm · Esc cancel`; `y` arms (` — ⏎ confirm`), Enter closes; any other key cancels (typed text closes nothing unless it is `x`, `y`, Enter in a row) |
|
|
146
|
-
| Every sidebar key | `?` replaces the list with the key sheet until the next key (that key only closes it): the short form of the [user guide's table](docs/USERGUIDE.md#the-sidebar), first `lanes <installed> · view <running>`, then companion keys on one line (`J K`, `Tab S-Tab`, `+ U`, `u z`, `p h`, `H o`, `r
|
|
146
|
+
| Every sidebar key | `?` replaces the list with the key sheet until the next key (that key only closes it): the short form of the [user guide's table](docs/USERGUIDE.md#the-sidebar), first `lanes <installed> · view <running>`, then companion keys on one line (`J K`, `Tab S-Tab`, `+ U d`, `u z`, `p h`, `H o`, `r ?`; `d` shows the cursor lane's docs link), ending with Ctrl–Alt–L, the chord that brings the keyboard to the sidebar from a lane. The footer legend says ` ⏎ open · / find · x close · ? keys`; one prompt at a time, in one shape on the desktop and the phone (` › close ‘name’ — y⏎ confirm · Esc cancel`, ` › Rename: …█`, ` › Register as: …█` then ` › register ‘name’ — y⏎ confirm`) and a filter counts its matches (` /abc · 3 matches`). What an action says in the footer (` Closed sh`, ` Cancelled`, why it failed) stays 3 s or until your next action, not just until the next key. After an action — a close, rename, register, cancel, `Esc`, `r`, closing `?`, a click that opens no lane — the keyboard goes back to the lane, so what you type next reaches the lane, not the sidebar; a prompt, the filter and the armed upload strip keep it |
|
|
147
147
|
| Pin, hide | `p` pins the cursor lane (it leads its section, `▲`, and never falls below `⋮ N more`), `h` hides it, `o` shows the hidden lanes again (faint), `H` then `y` `Enter` hides the whole workspace — its tab closes, its lanes keep running. They are yours, not the device's: every device you own follows your profile on its next tick; a session under **Unregistered** is never hidden ([guide](docs/USERGUIDE.md#the-sidebar)) |
|
|
148
148
|
| Jump to the next lane that needs you | `Tab` / `Shift-Tab` in the sidebar (cycles **Needs you** from the lane the pane shows; the pane title carries the counts and a lane that starts needing you raises one OSC 9 desktop notification on desktop layouts — `lanes-launch <device> --notify 0` (or `lanes attach <device> --notify 0` on the server) silences it for that device, live; `LANES_NOTIFY=0` in the shell that creates a view session does the same for it, see the [user guide](docs/USERGUIDE.md#9-appearance)) |
|
|
149
149
|
| Other workspaces that need you | The row above the footer rule: ` ◆1 Worlds · ◇2 Code` (blank and absent when none; panes of 18 rows or more) |
|
|
150
150
|
| What the fleet spends | With the opt-in economy poller ([RUNBOOK](docs/RUNBOOK.md#economy-projection-opt-in)) the same row is the fleet economy strip instead: ` $8.5k/16k 53% · F2 S1 · spill 76% · free 3/19` — overflow spend against the daily cap (amber at the alert level), new upstream 429s, this workspace's lanes by model tier (`F` fable, `O` opus, `S` sonnet, `H` haiku, `G` gpt), the pool's spill fraction, free accounts and the next 5 h reset, whole parts dropped from the end to fit; a leading `?` means the figures are frozen — the poller could not reach the daemon, or it stopped (no rewrite for over 3 min) |
|
|
151
151
|
| Upload | Click the one-row upload strip above the footer (` ↑ uploads · click, then drop (U)`), press `U` in the sidebar or Ctrl–Alt–U from anywhere in the tab, then drop anywhere on the window; or use `~/Lanes Uploads/<workspace>/`, or `lanes-upload <files>` |
|
|
152
|
+
| See what an agent published, or upload from a phone | The Docs page, `https://<server>:8443/lanes-docs/`, open only to your own tailnet login: **More → Docs** on a phone or tablet, **Docs** in the laptop's Lanes bar, a ⌘/Ctrl-click on the sidebar's ` ↑ uploads · ▤ docs N` strip, or `d` on a lane for its own link. An agent publishes with `lanes_publish` (or `~/.lz/bin/lane publish <file>` in a lane's shell): Markdown with Mermaid diagrams rendered, HTML and SVG opened in a sandbox, any other file a download, each with a sha256 receipt; the page's drop area lands files in the workspace's `uploads/` with the same receipt. It answers nobody until the operator pins the Serve header check (`lanes enrol mode request`) ([user guide](docs/USERGUIDE.md#docs)) |
|
|
152
153
|
| Phone view from a client | `lanes-launch web phone` in your own terminal: prepares `<your device>-phone` (phone layout), makes Tailscale Serve forward private HTTPS 8443 when the mapping is missing, prints the private URL with a QR code of it and a login token; scan the code or open the URL in Safari. `sh install/enable-phone.sh --host lanes` is the older one-shot for the device `phone` ([guide](docs/USERGUIDE.md#phone-safari-home-screen)) |
|
|
153
154
|
| Laptop or tablet browser view | `lanes-launch web laptop` (`<your device>-web`, desktop layout) or `lanes-launch web <device> [--desktop\|--mobile]` from a client; on the server `lanes web <device> [--desktop\|--mobile] [--viewports N] [--token \| --print-only]` (prepares the view, enables the loopback web services, adds the Tailscale Serve `https:8443` mapping when missing, prints the private URL) ([guide](docs/INSTALL.md#laptop-and-tablet-browser)) |
|
|
154
155
|
| Web login tokens | `lanes token list` (each token with the device it was created for), `lanes token revoke <device>`, `lanes token revoke-all --yes` ([runbook](docs/RUNBOOK.md#login-tokens)) |
|
|
@@ -190,7 +191,7 @@ long (agent since-time, or last output). Glyph then ASCII twin (`LANES_GLYPHS=as
|
|
|
190
191
|
| --- | --- | --- | --- | --- |
|
|
191
192
|
| `◆` `!` needs approval | `◇` `?` needs input | `●` `*` output in the last 20 s | `○` `o` quiet | `▌` `\|` cursor · `›` `=` viewing · `²` `2` / `³` `3` shown in viewport 2/3 · `▲` `^` pinned |
|
|
192
193
|
| `▸` `>` working | `✓` `v` done (until viewed) | `–` `-` no session right now | `!` `!` pane dead | `·` `.` viewers · `…` `~` clipped (end of a name) or on its way — closing, registering, a new shell (column 3, dim) · `×` `x` close |
|
|
193
|
-
| `✗` `X` errored | `_` `_` shell · `·` `.` idle | `✗` `x` container down | `?` `?` not observed · `·` `.` helper | `⋮` `:` more rows · `↑` `^` uploads · `↓` `v` drop now |
|
|
194
|
+
| `✗` `X` errored | `_` `_` shell · `·` `.` idle | `✗` `x` container down | `?` `?` not observed · `·` `.` helper | `⋮` `:` more rows · `↑` `^` uploads · `↓` `v` drop now · `▤` `#` docs |
|
|
194
195
|
|
|
195
196
|
Column 34 is one tail cell: `?` stale (last known state shown) > `⚠` `!` starving > `↓` `v` downgraded > `$` mostly
|
|
196
197
|
paid. Colour is reinforcement, never the only signal; `LANES_COLOR=none` keeps every glyph. Full tables, tokens and
|
package/payload/lanes/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.3.0
|
package/payload/lanes/bin/lanes
CHANGED
|
@@ -149,7 +149,14 @@
|
|
|
149
149
|
lanes upload <ws> | --active <device> [--paste]
|
|
150
150
|
read a tar stream on stdin into that board's uploads/ folder (the same folder Cursor
|
|
151
151
|
users drop files into); prints the in-container paths. --paste also types the path(s)
|
|
152
|
-
into the lane that device is looking at. `lanes workspaces` lists board ids.
|
|
152
|
+
into the lane that device is looking at. `lanes workspaces` lists board ids. Each file
|
|
153
|
+
also goes to the docs store with a sha256 receipt (stderr `# docs:` lines), for the
|
|
154
|
+
login that owns the device; the Docs page's upload (POST /up) lands the same way.
|
|
155
|
+
lanes docs push <id> the hook for pushing one docs-store document to the data service's publication surface
|
|
156
|
+
— NOT BUILT: it prints the document's receipt (receipt.json: id, sha256, bytes, url,
|
|
157
|
+
at; looked up by id in every login's store, for the operator), says `not configured`
|
|
158
|
+
on stderr and exits 2. It sends nothing and writes nothing. Exit 1: no document with
|
|
159
|
+
that id, an id that is not one, or another argument (this usage)
|
|
153
160
|
lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]
|
|
154
161
|
make Cursor/VS Code terminals open exactly the board's lanes through `lane open`
|
|
155
162
|
(dry-run by default: prints the git-style diff; --write applies with a backup).
|
|
@@ -170,7 +177,7 @@
|
|
|
170
177
|
current ✓ · statusd runs 1.1.0 ✗ · enrold notify ✓` — this tree's VERSION and the npm
|
|
171
178
|
stamp's commit, the tree, whether ~/.local/share/lanes/current names it (✗ with the
|
|
172
179
|
tree it does name), then the tree each long-running process recorded at its last start
|
|
173
|
-
(~/.local/state/lanes/run/<name>.tree: statusd, enrold, notify, economy, studio_host,
|
|
180
|
+
(~/.local/state/lanes/run/<name>.tree: statusd, enrold, docsd, notify, economy, studio_host,
|
|
174
181
|
the newest sidebar), ✗ for one that is not the current tree (`runs 1.1.0 ✗`) or that
|
|
175
182
|
started before its tree's code (VERSION, bin/, libexec/) last changed — a checkout
|
|
176
183
|
updated in place by `git pull` keeps its path (`started before its tree changed ✗`) —
|
|
@@ -269,6 +276,11 @@
|
|
|
269
276
|
lanes-web / lanes-mobile units (⚠ not enabled, ✗ enabled but not running), the login-token
|
|
270
277
|
database the running lanes-web reads (✗ when it is not the one `lanes token` uses) and the
|
|
271
278
|
Tailscale Serve https:8443 mapping (⚠ not checked on a host without a Tailscale name);
|
|
279
|
+
the docs service: lanes-docsd enabled, active and answering on its loopback port (⚠
|
|
280
|
+
not enabled, ✗ enabled but not running or not answering), the pin gate (⚠ until `lanes
|
|
281
|
+
enrol mode request`), the store (documents, bytes, docs.log), the Mermaid pin (⚠ not
|
|
282
|
+
installed: install/enable-docs.sh; ✗ not the pinned build) and the age switch (✗ set
|
|
283
|
+
without an age binary);
|
|
272
284
|
then the release that runs: ~/.local/share/lanes/current names a tree whose VERSION
|
|
273
285
|
agrees with its pkg/<version> name and npm stamp and that this lanes runs (⚠ without the
|
|
274
286
|
link: the rest compares with this tree); every lanes process that runs now has a run
|
|
@@ -777,9 +789,18 @@ def hold_sessions(stack, timeout, doing):
|
|
|
777
789
|
return None
|
|
778
790
|
|
|
779
791
|
|
|
792
|
+
# The web front's own path roots (web/Caddyfile): a view session is served at /<device>, so a device of one of these
|
|
793
|
+
# names would sit on a route a service owns — the enrolment service (/enrol/*), the docs service (/lanes-docs/*) and
|
|
794
|
+
# the shell's assets (/lanes-assets/*). Compared casefolded: Caddy's path matcher ignores case.
|
|
795
|
+
RESERVED_DEVICES = ('enrol', 'lanes-assets', 'lanes-docs')
|
|
796
|
+
|
|
797
|
+
|
|
780
798
|
def device_name(value):
|
|
781
799
|
if not L.IDENT.match(value or '') or len(value) > 32:
|
|
782
800
|
raise L.LanesError('Invalid device name')
|
|
801
|
+
if value.casefold() in RESERVED_DEVICES:
|
|
802
|
+
raise L.LanesError(f'Invalid device name: {value} is a path the web front reserves '
|
|
803
|
+
f'({", ".join(RESERVED_DEVICES)})')
|
|
783
804
|
return value
|
|
784
805
|
|
|
785
806
|
|
|
@@ -2129,8 +2150,12 @@ class UploadReader:
|
|
|
2129
2150
|
|
|
2130
2151
|
|
|
2131
2152
|
def cmd_upload(args):
|
|
2153
|
+
"""`lanes upload <workspace> | --active <device> [--device D] [--paste] [--request R]`: a tar stream on stdin into
|
|
2154
|
+
the workspace's `uploads/` AND the docs store (W10 W5, libexec/uploads.py receive_tar): each file lands as it always
|
|
2155
|
+
did — same folder, `-1` collision suffix, `.git/info/exclude` line — and gets a sha256 receipt, `source` ssh, for
|
|
2156
|
+
the login that owns the asking device (users.login_for; none: a docs.log `no-owner` line), under the lane its view
|
|
2157
|
+
shows (uploads.upload_lane). stdout and `<request>.done` stay the landed in-container paths, one per line."""
|
|
2132
2158
|
import shlex
|
|
2133
|
-
import tarfile
|
|
2134
2159
|
registry = L.load_registry()
|
|
2135
2160
|
device = args[args.index('--active') + 1] if '--active' in args else None
|
|
2136
2161
|
paste_device = args[args.index('--device') + 1] if '--device' in args else device
|
|
@@ -2151,34 +2176,20 @@ def cmd_upload(args):
|
|
|
2151
2176
|
raise L.LanesError(f'unknown board: {names[0]} (see `lanes workspaces`)')
|
|
2152
2177
|
else:
|
|
2153
2178
|
raise L.LanesError('usage: lanes upload <workspace> | --active <device> [--paste] (tar stream on stdin)')
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
# check-ignore: 0 = ignored, 1 = not ignored; an error or no answer (None) is not a reason to append.
|
|
2162
|
-
if (root / '.git').is_dir() and L.run_bounded(['git', '-C', str(root), 'check-ignore', '-q', 'uploads/x'], 10)[0] == 1:
|
|
2163
|
-
exclude.parent.mkdir(exist_ok=True)
|
|
2164
|
-
with open(exclude, 'a') as handle:
|
|
2165
|
-
handle.write('\n# lanes upload: local intake folder, never committed\nuploads/\n')
|
|
2166
|
-
staging = uploads / f'.incoming-{os.getpid()}'
|
|
2167
|
-
staging.mkdir()
|
|
2168
|
-
landed = []
|
|
2179
|
+
uploads_lib, users = libexec_module('uploads'), libexec_module('users')
|
|
2180
|
+
folder, home, parts = uploads_lib.landing(registry, workspace)
|
|
2181
|
+
# The view that asked (a suffixed view's sidecar, else the device named): whose upload it is, and whose lane.
|
|
2182
|
+
view_device = paste_target(spool, request, paste_device) if spool else paste_device
|
|
2183
|
+
login = users.login_for(view_device) if view_device else None
|
|
2184
|
+
lane = uploads_lib.upload_lane(workspace, view_device)
|
|
2185
|
+
uploads = uploads_lib.open_uploads(home, parts)
|
|
2169
2186
|
try:
|
|
2170
2187
|
stream = UploadReader(sys.stdin.buffer, spool / f'{request}.progress' if spool else None)
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
final, n = uploads / entry.name, 1
|
|
2175
|
-
while final.exists(): # never overwrite an earlier upload
|
|
2176
|
-
final = uploads / f'{entry.stem}-{n}{entry.suffix}'
|
|
2177
|
-
n += 1
|
|
2178
|
-
entry.rename(final)
|
|
2179
|
-
landed.append(f"{folder}/uploads/{final.name}")
|
|
2188
|
+
landed = [f'{folder}/uploads/{name}' for name in uploads_lib.receive_tar(
|
|
2189
|
+
stream, uploads, login=login, ws=workspace['id'], lane=lane,
|
|
2190
|
+
say=lambda line: print(line, file=sys.stderr, flush=True))]
|
|
2180
2191
|
finally:
|
|
2181
|
-
|
|
2192
|
+
os.close(uploads)
|
|
2182
2193
|
if not landed:
|
|
2183
2194
|
# The helper reports the client-side cause (eg. macOS denied Downloads). Publishing a generic .err here
|
|
2184
2195
|
# races that report: the sidebar consumes it and loses the useful error before the helper can send it.
|
|
@@ -2187,7 +2198,6 @@ def cmd_upload(args):
|
|
|
2187
2198
|
print(path)
|
|
2188
2199
|
if '--paste' in args and paste_device:
|
|
2189
2200
|
# The result files (.done / .err) stay in the base spool, where the asking sidebar looks for them.
|
|
2190
|
-
view_device = paste_target(spool, request, paste_device) if spool else paste_device
|
|
2191
2201
|
shown = type_into_lane(registry, device_name(view_device), workspace, ' '.join(shlex.quote(p) for p in landed) + ' ')
|
|
2192
2202
|
print(f'# typed into lane: {shown}' if shown else '# could not type into the lane (not a tmux lane?)', file=sys.stderr)
|
|
2193
2203
|
if spool:
|
|
@@ -2195,6 +2205,32 @@ def cmd_upload(args):
|
|
|
2195
2205
|
return 0
|
|
2196
2206
|
|
|
2197
2207
|
|
|
2208
|
+
DOCS_USAGE = 'usage: lanes docs push <id>'
|
|
2209
|
+
# What a publication target needs and does not have yet: the data service's token-authenticated publication surface
|
|
2210
|
+
# (plan W10 "Operator gates" 3 — decided once that surface is stable). Until then `lanes docs push` is the hook only.
|
|
2211
|
+
DOCS_PUSH_UNCONFIGURED = ('lanes docs push: not configured — no publication target exists yet (the data service\'s '
|
|
2212
|
+
'publication surface is not built; plan W10, operator gate 3): nothing was sent')
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
def cmd_docs(args):
|
|
2216
|
+
"""`lanes docs push <id>` (docs cockpit W10 W9, LNS-110): the hook for the data service's publication surface — a
|
|
2217
|
+
STUB, not built. It looks the document up in every login's docs store (docs_store.operator_receipt: the operator is
|
|
2218
|
+
the account the store belongs to, so no login's visibility applies), prints its receipt.json on stdout
|
|
2219
|
+
(DM-LANES-DOCS-RECEIPT, as stored: indent 2), says `not configured` on stderr and returns 2. Nothing is sent,
|
|
2220
|
+
written or logged. No such document, an id that is not one (docs_store.ID_RE) or any other argument: LanesError,
|
|
2221
|
+
exit 1."""
|
|
2222
|
+
if len(args) != 2 or args[0] != 'push':
|
|
2223
|
+
raise L.LanesError(DOCS_USAGE)
|
|
2224
|
+
D = libexec_module('docs_store')
|
|
2225
|
+
found = D.operator_receipt(args[1])
|
|
2226
|
+
if found is None:
|
|
2227
|
+
raise L.LanesError(f'docs push: no document {args[1]} in the docs store ({D.root()})')
|
|
2228
|
+
_meta, receipt = found
|
|
2229
|
+
print(json.dumps(receipt, indent=2, ensure_ascii=False))
|
|
2230
|
+
print(DOCS_PUSH_UNCONFIGURED, file=sys.stderr)
|
|
2231
|
+
return 2
|
|
2232
|
+
|
|
2233
|
+
|
|
2198
2234
|
def resolve_device(name, args):
|
|
2199
2235
|
"""`coder-launch worlds` style: a board id given where a device is expected means "my default device"."""
|
|
2200
2236
|
try:
|
|
@@ -2768,7 +2804,7 @@ def cmd_hello(args):
|
|
|
2768
2804
|
|
|
2769
2805
|
# The record_tree names, in the order `lanes version` lists them: statusd first (every view's status comes from it),
|
|
2770
2806
|
# then the services, then the newest sidebar. A name not listed (a newer tree's process) follows, sorted.
|
|
2771
|
-
RUN_ORDER = ('statusd', 'enrold', 'notify', 'economy', 'studio_host', 'sidebar')
|
|
2807
|
+
RUN_ORDER = ('statusd', 'enrold', 'docsd', 'notify', 'economy', 'studio_host', 'sidebar')
|
|
2772
2808
|
VERSION_SCHEMA = 'lanes.version/1'
|
|
2773
2809
|
COMMIT_RE = re.compile(r'([0-9a-f]{7})[0-9a-f]{0,33}(-dirty)?') # scripts/build-payload.mjs: rev-parse HEAD [-dirty]
|
|
2774
2810
|
# A tree's code, as far as a recorded process runs it: VERSION and the regular files directly in these folders (every
|
|
@@ -3919,6 +3955,25 @@ def cmd_device(args):
|
|
|
3919
3955
|
return 0
|
|
3920
3956
|
|
|
3921
3957
|
|
|
3958
|
+
def unit_properties(units):
|
|
3959
|
+
"""One bounded `systemctl --user show` of `units` (Id, LoadState, ActiveState, UnitFileState, MainPID) ->
|
|
3960
|
+
({unit: {property: value}}, None), or (None, why) when systemctl did not answer or failed. Read-only."""
|
|
3961
|
+
argv = ['systemctl', '--user', 'show', '-p', 'Id', '-p', 'LoadState', '-p', 'ActiveState', '-p', 'UnitFileState',
|
|
3962
|
+
'-p', 'MainPID', *units]
|
|
3963
|
+
rc, out, err = L.run_bounded(argv, 10, env=clean_env())
|
|
3964
|
+
if rc != 0:
|
|
3965
|
+
return None, 'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'
|
|
3966
|
+
found = {}
|
|
3967
|
+
for block in out.strip().split('\n\n'):
|
|
3968
|
+
props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
|
|
3969
|
+
found[props.get('Id', '')] = props
|
|
3970
|
+
return found, None
|
|
3971
|
+
|
|
3972
|
+
|
|
3973
|
+
def unit_enabled(props):
|
|
3974
|
+
return props.get('UnitFileState', '').startswith(('enabled', 'linked', 'alias'))
|
|
3975
|
+
|
|
3976
|
+
|
|
3922
3977
|
def web_doctor():
|
|
3923
3978
|
"""`lanes doctor`'s browser/phone lines -> [(mark, text)], mark ✓ | ⚠ | ✗ (LNS-50). One bounded
|
|
3924
3979
|
`systemctl --user show` for both web units, a read of the running lanes-web's environment, `tailscale status --json`
|
|
@@ -3932,20 +3987,14 @@ def web_doctor():
|
|
|
3932
3987
|
if not shutil.which('systemctl'):
|
|
3933
3988
|
lines.append(('⚠', 'web units not checked — no systemctl on this host (`lanes web` needs systemd user services)'))
|
|
3934
3989
|
else:
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
if rc != 0:
|
|
3939
|
-
lines.append(('✗', f"web units NOT checked — systemctl --user {'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'}"))
|
|
3990
|
+
units, why = unit_properties(WEB_UNITS)
|
|
3991
|
+
if units is None:
|
|
3992
|
+
lines.append(('✗', f'web units NOT checked — systemctl --user {why}'))
|
|
3940
3993
|
else:
|
|
3941
|
-
units = {}
|
|
3942
|
-
for block in out.strip().split('\n\n'):
|
|
3943
|
-
props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
|
|
3944
|
-
units[props.get('Id', '')] = props
|
|
3945
3994
|
for unit in WEB_UNITS:
|
|
3946
3995
|
props = units.get(unit, {})
|
|
3947
3996
|
active = props.get('ActiveState') or 'unknown'
|
|
3948
|
-
enabled = props
|
|
3997
|
+
enabled = unit_enabled(props)
|
|
3949
3998
|
enabled_any = enabled_any or enabled
|
|
3950
3999
|
if enabled and active == 'active':
|
|
3951
4000
|
lines.append(('✓', f'{unit} active'))
|
|
@@ -3990,6 +4039,207 @@ def web_doctor():
|
|
|
3990
4039
|
return lines
|
|
3991
4040
|
|
|
3992
4041
|
|
|
4042
|
+
# ------------------------------------------------------------------ doctor: the docs service (W10 W8, LNS-109)
|
|
4043
|
+
DOCS_UNIT = 'lanes-docsd.service'
|
|
4044
|
+
DOCS_PROBE_S = 2 # seconds the loopback connect to lanes-docsd may take
|
|
4045
|
+
ENABLE_DOCS = 'install/enable-docs.sh'
|
|
4046
|
+
|
|
4047
|
+
|
|
4048
|
+
def home_shown(path, home=None):
|
|
4049
|
+
"""`path` as `~/…` under the home, else as it is (plain)."""
|
|
4050
|
+
try:
|
|
4051
|
+
return plain(f'~/{Path(path).relative_to(Path(home) if home is not None else Path.home())}')
|
|
4052
|
+
except (ValueError, KeyError, RuntimeError):
|
|
4053
|
+
return plain(path)
|
|
4054
|
+
|
|
4055
|
+
|
|
4056
|
+
def byte_size(count):
|
|
4057
|
+
"""A size for a person: `812 B`, `3.4 KiB`, `17.0 MiB`, `1.2 GiB`."""
|
|
4058
|
+
if count < 1024:
|
|
4059
|
+
return f'{count} B'
|
|
4060
|
+
for unit in ('KiB', 'MiB', 'GiB'):
|
|
4061
|
+
count /= 1024
|
|
4062
|
+
if count < 1024 or unit == 'GiB':
|
|
4063
|
+
return f'{count:.1f} {unit}'
|
|
4064
|
+
|
|
4065
|
+
|
|
4066
|
+
def tree_bytes(root):
|
|
4067
|
+
"""(regular files, their bytes) under the directory `root`, never through a link (a link at `root` itself counts
|
|
4068
|
+
nothing); (0, 0) when it does not exist. Unreadable entries are skipped."""
|
|
4069
|
+
if os.path.islink(root):
|
|
4070
|
+
return 0, 0
|
|
4071
|
+
files = total = 0
|
|
4072
|
+
stack = [os.fspath(root)]
|
|
4073
|
+
while stack:
|
|
4074
|
+
try:
|
|
4075
|
+
with os.scandir(stack.pop()) as entries:
|
|
4076
|
+
for entry in entries:
|
|
4077
|
+
try:
|
|
4078
|
+
if entry.is_dir(follow_symlinks=False):
|
|
4079
|
+
stack.append(entry.path)
|
|
4080
|
+
elif entry.is_file(follow_symlinks=False):
|
|
4081
|
+
files, total = files + 1, total + entry.stat(follow_symlinks=False).st_size
|
|
4082
|
+
except OSError:
|
|
4083
|
+
continue
|
|
4084
|
+
except OSError:
|
|
4085
|
+
continue
|
|
4086
|
+
return files, total
|
|
4087
|
+
|
|
4088
|
+
|
|
4089
|
+
def docs_port():
|
|
4090
|
+
"""(port, None): the port lanes-docsd listens on — LANES_DOCS_PORT from its drop-in (docs_store.unit_environment,
|
|
4091
|
+
the files `lanes upload` reads too), else docsd.DEFAULT_PORT — or (None, why) for a setting that is not a port."""
|
|
4092
|
+
value = libexec_module('docs_store').unit_environment().get('LANES_DOCS_PORT') or ''
|
|
4093
|
+
if not value:
|
|
4094
|
+
return libexec_module('docsd').DEFAULT_PORT, None
|
|
4095
|
+
if value.isdigit() and 0 < int(value) <= 65535:
|
|
4096
|
+
return int(value), None
|
|
4097
|
+
return None, f'LANES_DOCS_PORT={plain(value)[:20]} in its drop-in is not a port number'
|
|
4098
|
+
|
|
4099
|
+
|
|
4100
|
+
def port_answers(port, timeout=DOCS_PROBE_S):
|
|
4101
|
+
"""Whether 127.0.0.1:<port> accepts a TCP connection within `timeout` s. It connects and closes without sending a
|
|
4102
|
+
byte, so lanes-docsd answers nothing and writes no docs.log line (its handler reads an empty request line)."""
|
|
4103
|
+
import socket as sockets
|
|
4104
|
+
try:
|
|
4105
|
+
with sockets.create_connection(('127.0.0.1', port), timeout=timeout):
|
|
4106
|
+
return True
|
|
4107
|
+
except OSError:
|
|
4108
|
+
return False
|
|
4109
|
+
|
|
4110
|
+
|
|
4111
|
+
def docs_unit_line(probe):
|
|
4112
|
+
restart = f'journalctl --user -u {DOCS_UNIT}; systemctl --user restart {DOCS_UNIT}'
|
|
4113
|
+
if not shutil.which('systemctl'):
|
|
4114
|
+
return ('⚠', f'{DOCS_UNIT} not checked — no systemctl on this host')
|
|
4115
|
+
units, why = unit_properties([DOCS_UNIT])
|
|
4116
|
+
if units is None:
|
|
4117
|
+
return ('✗', f'{DOCS_UNIT} NOT checked — systemctl --user {why}')
|
|
4118
|
+
props = units.get(DOCS_UNIT, {})
|
|
4119
|
+
active, enabled = props.get('ActiveState') or 'unknown', unit_enabled(props)
|
|
4120
|
+
if active != 'active':
|
|
4121
|
+
if enabled:
|
|
4122
|
+
return ('✗', f'{DOCS_UNIT} enabled but {plain(active)} — {restart}')
|
|
4123
|
+
return ('⚠', f'{DOCS_UNIT} not enabled, so there is no docs page — the full server install enables it (an '
|
|
4124
|
+
f'update with --update-only enables nothing): systemctl --user enable --now {DOCS_UNIT}')
|
|
4125
|
+
port, why = docs_port()
|
|
4126
|
+
if port is None:
|
|
4127
|
+
return ('✗', f'{DOCS_UNIT} active, but {why} — fix it with systemctl --user edit {DOCS_UNIT}')
|
|
4128
|
+
where = f'127.0.0.1:{port}'
|
|
4129
|
+
if not probe(port):
|
|
4130
|
+
return ('✗', f'{DOCS_UNIT} active, but {where} does not answer — {restart}')
|
|
4131
|
+
if not enabled:
|
|
4132
|
+
return ('⚠', f'{DOCS_UNIT} active on {where} but not enabled: a reboot loses it — systemctl --user enable '
|
|
4133
|
+
f'{DOCS_UNIT}')
|
|
4134
|
+
return ('✓', f'{DOCS_UNIT} active, answering on {where}')
|
|
4135
|
+
|
|
4136
|
+
|
|
4137
|
+
def docs_pin_line():
|
|
4138
|
+
"""The pin gate as docsd reads it on every request (enrold.read_mode): an operator gate, so never a ✗."""
|
|
4139
|
+
mode, problem = libexec_module('enrold').read_mode()
|
|
4140
|
+
if mode == 'request':
|
|
4141
|
+
return ('✓', 'docs: pinned — lanes-docsd answers members through Tailscale Serve (lanes enrol mode request)')
|
|
4142
|
+
said = problem or ('pinned invite only' if mode == 'invite' else 'unpinned: auto')
|
|
4143
|
+
return ('⚠', f'docs: refuses until the Serve header check is pinned ({plain(said)}): every request is 403 '
|
|
4144
|
+
'not-pinned — run the check (RUNBOOK "Enrolment service", Operator verification), then lanes enrol '
|
|
4145
|
+
'mode request (RUNBOOK "Docs service")')
|
|
4146
|
+
|
|
4147
|
+
|
|
4148
|
+
def docs_store_line(home=None):
|
|
4149
|
+
D = libexec_module('docs_store')
|
|
4150
|
+
counts = D.read_counts()
|
|
4151
|
+
documents = sum(entry['n'] for entry in counts.values())
|
|
4152
|
+
_files, size = tree_bytes(D.root())
|
|
4153
|
+
try:
|
|
4154
|
+
log = f'docs.log {byte_size(D.log_path().lstat().st_size)}'
|
|
4155
|
+
except FileNotFoundError:
|
|
4156
|
+
log = 'no docs.log yet'
|
|
4157
|
+
except OSError as error:
|
|
4158
|
+
log = f'docs.log not readable ({error.strerror or error})'
|
|
4159
|
+
return ('', f"docs store: {documents} document{'' if documents == 1 else 's'} in {len(counts)} "
|
|
4160
|
+
f"lane{'' if len(counts) == 1 else 's'} (counts.json), {byte_size(size)} under "
|
|
4161
|
+
f'{home_shown(D.root(), home)}; {log}')
|
|
4162
|
+
|
|
4163
|
+
|
|
4164
|
+
def docs_mermaid_line(home=None):
|
|
4165
|
+
"""holders.lock's opt-in `mermaid` pin against the file install/enable-docs.sh installs: the member digest, read
|
|
4166
|
+
without following a link or blocking on a FIFO."""
|
|
4167
|
+
import hashlib
|
|
4168
|
+
SH = libexec_module('studio_host')
|
|
4169
|
+
script = f"sh {home_shown(L.CURRENT, home)}/{ENABLE_DOCS}"
|
|
4170
|
+
try:
|
|
4171
|
+
pin = SH.parse_lock((L.REPO / 'holders.lock').read_text()).get('mermaid')
|
|
4172
|
+
why = 'no mermaid line' if pin is None else '' if pin.opt_in else 'the mermaid line is not an opt-in pin'
|
|
4173
|
+
except (OSError, ValueError, SH.Refusal) as error:
|
|
4174
|
+
pin, why = None, str(error)
|
|
4175
|
+
if why:
|
|
4176
|
+
return ('✗', f'docs: Mermaid not checked — {L.REPO / "holders.lock"}: {plain(why)}')
|
|
4177
|
+
target = L.SHARE / 'vendor' / f'mermaid-{pin.version}.min.js'
|
|
4178
|
+
shown, version = home_shown(target, home), plain(pin.version)
|
|
4179
|
+
try:
|
|
4180
|
+
fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0))
|
|
4181
|
+
except FileNotFoundError:
|
|
4182
|
+
return ('⚠', f'docs: Mermaid {version} not installed ({shown}), so diagrams show as source — run {script} '
|
|
4183
|
+
'(every server install and update runs it)')
|
|
4184
|
+
except OSError as error:
|
|
4185
|
+
return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file '
|
|
4186
|
+
f'({error.strerror or error}) — {script} replaces it from a verified download')
|
|
4187
|
+
try:
|
|
4188
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
4189
|
+
return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file — {script} replaces it '
|
|
4190
|
+
'from a verified download')
|
|
4191
|
+
digest = hashlib.sha256()
|
|
4192
|
+
for chunk in iter(lambda: os.read(fd, 1 << 20), b''):
|
|
4193
|
+
digest.update(chunk)
|
|
4194
|
+
finally:
|
|
4195
|
+
os.close(fd)
|
|
4196
|
+
actual = digest.hexdigest()
|
|
4197
|
+
if actual != pin.binary_sha256:
|
|
4198
|
+
return ('✗', f'docs: Mermaid {version} refused — {shown} is not the pinned build (sha256 {actual[:12]}…, '
|
|
4199
|
+
f'holders.lock {pin.binary_sha256[:12]}…): browsers refuse it by its integrity, so diagrams '
|
|
4200
|
+
f'show as source — {script} replaces it from a verified download')
|
|
4201
|
+
return ('✓', f'docs: Mermaid {version} pinned ({shown}, sha256 {actual[:12]}…)')
|
|
4202
|
+
|
|
4203
|
+
|
|
4204
|
+
def docs_age_line():
|
|
4205
|
+
"""The store's encryption switch (docs_store.age_settings: this environment, else lanes-docsd's drop-in, as
|
|
4206
|
+
`lanes upload` reads it) and the `age` on this PATH. The recipient is never printed."""
|
|
4207
|
+
D = libexec_module('docs_store')
|
|
4208
|
+
binary = D.age_binary()
|
|
4209
|
+
has = f'age at {plain(binary)}' if binary else 'no age binary on PATH'
|
|
4210
|
+
try:
|
|
4211
|
+
recipient, identity = D.age_settings()
|
|
4212
|
+
except D.DocsError as error:
|
|
4213
|
+
return ('✗', f'docs: age encryption NOT usable — {error}: every upload and publish fails until it is fixed '
|
|
4214
|
+
f'({home_shown(D.dropin_dir())})')
|
|
4215
|
+
if recipient is None:
|
|
4216
|
+
return ('', f'docs: age encryption off (LANES_DOCS_AGE_RECIPIENT unset; {has})')
|
|
4217
|
+
if binary is None:
|
|
4218
|
+
return ('✗', 'docs: age encryption on (LANES_DOCS_AGE_RECIPIENT set) but there is no age binary on PATH: every '
|
|
4219
|
+
'upload and publish fails (encrypt-failed) — install age or remove the setting (the server '
|
|
4220
|
+
'install refuses it)')
|
|
4221
|
+
opens = ('lanes-docsd opens them with LANES_DOCS_AGE_IDENTITY' if identity else
|
|
4222
|
+
'no LANES_DOCS_AGE_IDENTITY, so their downloads answer 409 encrypted')
|
|
4223
|
+
return ('✓', f'docs: age encryption on — new store copies are age output ({has}); {opens}')
|
|
4224
|
+
|
|
4225
|
+
|
|
4226
|
+
def docs_doctor(home=None, probe=None):
|
|
4227
|
+
"""`lanes doctor`'s docs lines (W10 W8, LNS-109) -> [(mark, text)], mark ✓ | ✗ | ⚠ | '' (information), in this
|
|
4228
|
+
order; read-only (one bounded `systemctl --user show`, one loopback connect, file reads):
|
|
4229
|
+
lanes-docsd.service — enabled, active and its loopback port (docs_port) accepting a connection ✓ · enabled but not
|
|
4230
|
+
active, or active while its port refuses ✗ · active but not enabled ⚠ (a reboot loses it) · not enabled ⚠ (the
|
|
4231
|
+
full server install enables it; an update with --update-only enables nothing) · systemctl silent ✗ · no
|
|
4232
|
+
systemctl ⚠. `probe` (port -> bool) stands in for port_answers.
|
|
4233
|
+
the pin gate — `request` ✓ `docs: pinned` · else ⚠ `docs: refuses until the Serve header check is pinned`
|
|
4234
|
+
the store — information: documents and lanes from counts.json, the bytes under docs/, docs.log's size
|
|
4235
|
+
the Mermaid pin — the pinned member digest ✓ · absent ⚠ (run install/enable-docs.sh) · another digest or not a
|
|
4236
|
+
regular file ✗ (refused: browsers refuse it by its integrity) · no valid pin ✗
|
|
4237
|
+
the `age` switch — unset: information · set with an `age` on PATH ✓ · set without one, or no recipient ✗"""
|
|
4238
|
+
home = Path(home) if home is not None else Path.home()
|
|
4239
|
+
return [docs_unit_line(port_answers if probe is None else probe), docs_pin_line(), docs_store_line(home),
|
|
4240
|
+
docs_mermaid_line(home), docs_age_line()]
|
|
4241
|
+
|
|
4242
|
+
|
|
3993
4243
|
# ------------------------------------------------------------------ doctor: the release that runs (W9 V3, LNS-85)
|
|
3994
4244
|
# `lanes version` says which tree each process last started from; doctor judges it, with the reference `lanes version`
|
|
3995
4245
|
# uses (the tree the `current` link names; this tree when there is none). A run record outlives its process, so only a
|
|
@@ -4004,8 +4254,8 @@ def web_doctor():
|
|
|
4004
4254
|
# <tree>/libexec/sidebar.py …`. Anything else that names the file — an editor, a pager, a grep, the script run by
|
|
4005
4255
|
# hand another way — and a one-shot that never records (RUN_ONESHOTS) is not that daemon (DEF-1785). studio_host
|
|
4006
4256
|
# records a oneshot step (install/enable-studio-host.sh), never a running process.
|
|
4007
|
-
RUN_SCRIPTS = {'statusd': 'statusd.py', 'enrold': 'enrold.py', '
|
|
4008
|
-
'sidebar': 'sidebar.py'}
|
|
4257
|
+
RUN_SCRIPTS = {'statusd': 'statusd.py', 'enrold': 'enrold.py', 'docsd': 'docsd.py', 'notify': 'notify.py',
|
|
4258
|
+
'economy': 'economy_poll.py', 'sidebar': 'sidebar.py'}
|
|
4009
4259
|
RUN_INTERPRETER = re.compile(r'python3(?:\.\d+)?') # argv[0]'s name, by path or not: python3, a versioned python3.N
|
|
4010
4260
|
RUN_FLAGS = ['-S', '-E'] # the interpreter flags of every launch, in this order
|
|
4011
4261
|
# The arguments that make a run a one-shot that never records, as each record_tree comment names them. statusd reads
|
|
@@ -4013,8 +4263,8 @@ RUN_FLAGS = ['-S', '-E'] # the interpreter flags of
|
|
|
4013
4263
|
# anywhere and by any prefix it can tell apart (`--dry`; none of these prefixes another option of its script).
|
|
4014
4264
|
RUN_ONESHOTS = {'statusd': ('--once',), 'notify': ('--test',), 'economy': ('--once', '--dry-run')}
|
|
4015
4265
|
RUN_FIRST_ARGUMENT = {'statusd'}
|
|
4016
|
-
RUN_UNITS = {'statusd': 'lanes-statusd.service', 'enrold': 'lanes-enrold.service', '
|
|
4017
|
-
'economy': 'lanes-economy.service'} # what restarts each daemon; a sidebar restarts with its view
|
|
4266
|
+
RUN_UNITS = {'statusd': 'lanes-statusd.service', 'enrold': 'lanes-enrold.service', 'docsd': 'lanes-docsd.service',
|
|
4267
|
+
'notify': 'lanes-notify.service', 'economy': 'lanes-economy.service'} # what restarts each daemon; a sidebar restarts with its view
|
|
4018
4268
|
LAYOUT_COMMAND = re.compile(r'command=("(?:[^"\\]|\\.)*")') # q(): a JSON string
|
|
4019
4269
|
LAYOUT_FILES = L.LAYOUT_FILES # the tree files a generated layout runs
|
|
4020
4270
|
UNIT_ENTRY = re.compile(r'"@REPO@/([^"]+)"') # a unit template's tree file (install/services.py)
|
|
@@ -4370,6 +4620,12 @@ def cmd_doctor(_args):
|
|
|
4370
4620
|
print(f'⚠ {text}')
|
|
4371
4621
|
else:
|
|
4372
4622
|
check(mark == '✓', text)
|
|
4623
|
+
# The docs service (W10 W8, LNS-109): lanes-docsd and its port, the pin gate, the store, the Mermaid pin, `age`.
|
|
4624
|
+
for mark, text in docs_doctor():
|
|
4625
|
+
if mark in ('⚠', ''):
|
|
4626
|
+
print(f'{mark or " "} {text}')
|
|
4627
|
+
else:
|
|
4628
|
+
check(mark == '✓', text)
|
|
4373
4629
|
# The release that runs (W9 V3): `current`, the run records against the one process-table sweep above, the
|
|
4374
4630
|
# device layouts, the units.
|
|
4375
4631
|
for mark, text in release_doctor(table if rc == 0 else None):
|
|
@@ -5053,7 +5309,7 @@ COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_cli
|
|
|
5053
5309
|
'sidebar-key': cmd_sidebar_key, 'studio-host': cmd_studio_host,
|
|
5054
5310
|
'keys': cmd_keys, 'invite': cmd_invite, 'enrol': cmd_enrol, 'offboard': cmd_offboard,
|
|
5055
5311
|
'token': cmd_token, 'onboard': cmd_onboard, 'user': cmd_user, 'lane': cmd_lane, 'here': cmd_here,
|
|
5056
|
-
'version': cmd_version, 'update': cmd_update}
|
|
5312
|
+
'version': cmd_version, 'update': cmd_update, 'docs': cmd_docs}
|
|
5057
5313
|
|
|
5058
5314
|
if __name__ == '__main__':
|
|
5059
5315
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
|
@@ -23,6 +23,12 @@ a `holder: tmux` lane through its exact socket and session; any other id is a zm
|
|
|
23
23
|
hook sidecar says the agent is `working` unless --force
|
|
24
24
|
lane gc --sockets unlink this container's tmux socket files that tmux is refused on (ECONNREFUSED: no
|
|
25
25
|
server listens); prints each unlinked path. A live, empty or silent server keeps its file
|
|
26
|
+
lane publish <file> [--title T] [--for <login>] [--kind md|html|svg|file]
|
|
27
|
+
put the file on the Lanes docs page from the lane this runs in ($LANE_ID, else the tmux
|
|
28
|
+
pane): writes ~/.lz/state/publish/<lane>/<id>.json (the file's container path, hashed
|
|
29
|
+
once) and prints {id, …, picked_up_by, expected_within_s} as JSON; the host's lanes-docsd
|
|
30
|
+
takes it within ~5 s and writes <id>.receipt.json beside it (kind from the extension when
|
|
31
|
+
--kind is left out; the file must be under /home/coder, at most 32 MiB)
|
|
26
32
|
lane mcp install [--agent claude|codex|all] [--write]
|
|
27
33
|
add the Lanes MCP server (~/.lz/bin/lane-mcp) to ~/.claude.json (mcpServers.lanes) and
|
|
28
34
|
~/.codex/config.toml ([mcp_servers.lanes], with env_vars passing LANE_ID, LANE_WS, TMUX
|
|
@@ -574,8 +580,43 @@ def cmd_mcp(args):
|
|
|
574
580
|
sys.exit(1 if refused else 0)
|
|
575
581
|
|
|
576
582
|
|
|
583
|
+
PUBLISH_USAGE = 'usage: lane publish <file> [--title T] [--for <login>] [--kind md|html|svg|file]'
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def cmd_publish(args):
|
|
587
|
+
"""`lane publish` (plan W10 W4): lane_core.publish from the lane this process runs in (lane_core.whoami: $LANE_ID,
|
|
588
|
+
else the tmux pane) for the file named relative to the working directory. Prints the answer as JSON on stdout
|
|
589
|
+
(what lanes_publish answers) and one line for a person on stderr. A refusal is one stderr line, exit 1."""
|
|
590
|
+
options, rest, path = {}, list(args), None
|
|
591
|
+
while rest:
|
|
592
|
+
word = rest.pop(0)
|
|
593
|
+
if word in ('--title', '--for', '--kind'):
|
|
594
|
+
if not rest or word in options:
|
|
595
|
+
sys.exit(PUBLISH_USAGE)
|
|
596
|
+
options[word] = rest.pop(0)
|
|
597
|
+
elif word.startswith('--') or path is not None:
|
|
598
|
+
sys.exit(PUBLISH_USAGE)
|
|
599
|
+
else:
|
|
600
|
+
path = word
|
|
601
|
+
if path is None:
|
|
602
|
+
sys.exit(PUBLISH_USAGE)
|
|
603
|
+
found = C.whoami(ROOT, os.environ)
|
|
604
|
+
if not found['lane']:
|
|
605
|
+
sys.exit(f"lane publish: {found.get('why') or 'not inside a lane'}")
|
|
606
|
+
try:
|
|
607
|
+
out = C.publish(ROOT, Path(os.environ.get('HOME') or Path.home()), os.path.abspath(path), found['lane'],
|
|
608
|
+
title=options.get('--title'), for_login=options.get('--for'), kind=options.get('--kind'))
|
|
609
|
+
except C.LaneError as error:
|
|
610
|
+
sys.exit(f'lane publish: {error}')
|
|
611
|
+
out.update(picked_up_by=C.PUBLISH_PICKUP, expected_within_s=C.PUBLISH_LATENCY_S)
|
|
612
|
+
print(json.dumps(out, ensure_ascii=False, indent=1))
|
|
613
|
+
sys.stderr.write(f"lane publish: {out['path']} ({out['kind']}, {out['bytes']} bytes) -> request {out['id']} of "
|
|
614
|
+
f"{out['lane']}; {C.PUBLISH_PICKUP} takes it within ~{C.PUBLISH_LATENCY_S} s and answers in "
|
|
615
|
+
f"{out['receipt']}\n")
|
|
616
|
+
|
|
617
|
+
|
|
577
618
|
COMMANDS = {'new': cmd_new, 'open': cmd_open, 'attach': cmd_attach, 'send': cmd_send, 'capture': cmd_capture, 'ls': cmd_ls,
|
|
578
|
-
'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc, 'mcp': cmd_mcp}
|
|
619
|
+
'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc, 'mcp': cmd_mcp, 'publish': cmd_publish}
|
|
579
620
|
|
|
580
621
|
if __name__ == '__main__':
|
|
581
622
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|