@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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/payload/lanes/README.md +4 -3
  3. package/payload/lanes/VERSION +1 -1
  4. package/payload/lanes/bin/lanes +301 -45
  5. package/payload/lanes/container/lane +42 -1
  6. package/payload/lanes/container/lane-mcp +65 -6
  7. package/payload/lanes/container/lane_core.py +178 -0
  8. package/payload/lanes/docs/ARCHITECTURE.md +192 -20
  9. package/payload/lanes/docs/DESIGN.md +6 -3
  10. package/payload/lanes/docs/INSTALL.md +25 -8
  11. package/payload/lanes/docs/RUNBOOK.md +369 -8
  12. package/payload/lanes/docs/USERGUIDE.md +201 -25
  13. package/payload/lanes/holders.lock +5 -0
  14. package/payload/lanes/install/enable-docs.sh +78 -0
  15. package/payload/lanes/install/host-install.sh +39 -5
  16. package/payload/lanes/libexec/docs_store.py +995 -0
  17. package/payload/lanes/libexec/docsd.py +2010 -0
  18. package/payload/lanes/libexec/mdlite.py +575 -0
  19. package/payload/lanes/libexec/members.py +5 -0
  20. package/payload/lanes/libexec/notify.py +65 -5
  21. package/payload/lanes/libexec/sidebar.py +106 -13
  22. package/payload/lanes/libexec/statusd.py +97 -5
  23. package/payload/lanes/libexec/tokens.py +31 -9
  24. package/payload/lanes/libexec/update.py +94 -22
  25. package/payload/lanes/libexec/uploads.py +538 -0
  26. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  27. package/payload/lanes/web/Caddyfile +24 -1
  28. package/payload/lanes/web/README.md +86 -8
  29. package/payload/lanes/web/browser.js +2 -4
  30. package/payload/lanes/web/docs.css +52 -0
  31. package/payload/lanes/web/docs.js +36 -0
  32. package/payload/lanes/web/mobile.js +3 -2
  33. package/payload/lanes/web/upload.css +17 -0
  34. package/payload/lanes/web/upload.js +84 -0
  35. package/payload/manifest.json +4 -3
@@ -18,7 +18,8 @@ capture's text, a sent text or a note), rotated to .1 past 1 MiB.
18
18
  Read tools (the lanes of THIS container: what ~/.lz/registry.json and ~/.lz/status.json hold):
19
19
  lanes_whoami which lane this agent runs in ($LANE_ID, else its tmux pane's session)
20
20
  lanes_list [workspace] [needs_you] [agent] every lane here with its state and agent
21
- lanes_status <lane> one lane in full: registry facts, observed state, agent, tmux liveness, hook sidecar
21
+ lanes_status <lane> one lane in full: registry facts, observed state, agent, documents (`docs`), tmux
22
+ liveness, hook sidecar, and the lane's latest publish receipt (`published`)
22
23
  lanes_capture <lane> [lines] the last N (1–500, default 100) non-blank lines of that lane's screen and scrollback
23
24
 
24
25
  Write tools (LNS-35). Each answers JSON: on success what changed, what picks it up (`picked_up_by`) and when
@@ -34,8 +35,12 @@ terminal); `lanes sync` publishes the change (lanes-statusd starts one within ~1
34
35
  lanes_signal <status> [note] report this agent's own status (and a one-line note) through lane-signal; the report
35
36
  stands until the agent's next tool call, prompt or permission prompt, and the end of
36
37
  its turn keeps it (lane-signal's EXPLICIT rule)
38
+ lanes_publish <path> [title] [for] [kind] put a file of this container on the Lanes docs page from this agent's
39
+ own lane (plan W10 W4): a request in ~/.lz/state/publish/<lane>/ that the host's
40
+ lanes-docsd takes within ~5 s, answering in <id>.receipt.json (lane_core.publish)
37
41
  Human-only by omission (no tool exists): ending or closing a lane, interrupting it (C-c or any key that is not
38
- printable text), opening or attaching one, uploads, the host's sync/gc/ensure, renaming a tmux session, and any
42
+ printable text), opening or attaching one, uploads (a person's file into a lane: the Docs page or the desktop drop;
43
+ an agent's own file goes the other way, lanes_publish), the host's sync/gc/ensure, renaming a tmux session, and any
39
44
  edit of an adopted (`lanes register`ed, `--x-`) session — those stay with a person on the boards or the host.
40
45
  """
41
46
  import json
@@ -71,6 +76,8 @@ PATH_MAX, COMMAND_MAX = 1024, 1024
71
76
  SYNC_LATENCY_S, STATUS_LATENCY_S = 15, 5
72
77
  SYNC_PICKUP = 'lanes sync'
73
78
  STATUS_PICKUP = 'lanes-statusd'
79
+ # A publish request: lanes-docsd's own pick-up cycle (5 s, plan W10 W4) takes it and writes the receipt back.
80
+ PUBLISH_PICKUP, PUBLISH_LATENCY_S = C.PUBLISH_PICKUP, C.PUBLISH_LATENCY_S
74
81
  SESSION_VARS = ('CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID') # the agent's session id, when its env carries one
75
82
  # How long a lanes_signal report holds against the agent's own hooks (lane-signal's EXPLICIT rule, DEF-1801).
76
83
  SIGNAL_STANDS = ('your next tool call, a new prompt, a permission prompt, a failed turn or the session\'s end; the end '
@@ -86,7 +93,9 @@ INSTRUCTIONS = ('Lanes tools for the workspace container this agent runs in. A l
86
93
  'display name, lanes_new declares and starts a new lane, lanes_send types text into a lane (refused '
87
94
  'while its agent is working or waiting for an approval unless force: true), lanes_signal reports '
88
95
  'your own status with a short note. Every write answers what changed, what picks it up and when; a '
89
- 'refusal answers a machine code and what a person can do instead. Ending, interrupting or attaching '
96
+ 'refusal answers a machine code and what a person can do instead. lanes_publish puts a file of this '
97
+ 'container on the Lanes docs page, where the people on your boards open it (the host takes it within '
98
+ 'about 5 s; lanes_status shows the receipt as `published`). Ending, interrupting or attaching '
90
99
  'a lane is a person\'s call: no tool does it.')
91
100
 
92
101
  ID_PATTERN = '^[A-Za-z0-9_][A-Za-z0-9_.-]*$'
@@ -139,8 +148,9 @@ TOOLS = (
139
148
  _tool('lanes_status', 'Show one lane',
140
149
  'One lane in full: the registry facts (board, display name, kind, tmux socket and session, the declaring '
141
150
  'terminals.json and repository), the host\'s observation (state, agent, attached clients, viewers, last '
142
- 'activity, size, stale), whether its tmux session is alive now (null when tmux did not answer) and its '
143
- 'lane-signal hook sidecar.',
151
+ 'activity, size, stale, and `docs`: how many documents the docs page holds for the lane and when the '
152
+ 'newest arrived), whether its tmux session is alive now (null when tmux did not answer), its '
153
+ 'lane-signal hook sidecar, and `published`: the lane\'s latest publish receipt (id, url, at, outcome).',
144
154
  {'lane': LANE_ARG}, required=('lane',)),
145
155
  _tool('lanes_capture', 'Read a lane\'s screen',
146
156
  'The last `lines` non-blank lines of a tmux lane\'s screen and scrollback, as plain text (wrapped lines '
@@ -213,6 +223,25 @@ TOOLS = (
213
223
  'description': f'What you are doing, one line of at most {NOTE_MAX} characters; "" clears it. '
214
224
  'Kept while the report stands or its status repeats.'}},
215
225
  required=('status',), hints=_writes(False, True)),
226
+ _tool('lanes_publish', 'Publish a file to the docs page',
227
+ 'Put a file of this container on the Lanes docs page, from the lane this agent runs in: Markdown (with '
228
+ 'Mermaid diagrams) is rendered there, an HTML or SVG artifact opens sandboxed on its own page, any other '
229
+ 'file is offered as a download. Writes a request the host\'s lanes-docsd takes within about 5 s; it reads '
230
+ 'the file itself, checks its sha256 against the one answered here and writes a receipt that lanes_status '
231
+ 'shows as `published` (outcome ok with the document\'s url, or refused with the reason). The file must be '
232
+ f'under /home/coder (every link resolved) and at most {C.PUBLISH_MAX >> 20} MiB. Who sees it: `for`, else '
233
+ 'the person who last opened this lane, else every member whose record lists the lane\'s board.',
234
+ {'path': {'type': 'string', 'minLength': 1, 'maxLength': PATH_MAX,
235
+ 'description': 'The file: an absolute path under /home/coder.'},
236
+ 'title': {'type': 'string', 'maxLength': C.TITLE_MAX,
237
+ 'description': f'The title the docs page shows, one line of at most {C.TITLE_MAX} characters; '
238
+ 'default: the file name.'},
239
+ 'for': {'type': 'string', 'minLength': 1, 'maxLength': C.LOGIN_MAX,
240
+ 'description': 'The tailnet login of the member the document is for (only they see it).'},
241
+ 'kind': {'type': 'string', 'enum': list(C.PUBLISH_KINDS),
242
+ 'description': 'md, html, svg or file; default: from the extension (.md/.markdown, .html/.htm, '
243
+ '.svg, anything else file).'}},
244
+ required=('path',), hints=_writes(False, False)),
216
245
  )
217
246
  TOOL_BY_NAME = {tool['name']: tool for tool in TOOLS}
218
247
  WRITE_TOOLS = frozenset(tool['name'] for tool in TOOLS if not tool['annotations']['readOnlyHint'])
@@ -280,6 +309,13 @@ HUMAN = {
280
309
  'tmux_failed': 'lanes_status says whether the lane is running.',
281
310
  'signal_not_written': 'A person checks the lane-signal install (`lanes install-container <container>` on the '
282
311
  'host).',
312
+ 'outside_home': 'Publish a file under /home/coder: copy it there first (a link that leads outside is refused).',
313
+ 'not_a_file': 'Pass an existing regular file (not a directory, FIFO or device).',
314
+ 'too_large': f'A published file is at most {C.PUBLISH_MAX >> 20} MiB: publish a smaller one, or tell a person '
315
+ 'where the file is.',
316
+ 'invalid_title': 'A title is one line of printable text without leading or trailing spaces.',
317
+ 'invalid_for': 'Pass one tailnet login (as `anna@example.com`), or leave `for` out.',
318
+ 'invalid_kind': 'Pass md, html, svg or file, or leave `kind` out.',
283
319
  'refused': 'A person takes it from here.',
284
320
  }
285
321
 
@@ -384,7 +420,9 @@ def agent_summary(agent):
384
420
  return {key: agent[key] for key in ('harness', 'status', 'name', 'since') if agent.get(key) is not None}
385
421
 
386
422
 
387
- OBSERVED = ('attached', 'viewers', 'activity', 'size', 'stale')
423
+ # The observed keys a row copies from ~/.lz/status.json when present. `docs` ({n, at}: the documents the docs cockpit
424
+ # holds for the lane, plan W10 W7) is an additive result key within wire v1, absent while the lane has none.
425
+ OBSERVED = ('attached', 'viewers', 'activity', 'size', 'stale', 'docs')
388
426
 
389
427
 
390
428
  class Server:
@@ -643,6 +681,9 @@ class Server:
643
681
  alive = None if code in (124, 127) else code == 0
644
682
  out['alive'] = alive
645
683
  out['hook'] = C.hook_agent(self.root / 'state' / 'hooks', lane, self.proc) or None
684
+ published = C.latest_receipt(self.root, lane) # plan W10 W4: read here, in the container, never via status.json
685
+ if published:
686
+ out['published'] = published
646
687
  out['status'] = freshness(status)
647
688
  return out
648
689
 
@@ -1066,6 +1107,24 @@ class Server:
1066
1107
  'picked_up_by': STATUS_PICKUP, 'expected_within_s': STATUS_LATENCY_S}
1067
1108
 
1068
1109
 
1110
+ def tool_publish(self, arguments):
1111
+ found = C.whoami(self.root, self.environ)
1112
+ lane = found['lane']
1113
+ if not lane:
1114
+ refuse('not_in_lane', found.get('why') or 'this agent runs in no lane',
1115
+ 'lanes_publish publishes from the lane this agent runs in: start the agent inside a lane (for '
1116
+ 'Codex, `lane mcp install` passes the lane\'s variables to this server).')
1117
+ try:
1118
+ local = self.local(arguments['path'])
1119
+ except ValueError as error:
1120
+ refuse('outside_home', f'{error}: pass an absolute path under {C.CONTAINER_HOME}')
1121
+ out = C.publish(self.root, self.home, local, lane, title=arguments.get('title'),
1122
+ for_login=arguments.get('for'), kind=arguments.get('kind'))
1123
+ return {**out, 'changed': f'{out["request"]}: a publish request for {out["path"]} ({out["kind"]}, '
1124
+ f'{out["bytes"]} bytes, sha256 {out["sha256"]})',
1125
+ 'picked_up_by': PUBLISH_PICKUP, 'expected_within_s': PUBLISH_LATENCY_S}
1126
+
1127
+
1069
1128
  def main(argv):
1070
1129
  if argv:
1071
1130
  if argv[0] in ('-h', '--help'):
@@ -25,6 +25,9 @@ sees, and `lane-mcp` may write nothing but protocol frames to its stdout.
25
25
  test_lane_mcp_write.HostRules), and `declared_name`, which finds a registry lane's declared terminal in its file.
26
26
  * the agents' MCP entries: `mcpServers.lanes` in ~/.claude.json and `[mcp_servers.lanes]` (command + env_vars) in
27
27
  ~/.codex/config.toml, planned as a diff, written with a `.bak-<UTC>` copy, atomically and only when missing.
28
+ * publish requests (plan W10 W4): `publish` writes ~/.lz/state/publish/<lane>/<id>.json for `lane publish` and
29
+ lane-mcp's `lanes_publish` (the file hashed once, the id in docs_store's grammar: `new_publish_id`), and
30
+ `latest_receipt` reads the `<id>.receipt.json` the host's lanes-docsd writes back (`lanes_status`'s `published`).
28
31
  """
29
32
  import difflib
30
33
  import json
@@ -1138,3 +1141,178 @@ def mcp_apply(home, agents=MCP_AGENTS, command=MCP_COMMAND):
1138
1141
  results.append({'agent': agent, 'path': path, 'status': status, 'backup': saved,
1139
1142
  'why': refusal[0] if refusal else None})
1140
1143
  return results
1144
+
1145
+
1146
+ # ------------------------------------------------------------------------------------------------ publishing (W10 W4)
1147
+ # `lane publish <file>` and lane-mcp's `lanes_publish` put a file of this container on the Lanes docs page. Nothing here
1148
+ # copies the file anywhere: the request names it, and the host's lanes-docsd reads it through /proc/<container init>/root
1149
+ # on its own 5 s cycle (it trusts nothing in the request: the path is resolved inside the container again, the bytes
1150
+ # hashed again), stores it in the docs store and writes `<id>.receipt.json` back beside the request, which it removes.
1151
+ #
1152
+ # ~/.lz/state/publish/<lane id>/<id>.json the request (DM-LANES-PUBLISH-REQUEST): {v: 1, path, title, kind,
1153
+ # claimed_sha256, mime, bytes, for}, `path` the file's absolute path
1154
+ # in this container with every link resolved
1155
+ # ~/.lz/state/publish/<lane id>/<id>.receipt.json the host's answer (DM-LANES-PUBLISH-RECEIPT): {v: 1, id, request,
1156
+ # sha256, bytes, url, at, outcome: ok|refused, reason?}
1157
+
1158
+ PUBLISH_KINDS = ('md', 'html', 'svg', 'file') # docs_store.KINDS without `upload` (a person's upload is the host's)
1159
+ PUBLISH_MAX = 32 << 20 # docsd.PUBLISH_LIMIT: a larger file is refused here and on the host
1160
+ PUBLISH_PICKUP = 'lanes-docsd' # what takes a request (docsd.Pickup)
1161
+ PUBLISH_LATENCY_S = 5 # docsd.PICKUP_INTERVAL: one pick-up cycle
1162
+ PUBLISH_DIR = ('state', 'publish') # under ~/.lz
1163
+ PUBLISH_ID = re.compile(r'[a-z0-9]{10,20}') # docs_store.ID_RE
1164
+ RECEIPT_SUFFIX = '.receipt.json'
1165
+ TITLE_MAX = 200 # docs_store.MAX_TITLE
1166
+ LOGIN_MAX = 254 # members.MAX_LOGIN
1167
+ ID_TIME = '0123456789abcdefghijklmnopqrstuvwxyz' # docs_store.BASE36: epoch seconds, zero-padded to 6
1168
+ ID_RANDOM = 'abcdefghijklmnopqrstuvwxyz234567' # docs_store.BASE32: 6 random characters
1169
+ KIND_BY_SUFFIX = {'.md': 'md', '.markdown': 'md', '.html': 'html', '.htm': 'html', '.svg': 'svg'}
1170
+ MIME_BY_SUFFIX = {'.md': 'text/markdown', '.markdown': 'text/markdown', '.html': 'text/html', '.htm': 'text/html',
1171
+ '.svg': 'image/svg+xml', '.txt': 'text/plain', '.json': 'application/json', '.csv': 'text/csv',
1172
+ '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1173
+ '.gif': 'image/gif', '.webp': 'image/webp'} # = libexec/docs_store.MIME_BY_SUFFIX (DEF-1920)
1174
+ REFUSED_TEXT = frozenset(('Cc', 'Cf', 'Cs', 'Co', 'Cn', 'Zl', 'Zp')) # members.REFUSED_CATEGORIES
1175
+
1176
+
1177
+ def new_publish_id(now=None):
1178
+ """A request id in docs_store's grammar (`new_id`: base36 epoch seconds zero-padded to 6, then 6 random base32),
1179
+ pinned equal to it by test_lane_publish. The container cannot import the host's store."""
1180
+ import secrets
1181
+ seconds, digits = int(time.time() if now is None else now), ''
1182
+ while True:
1183
+ seconds, rest = divmod(seconds, 36)
1184
+ digits = ID_TIME[rest] + digits
1185
+ if not seconds:
1186
+ break
1187
+ return digits.rjust(6, '0') + ''.join(secrets.choice(ID_RANDOM) for _ in range(6))
1188
+
1189
+
1190
+ def publish_kind(path, kind=None):
1191
+ """`kind` when given (one of PUBLISH_KINDS), else from the file name: .md/.markdown md, .html/.htm html, .svg svg,
1192
+ anything else file. LaneError `invalid_kind`."""
1193
+ if kind is None or kind == '':
1194
+ return KIND_BY_SUFFIX.get(os.path.splitext(str(path))[1].lower(), 'file')
1195
+ if kind not in PUBLISH_KINDS:
1196
+ raise LaneError(f'kind {kind!r} is not one of {", ".join(PUBLISH_KINDS)}', 'invalid_kind')
1197
+ return kind
1198
+
1199
+
1200
+ def _one_line(value, what, limit, blanks, code):
1201
+ """`value` when it is one printable line of at most `limit` characters without outer blanks (members._text's rule;
1202
+ `blanks`: inner spaces allowed), else LaneError `code`. The host checks it again."""
1203
+ if (not isinstance(value, str) or not value or len(value) > limit or value != value.strip()
1204
+ or any(unicodedata.category(char) in REFUSED_TEXT for char in value)
1205
+ or (not blanks and any(char.isspace() for char in value))):
1206
+ raise LaneError(f'{what} is one line of at most {limit} printable characters'
1207
+ + ('' if blanks else ' without blanks'), code)
1208
+ return value
1209
+
1210
+
1211
+ def publish_dir(root, lane):
1212
+ return Path(root).joinpath(*PUBLISH_DIR, lane)
1213
+
1214
+
1215
+ def publish(root, home, path, lane, title=None, for_login=None, kind=None, now=None):
1216
+ """Write a publish request for the file at `path` (as this process reaches it) from `lane` -> {id, lane, path,
1217
+ kind, title, for, bytes, sha256, request, receipt}: `path` the file's container path, `request`/`receipt` the
1218
+ container paths of the request and of the receipt lanes-docsd writes. `root` is ~/.lz and `home` /home/coder as
1219
+ this process reaches them (themselves in a container, test directories otherwise).
1220
+
1221
+ The file must resolve (os.path.realpath) inside `home` to a regular file of at most PUBLISH_MAX bytes; it is read
1222
+ once, never through a link at its last component and never blocking on a FIFO, to hash it (`claimed_sha256`, which
1223
+ the host compares with its own). Refusals (LaneError): `not_in_lane`, `invalid_title`, `invalid_for`,
1224
+ `invalid_kind`, `outside_home`, `not_a_file`, `too_large`. The request is replaced atomically (0644); nothing else
1225
+ is written."""
1226
+ import hashlib
1227
+ import mimetypes
1228
+ if not isinstance(lane, str) or not LANE_RE.fullmatch(lane):
1229
+ raise LaneError('this agent runs in no lane to publish from', 'not_in_lane')
1230
+ title = None if title in (None, '') else _one_line(title, 'a title', TITLE_MAX, True, 'invalid_title')
1231
+ for_login = None if for_login in (None, '') else _one_line(for_login, 'a login', LOGIN_MAX, False, 'invalid_for')
1232
+ base = os.path.realpath(str(home))
1233
+ real = os.path.realpath(str(path))
1234
+ if not real.startswith(base.rstrip(os.sep) + os.sep):
1235
+ raise LaneError(f'{path} is not a file under {CONTAINER_HOME} (with every link resolved): only this '
1236
+ 'container\'s home can be published', 'outside_home')
1237
+ kind = publish_kind(real, kind)
1238
+ try:
1239
+ fd = os.open(real, os.O_RDONLY | os.O_NONBLOCK | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0))
1240
+ except OSError as error:
1241
+ raise LaneError(f'{path}: {error.strerror or "cannot be opened"}', 'not_a_file') from None
1242
+ digest, size = hashlib.sha256(), 0
1243
+ try:
1244
+ info = os.fstat(fd)
1245
+ if not stat.S_ISREG(info.st_mode):
1246
+ raise LaneError(f'{path} is not a regular file', 'not_a_file')
1247
+ if info.st_size > PUBLISH_MAX:
1248
+ raise LaneError(f'{path} is larger than {PUBLISH_MAX >> 20} MiB', 'too_large')
1249
+ while True:
1250
+ chunk = os.read(fd, 1 << 16)
1251
+ if not chunk:
1252
+ break
1253
+ size += len(chunk)
1254
+ if size > PUBLISH_MAX:
1255
+ raise LaneError(f'{path} is larger than {PUBLISH_MAX >> 20} MiB', 'too_large')
1256
+ digest.update(chunk)
1257
+ finally:
1258
+ os.close(fd)
1259
+ container = CONTAINER_HOME + '/' + os.path.relpath(real, base).replace(os.sep, '/')
1260
+ name = os.path.basename(real)
1261
+ # The same table as libexec/docs_store.MIME_BY_SUFFIX: Python's built-in map gained `.md` only in 3.12, so the
1262
+ # request's `mime` must not depend on the interpreter (the host compares nothing on it, but receipts carry it).
1263
+ mime, encoding = MIME_BY_SUFFIX.get(os.path.splitext(name)[1].lower()), None
1264
+ if not mime:
1265
+ mime, encoding = mimetypes.MimeTypes().guess_type(name) # the built-in table, as the host's
1266
+ request = {'v': 1, 'path': container, 'title': title, 'kind': kind, 'claimed_sha256': digest.hexdigest(),
1267
+ 'mime': mime if mime and not encoding else 'application/octet-stream', 'bytes': size, 'for': for_login}
1268
+ directory = publish_dir(root, lane)
1269
+ directory.mkdir(parents=True, exist_ok=True)
1270
+ for _ in range(16):
1271
+ request_id = new_publish_id(now)
1272
+ if not any(os.path.lexists(str(directory / (request_id + suffix))) for suffix in ('.json', RECEIPT_SUFFIX)):
1273
+ break
1274
+ else:
1275
+ raise LaneError('could not mint a free request id', 'refused')
1276
+ atomic_replace(directory / f'{request_id}.json', json.dumps(request, ensure_ascii=False, indent=1) + '\n', 0o644)
1277
+ where = LZ + '/' + '/'.join(PUBLISH_DIR) + f'/{lane}/{request_id}'
1278
+ return {'id': request_id, 'lane': lane, 'path': container, 'kind': kind, 'title': title, 'for': for_login,
1279
+ 'bytes': size, 'sha256': request['claimed_sha256'], 'request': where + '.json',
1280
+ 'receipt': where + RECEIPT_SUFFIX}
1281
+
1282
+
1283
+ def latest_receipt(root, lane):
1284
+ """The newest receipt lanes-docsd wrote back for `lane` (~/.lz/state/publish/<lane>/<id>.receipt.json; the latest
1285
+ `at` — whole seconds —, then the file written last, then the id) as lanes_status shows it -> {id, url, at,
1286
+ outcome, request, reason?}, or None (no receipt yet, or none that reads as one). Each file is read as a regular
1287
+ file only (read_regular: no link, no FIFO)."""
1288
+ if not isinstance(lane, str) or not LANE_RE.fullmatch(lane):
1289
+ return None
1290
+ directory = publish_dir(root, lane)
1291
+ try:
1292
+ names = os.listdir(str(directory))
1293
+ except OSError:
1294
+ return None
1295
+ best = None
1296
+ for name in names:
1297
+ request_id = name[:-len(RECEIPT_SUFFIX)] if name.endswith(RECEIPT_SUFFIX) else ''
1298
+ if not PUBLISH_ID.fullmatch(request_id):
1299
+ continue
1300
+ receipt = read_json(directory / name)
1301
+ if not (isinstance(receipt, dict) and receipt.get('v') == 1 and isinstance(receipt.get('at'), str)
1302
+ and receipt.get('outcome') in ('ok', 'refused')):
1303
+ continue
1304
+ try:
1305
+ written = os.stat(str(directory / name), follow_symlinks=False).st_mtime_ns
1306
+ except OSError:
1307
+ continue
1308
+ key = (receipt['at'], written, request_id)
1309
+ if best is None or key > best[0]:
1310
+ best = (key, request_id, receipt)
1311
+ if best is None:
1312
+ return None
1313
+ _key, request_id, receipt = best
1314
+ out = {'id': receipt.get('id'), 'url': receipt.get('url'), 'at': receipt['at'], 'outcome': receipt['outcome'],
1315
+ 'request': request_id}
1316
+ if receipt['outcome'] == 'refused':
1317
+ out['reason'] = receipt.get('reason')
1318
+ return out