@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
|
@@ -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,
|
|
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
|
|
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.
|
|
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
|
|
143
|
-
'
|
|
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
|
-
|
|
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
|