geneseed 3.1.0 → 3.1.2

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 (102) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/DESIGN.md +31 -20
  3. package/README.md +2 -2
  4. package/SETUP.md +30 -3
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +113 -85
  11. package/docs/web/rules.md +2 -0
  12. package/docs/web/setup-choices.md +13 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +203 -0
  15. package/js/build/bundle.mjs +231 -0
  16. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  17. package/js/build/emit-claude.mjs +287 -0
  18. package/js/build/emit-common.mjs +255 -0
  19. package/js/build/emit-opencode.mjs +214 -0
  20. package/js/{generate.mjs → build/generate.mjs} +45 -31
  21. package/js/{render.mjs → build/render.mjs} +72 -11
  22. package/js/{checkout.mjs → build/source.mjs} +52 -11
  23. package/js/build/stubs.mjs +256 -0
  24. package/js/{themes.mjs → build/themes.mjs} +24 -24
  25. package/js/build/version.mjs +155 -0
  26. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  27. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  28. package/js/{installs.mjs → hosts/installs.mjs} +68 -16
  29. package/js/{link.mjs → hosts/link.mjs} +29 -28
  30. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  31. package/js/{native.mjs → hosts/native.mjs} +24 -22
  32. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  33. package/js/{settings.mjs → hosts/settings.mjs} +94 -69
  34. package/js/inspect/checks-authoring.mjs +761 -0
  35. package/js/inspect/checks-build.mjs +231 -0
  36. package/js/inspect/checks-repo.mjs +308 -0
  37. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  38. package/js/inspect/doctor.mjs +286 -0
  39. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  40. package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
  41. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  42. package/js/inspect/scan.mjs +125 -0
  43. package/js/{status.mjs → inspect/status.mjs} +27 -25
  44. package/js/inspect/validate.mjs +199 -0
  45. package/js/lib/fs.mjs +37 -759
  46. package/js/lib/json.mjs +344 -0
  47. package/js/lib/paths.mjs +242 -0
  48. package/js/lib/proc.mjs +3 -3
  49. package/js/lib/text.mjs +181 -0
  50. package/js/lib/udiff.mjs +1 -1
  51. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  52. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  53. package/js/{setup.mjs → maintain/setup.mjs} +49 -37
  54. package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
  55. package/js/{update.mjs → maintain/update.mjs} +51 -51
  56. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  57. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  58. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  59. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  60. package/js/web/actions.mjs +124 -89
  61. package/js/web/activity.mjs +22 -21
  62. package/js/web/api.mjs +88 -58
  63. package/js/web/daemon.mjs +319 -0
  64. package/js/web/docs.mjs +24 -21
  65. package/js/web/graph.mjs +0 -0
  66. package/js/web/handler.mjs +442 -0
  67. package/js/web/jobs.mjs +29 -21
  68. package/js/web/routes.mjs +223 -0
  69. package/js/web/server.mjs +56 -987
  70. package/package.json +11 -3
  71. package/src/AGENT.md.tmpl +1 -1
  72. package/src/doctrines/README.md +1 -1
  73. package/themes/opencode/README.md +1 -1
  74. package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
  75. package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
  76. package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
  77. package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
  78. package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
  79. package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
  80. package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
  81. package/web/dist/assets/Laws-BK07NKw9.js +3 -0
  82. package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
  83. package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
  84. package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
  85. package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
  86. package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
  87. package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
  88. package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
  89. package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
  90. package/web/dist/assets/index-BE226Gv6.css +1 -0
  91. package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
  92. package/web/dist/assets/index-BgIs5eIa.js +12 -0
  93. package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +135 -69
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1712
  99. package/js/emit.mjs +0 -1410
  100. package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
  101. package/web/dist/assets/index-1pPSz7Kl.css +0 -1
  102. package/web/dist/assets/index-C-gb-q-r.js +0 -12
package/js/web/server.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * The web console's HTTP shell — `rituals/_web_server.py`'s handler, static route and
3
- * `serve()` entry. P6a: the shell and `/api/ping`, and nothing that answers a real
4
- * endpoint.
2
+ * The `geneseed web` VERB — the UI-bundle decision, the npm build, `serve()` and the CLI
3
+ * entry. The request handler and the static route are in `handler.mjs`, the route
4
+ * declarations in `routes.mjs`, the daemon record and lifecycle in `daemon.mjs`; this file
5
+ * binds a socket and wires the three together.
5
6
  *
6
7
  * WHY THE SHELL FIRST, AND ALONE. `_web_server.py` is 654 lines and none of it is an API
7
8
  * function: it is routing, two security guards, gzip negotiation, the static route with
@@ -17,7 +18,7 @@
17
18
  * read its body, it joins on `', '`. So the twin needs no new helper and the response
18
19
  * bodies stay byte-comparable.
19
20
  *
20
- * Its one condition is the int/float distinction. `pyStr` refuses a BARE JS number
21
+ * Its one condition is the int/float distinction. `formatValue` refuses a BARE JS number
21
22
  * because `json.loads` tells `20` from `1.0` and `JSON.parse` does not — but the numbers
22
23
  * in a response body are not parsed from JSON, they are computed here (counts, a port, a
23
24
  * pid, a unix second), and every one of them is a Python `int`, which renders identically
@@ -25,975 +26,43 @@
25
26
  * call sites in a factory. A Python float would be the counter-example — `1.0` against
26
27
  * JS's `1` — and the byte gate over every endpoint body is what would catch one.
27
28
  *
28
- * P6h FINISHED IT, and the file grew a second half rather than a second module. The
29
- * reference keeps the handler and the daemon lifecycle in ONE file under a
30
- * `# ---- daemon mode` banner, and splitting them here would have made `serve()` (which
31
- * needs `npmBuild`) and the launcher (which needs `readDaemon`) import each other. So
32
- * `_probe`, `_live_daemon`, `_spawn_detached`, `start|stop|status|restart_daemon`,
33
- * `_npm_build` and `cmd_web` all live below, and `bin/geneseed-cli.mjs` is finally a
34
- * caller — which is what put this tree on the CLI's import graph and moved
35
- * `js/web/jobs.mjs`'s spawn row to `entry: "cli"`.
29
+ * THE DAEMON LIFECYCLE MOVED OUT, AND THE PREDICTED CYCLE DID NOT MATERIALISE. This
30
+ * docblock used to argue that splitting the handler from the daemon would make `serve()`
31
+ * (which needs `npmBuild`) and the launcher (which needs `readDaemon`) import each other.
32
+ * They do not: the dependency runs ONE WAY, `server → daemon`, because `npmBuild` stayed
33
+ * here with `serve` and only the record readers went. `probe`, `liveDaemon`,
34
+ * `spawnDetached`, `start|stop|status|restartDaemon`, `openUrl` and `requestRestart` are in
35
+ * `daemon.mjs`; `npmBuild`, `buildPlan`, `serve` and `cmdWeb` are here.
36
+ *
37
+ * `bin/geneseed-cli.mjs` imports `cmdWeb` statically, which is what put this tree on the
38
+ * CLI's import graph and moved `js/web/jobs.mjs`'s spawn row to `entry: "cli"` — and it is
39
+ * why a runtime dependency reached from here would load on EVERY `geneseed` invocation.
36
40
  *
37
41
  * WHAT IS STILL NOT HERE, SINCE P6i: `/api/pick-folder` alone, and it is DECLINED rather than
38
42
  * deferred — an OS-native folder chooser has no Node twin that is not a new GUI dependency.
39
43
  * `NOT_PORTED_POST` is empty and stays declared, because an empty half of a partition is the
40
44
  * partition asserting there is nothing left; `DECLINED_POST` is what still has a member.
41
45
  */
42
- import { spawn, spawnSync } from 'node:child_process';
46
+ import { GLOBAL_MANIFEST, resolvePath } from '../hosts/hosts.mjs';
47
+ import { printOut } from '../lib/fs.mjs';
48
+ import { which } from '../lib/paths.mjs';
49
+ import { parseIntStrict } from '../lib/text.mjs';
50
+ import { promptLine } from '../maintain/setup.mjs';
51
+ import { webState } from './api.mjs';
52
+ import { clearDaemon, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
53
+ import { isFile, makeHandler } from './handler.mjs';
54
+ import { JobManager } from './jobs.mjs';
55
+ import { spawnSync } from 'node:child_process';
43
56
  import { randomBytes } from 'node:crypto';
44
- import {
45
- existsSync, readFileSync, statSync, unlinkSync, chmodSync, mkdirSync, openSync,
46
- } from 'node:fs';
47
- import { createServer, request as httpRequest } from 'node:http';
48
- import { join, resolve as pathResolve, dirname, extname, basename, sep } from 'node:path';
57
+ import { existsSync } from 'node:fs';
58
+ import { createServer } from 'node:http';
59
+ import { join, resolve as pathResolve } from 'node:path';
49
60
  import { fileURLToPath } from 'node:url';
50
- import { gzipSync } from 'node:zlib';
51
-
52
- import { GLOBAL_MANIFEST, pyResolve } from '../hosts.mjs';
53
- import {
54
- jsonDumpsCompact, parseJson, pyInt, pyPrint, pyStr, pyTruthy, pyUnquote, pyWhich, writeText,
55
- } from '../lib/fs.mjs';
56
- import { promptLine } from '../setup.mjs';
57
- // P8c. The reference imports `_update` LAZILY inside the handler, to keep `build` and its
58
- // `sys.path` side effect out of web-import time; ESM has no such side effect and the import is
59
- // static. `js/update.mjs` imports `restartDaemon` from THIS module, so the two form a cycle —
60
- // which resolves because everything either side needs of the other is a hoisted function
61
- // declaration, not a value read at module-evaluation time.
62
- import { preflight } from '../update.mjs';
63
- import { doctrinesForBuild } from '../installs.mjs';
64
- import { NotFound, PREFIX_ROUTES, STATE_ROUTES, webState } from './api.mjs';
65
- import {
66
- apiDeployCmd, apiExcludesMutate, apiInstallCmd, apiInstallToggle, apiMcpToggle,
67
- apiMemoryDelete, apiProfileSave,
68
- apiRestore, apiRulesMutate, apiRulesPromote, apiSelectView, buildOverride, mcpTargetPaths,
69
- } from './actions.mjs';
70
- import { apiActivityToggle } from './activity.mjs';
71
- import { apiDocs, apiDocsPage } from './docs.mjs';
72
- import { JobManager, NOT_PORTED_ACTIONS, actionCommands } from './jobs.mjs';
73
-
74
- export { webState };
75
61
 
76
62
  const ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
77
63
 
78
- /**
79
- * DNS-rebinding guard. The reference's own comment carries the why; the shape is the
80
- * part a port gets wrong. `h.split(":", 1)[0]` keeps everything before the FIRST colon,
81
- * so `localhost:4747` passes and a bare IPv6 literal would not — which is what the
82
- * `[::1]` prefix test above it is for.
83
- */
84
- export function localHost(host) {
85
- const h = (host || '').trim().toLowerCase();
86
- if (h.startsWith('[::1]')) return true;
87
- return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
88
- }
89
-
90
- const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
91
- 'application/manifest+json', 'application/javascript'];
92
- const GZIP_MIN = 1024;
93
-
94
- const CTYPES = {
95
- '.html': 'text/html', '.js': 'text/javascript',
96
- '.css': 'text/css', '.json': 'application/json',
97
- '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
98
- '.woff2': 'font/woff2', '.woff': 'font/woff',
99
- '.webmanifest': 'application/manifest+json',
100
- '.png': 'image/png',
101
- };
102
-
103
- /**
104
- * The API paths this phase does not answer yet.
105
- *
106
- * Without it every one of them would fall through to `serveStatic`, which answers ANY
107
- * unknown path with index.html and a 200 — the SPA fallback. An unported endpoint would
108
- * therefore return a plausible HTML page instead of failing, and the first cell written
109
- * against it in P6b would be the thing that discovered the route was missing. A 501
110
- * naming the phase costs one lookup and cannot be mistaken for a working endpoint.
111
- *
112
- * It shrank to empty in P6g and the DECLARATION STAYS. Every GET the reference answers is now
113
- * answered here, so the two sets hold nothing — but they are half of a partition
114
- * (`test_every_get_route_is_either_ported_or_declared_unported`), and deleting them would let
115
- * the next GET route added to the reference fall through to the SPA with nothing to say so.
116
- * An empty declaration is the partition asserting that there is nothing left to declare.
117
- */
118
- /**
119
- * SPLIT BY VERB SINCE P6b, and the split is the point rather than a tidy-up. Five paths
120
- * answer BOTH verbs with different bodies — `/api/mcp`, `/api/excludes`, `/api/rules`,
121
- * `/api/profile`, `/api/activity` — and P6b ports the GET half of two of them. One set
122
- * keyed on path alone would have taken `/api/excludes` out of the unported list the
123
- * moment its GET landed, and its POST would then have answered `{"error": "not found"}`
124
- * with a 404: a plausible-looking response where the reference returns 200 or 409, which
125
- * is exactly the failure the 501 exists to prevent.
126
- */
127
- /** EMPTY SINCE P6g — every GET the reference answers is answered here. */
128
- export const NOT_PORTED = new Set([]);
129
- export const NOT_PORTED_PREFIXES = [];
130
-
131
- /**
132
- * The POSTs that have not crossed YET — and EMPTY SINCE P6i, which took `/api/install`.
133
- *
134
- * IT STAYS DECLARED, for the same reason the GET pair above stays and the prefix lists have
135
- * stayed since P6g: this set is half of a partition
136
- * (`test_every_post_route_is_either_ported_or_declared_unported`), so an empty set is the
137
- * partition ASSERTING that there is nothing left to declare. Delete it and the next POST route
138
- * added to the reference falls through to `{"error": "not found"}` at 404 — a plausible-looking
139
- * answer where the reference returns 200 or 409 — with nothing to say so.
140
- *
141
- * THE DISPATCHER PROBE NEEDS A TARGET, which an empty set no longer supplies.
142
- * `tests/test_web_server.py` retired its `unportedGet` probe when `NOT_PORTED` emptied in P6g;
143
- * `unportedPost` is retired here for the same reason, and `declinedPost` (`/api/pick-folder`,
144
- * which never crosses) is what keeps the 501 arm of the dispatcher under a live probe.
145
- */
146
- export const NOT_PORTED_POST = new Set([]);
147
- export const NOT_PORTED_POST_PREFIXES = [];
148
-
149
- /**
150
- * The POSTs that will NEVER cross, which is a different claim from the set above.
151
- *
152
- * `/api/pick-folder` opens an OS-NATIVE folder chooser on the daemon host — `osascript` on
153
- * macOS, a one-shot `tkinter` subprocess elsewhere. There is no Node twin that is not a new
154
- * GUI dependency, and reaching for one would also be a sixth `_ALLOWED_SPAWNS` row for a
155
- * modal dialog. The UI already falls back to its editable path field when this endpoint
156
- * fails, which is what a 501 gives it.
157
- *
158
- * SEPARATE FROM `NOT_PORTED_POST` ON PURPOSE. That set is a to-do list every later phase
159
- * shrinks, and folding a permanent decline into it would make the list wrong in the one
160
- * direction nobody checks: a phase that emptied it would have to either port a GUI dialog
161
- * or quietly delete a declaration. Both sets are unioned into the partition the reference's
162
- * routes are cross-checked against, so neither can drift.
163
- */
164
- export const DECLINED_POST = new Set([
165
- '/api/pick-folder',
166
- ]);
167
-
168
- /**
169
- * The POSTs P6f answers — and this table IS the dispatch, not a declaration beside it.
170
- *
171
- * M23's lesson, applied one verb over: `test_the_declared_partition_is_the_one_the_dispatcher_uses`
172
- * exists because a hand-kept list can agree with the reference while the running handler
173
- * does something else. `doPost` looks routes up HERE, and `PORTED_POST` is this table's own
174
- * keys, so the two cannot drift — the same reason `STATE_ROUTES` is a table on the GET side.
175
- *
176
- * THE SECOND COLUMN IS THE 409 CONVENTION, and it is a column because it is not uniform.
177
- * `ok: false` means the write did not happen — a stale fingerprint, a body that never
178
- * parsed, nothing to remove — and the client must re-fetch rather than retry, which is what
179
- * 409 says and 200 does not. Five routes map it. `/api/activity` does NOT: its flag write
180
- * answers 200 whatever `ok` says, and a port that unified the rule would be wrong exactly
181
- * there. `/api/memory/delete` carries no `ok` at all — it deletes, or it raises `NotFound`
182
- * and the outer catch answers 404 — so it is `false` here for a third reason again.
183
- *
184
- * `NotFound` IS NOT CAUGHT PER ROUTE. The reference wraps six of these calls in their own
185
- * `except NotFound` AND wraps the whole of `_post_routes` in another inside `do_POST`. The
186
- * inner six are redundant — every one produces the same `{"error": "not found: …"}` at 404
187
- * — so the twin keeps the outer one only, in `handler`, where `do_POST`'s is.
188
- */
189
- const POST_ROUTES = new Map([
190
- ['/api/mcp', [apiMcpToggle, true]],
191
- // P6i. `_post_routes` wraps this one in its own `except NotFound` and then maps
192
- // `ok: false` to 409 — the same shape as `/api/mcp` beside it, so it fits the table's
193
- // second column exactly and needs no third inline dispatch. The inner catch is redundant
194
- // (see this table's docblock); `handler`'s outer one answers identically.
195
- ['/api/install', [apiInstallToggle, true]],
196
- ['/api/excludes', [apiExcludesMutate, true]],
197
- ['/api/view', [apiSelectView, true]],
198
- ['/api/activity', [apiActivityToggle, false]],
199
- // `(self._read_json_body().get("name") or "")` — the SHELL resolves the name, and the
200
- // endpoint takes a bare slug rather than a body.
201
- ['/api/memory/delete',
202
- [(state, b) => apiMemoryDelete(state, pyTruthy(b.name) ? pyStr(b.name) : ''), false]],
203
- ['/api/rules', [apiRulesMutate, true]],
204
- ['/api/rules/promote', [apiRulesPromote, true]],
205
- ['/api/profile', [apiProfileSave, true]],
206
- ]);
207
-
208
- export const PORTED_POST = [...POST_ROUTES.keys()];
209
-
210
- /**
211
- * The 409 column, exported so it can be cross-checked against the reference's own `200 if
212
- * res.get("ok") else 409` conditionals by `ast`.
213
- *
214
- * A MUTATION IS WHY THIS EXISTS. Giving `/api/activity` the 409 treatment survived the whole
215
- * cell matrix: every toggle a cell can perform succeeds, and reaching the `ok: false` arm
216
- * needs the flag write to raise — which the two runtimes word differently, so no byte
217
- * comparison could hold it. The column is the thing under test and no cell can see it, so
218
- * the gate reads it out of both implementations instead.
219
- */
220
- export const POST_ROUTES_CONVENTION = Object.fromEntries(
221
- [...POST_ROUTES].map(([p, [, okIs409]]) => [p, okIs409]),
222
- );
223
-
224
- /**
225
- * The GET paths the dispatcher answers OUTSIDE the two tables, declared so the partition
226
- * cross-check can see them: `/api/ping` is the shell's own, and the two Docs routes take
227
- * the `?harness=` query param, which a `(state)` table entry has no way to receive.
228
- *
229
- * A declaration is not a dispatcher — M23 — so
230
- * `test_the_declared_partition_is_the_one_the_dispatcher_uses` probes all three against
231
- * the running handler as well.
232
- */
233
- export const PORTED_INLINE = ['/api/ping', '/api/docs', '/api/docs/page/',
234
- '/api/jobs', '/api/jobs/'];
235
-
236
- /**
237
- * The POSTs P6g answers outside `POST_ROUTES`, and they are outside it for a reason the
238
- * table's SECOND COLUMN makes plain.
239
- *
240
- * `POST_ROUTES` maps a route to `(fn, okIs409)` — one handler taking `(state, body)`, one
241
- * boolean deciding 200 against 409. The jobs routes fit neither half. `/api/jobs/<id>/cancel`
242
- * answers 200 or 404 on a lookup, not on an `ok` field; `/api/actions/<x>` answers 202 with a
243
- * job id, 409 when the runner is busy, 404 for an action the table does not name, 501 for one
244
- * that has not crossed, 400 or 409 for a resolver's refusal, and 200 for `restore` — six
245
- * statuses over one prefix, none of them read off `ok`. Bending the column to fit would make
246
- * it lie about the five routes it currently describes exactly, so these dispatch beside the
247
- * table and `tests/test_web_jobs.py` reads them out of `PORTED_POST_INLINE` instead.
248
- */
249
- export const PORTED_POST_INLINE = ['/api/shutdown', '/api/restart', '/api/jobs/',
250
- '/api/actions/'];
251
-
252
- /**
253
- * POST routes THAT NEVER EXISTED ON THE REFERENCE — the first of them, and the reason this set
254
- * has to exist at all.
255
- *
256
- * `test_every_post_route_is_either_ported_or_declared_unported` asserts SET EQUALITY against
257
- * `REF_POST`, a frozen literal of the paths the Python daemon answered. That was exactly right
258
- * while the port was in flight: a route on one side and not the other was a defect either way.
259
- * The port is finished and the reference is deleted, so the assertion now says something else —
260
- * "this daemon may never grow a route" — which is a rule nobody chose.
261
- *
262
- * SO THE PARTITION GAINS A FOURTH PART RATHER THAN LOSING ITS EQUALITY. Widening `REF_POST` would
263
- * have been the cheap edit and it would have been a lie: that list is a RECORD of what the
264
- * reference answered, and a route added in 2026 was never in it. Keeping the record honest and
265
- * declaring the additions beside it keeps both claims checkable — the reference's surface is
266
- * still pinned, and everything past it is enumerated here instead of merely appearing.
267
- *
268
- * A route belongs here only once it is dispatched below; the dispatcher probe is what keeps this
269
- * from becoming a declaration nobody honours.
270
- */
271
- export const POST_BEYOND_REF = new Set([
272
- '/api/reveal',
273
- ]);
274
-
275
- function notPorted(path) {
276
- return NOT_PORTED.has(path) || NOT_PORTED_PREFIXES.some((p) => path.startsWith(p));
277
- }
278
-
279
- function notPortedPost(path) {
280
- return NOT_PORTED_POST.has(path) || DECLINED_POST.has(path)
281
- || NOT_PORTED_POST_PREFIXES.some((p) => path.startsWith(p));
282
- }
283
-
284
- /**
285
- * `Handler._read_json_body` — the drained body as a dict, `{}` for ANYTHING else.
286
- *
287
- * P6a retained the drain with a note that it was measured indistinguishable and stayed
288
- * because "`_read_json_body` needs the bytes from P6f on". This is that caller.
289
- *
290
- * `{}` AND NEVER A RAISE is the contract, and it is what makes a malformed body a 409 or a
291
- * 404 from the endpoint's own validation rather than a 500 from a parser. That matters
292
- * beyond tidiness: P5a's one un-portable line is a `json` error message, because CPython
293
- * and V8 word them differently AND report different offsets — so a 500 carrying a parse
294
- * error is a response the two implementations can never agree on.
295
- *
296
- * `parseJson`, not `JSON.parse`: a body value can be written straight into a user's file
297
- * (`api_profile_save`'s `text`, `api_rules_mutate`'s `title`) and `str(1.0)` is `"1.0"`
298
- * where `String(1)` is `"1"`. A non-object body — a bare list, a number, `null` — takes the
299
- * same `{}` arm as garbage, because `isinstance(obj, dict)` is what the reference asks.
300
- */
301
- function readJsonBody(buf) {
302
- let obj;
303
- try {
304
- obj = parseJson((buf && buf.length ? buf : Buffer.from('{}')).toString('utf-8'));
305
- } catch {
306
- return {};
307
- }
308
- return (obj !== null && typeof obj === 'object' && obj.constructor === Object) ? obj : {};
309
- }
310
-
311
- // ---- the request handler ---------------------------------------------------
312
-
313
- /**
314
- * `make_handler(state, jm, token, dist, holder)` — `jm` is the SECOND argument here as it is
315
- * there. P6a shipped this without it because there was no `JobManager` to pass; every caller
316
- * moved in the same commit that added one.
317
- */
318
- export function makeHandler(state, jm, token, dist, holder = null) {
319
- const staticCache = new Map();
320
-
321
- function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '') {
322
- const headers = { ...(extra || {}) };
323
- if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
324
- && (acceptEncoding || '').includes('gzip')) {
325
- body = gzipSync(body, { level: 6 });
326
- headers['Content-Encoding'] = 'gzip';
327
- headers.Vary = 'Accept-Encoding';
328
- }
329
- res.writeHead(code, {
330
- 'Content-Type': ctype,
331
- 'Content-Length': String(body.length),
332
- ...headers,
333
- });
334
- res.end(body);
335
- }
336
-
337
- function sendJson(res, obj, code = 200, acceptEncoding = '') {
338
- sendBytes(res, Buffer.from(jsonDumpsCompact(obj, { bareInts: true }), 'utf-8'),
339
- 'application/json', code, null, acceptEncoding);
340
- }
341
-
342
- function serveStatic(res, path, acceptEncoding) {
343
- const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
344
- const index = pyResolve(join(dist, 'index.html'));
345
- let fp = pyResolve(join(dist, rel));
346
- // `dist not in fp.parents`: STRICTLY under dist. `Path.parents` never contains the
347
- // path itself, so `fp == dist` takes the fallback too.
348
- if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
349
- if (!isFile(fp)) fp = join(dist, 'index.html');
350
- if (!isFile(fp)) {
351
- return sendJson(res, { error: 'web/dist missing — run the UI build' }, 500, acceptEncoding);
352
- }
353
- let data = staticCache.get(fp);
354
- if (data === undefined) {
355
- data = readFileSync(fp);
356
- staticCache.set(fp, data);
357
- }
358
- let extra = null;
359
- if (basename(fp) === 'index.html') {
360
- const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
361
- data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
362
- extra = { 'Cache-Control': 'no-store' };
363
- } else if (path.includes('/assets/')) {
364
- extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
365
- }
366
- return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
367
- 200, extra, acceptEncoding);
368
- }
369
-
370
- function doGet(req, res, path, ae) {
371
- // The reference's own table first, then the routes that parse the path — and `ping`
372
- // sits between them there too, so the order here is its order.
373
- const route = STATE_ROUTES[path];
374
- if (route !== undefined) return sendJson(res, route(state), 200, ae);
375
- if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
376
- for (const [prefix, handler] of PREFIX_ROUTES) {
377
- if (path.startsWith(prefix)) return sendJson(res, handler(state, path), 200, ae);
378
- }
379
- if (path === '/api/docs') {
380
- return sendJson(res, apiDocs(state, harnessParam(req.url)), 200, ae);
381
- }
382
- if (path.startsWith('/api/docs/page/')) {
383
- const pid = pyUnquote(path.slice('/api/docs/page/'.length));
384
- try {
385
- return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
386
- } catch (e) {
387
- // A kind P6d does not answer says so, rather than falling through to the
388
- // `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
389
- if (e && e.notPorted) return sendJson(res, { error: e.message }, 501, ae);
390
- throw e;
391
- }
392
- }
393
- if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
394
- if (path.startsWith('/api/jobs/')) {
395
- // `path.rsplit("/", 1)[1]` — the LAST segment, so `/api/jobs/a/b` looks up `b` and
396
- // misses, rather than 404ing on the shape.
397
- const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
398
- return j ? sendJson(res, j, 200, ae)
399
- : sendJson(res, { error: 'no such job' }, 404, ae);
400
- }
401
- if (notPorted(path)) {
402
- return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
403
- }
404
- return serveStatic(res, path, ae);
405
- }
406
-
407
- function doPost(req, res, path, ae, body) {
408
- if (!localHost(req.headers.host)) {
409
- return sendJson(res, { error: 'forbidden host' }, 403, ae);
410
- }
411
- if (req.headers['x-geneseed-token'] !== token) {
412
- return sendJson(res, { error: 'forbidden' }, 403, ae);
413
- }
414
- if (path === '/api/shutdown') {
415
- // The one POST route the SHELL owns. It must answer BEFORE it stops, which is why
416
- // the reference runs `srv.shutdown()` on its own thread — called inline it would
417
- // deadlock against `serve_forever`. Here the equivalent is closing after the
418
- // response has flushed. `closeAllConnections` is the other half: `close()` alone
419
- // waits for idle keep-alive sockets, and the client that just sent this request is
420
- // holding one open. The reference drops them by exiting.
421
- res.on('finish', () => {
422
- holder.srv.close();
423
- holder.srv.closeAllConnections();
424
- });
425
- return sendJson(res, { stopping: true }, 200, ae);
426
- }
427
- if (path === '/api/restart') {
428
- // THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
429
- // `requestRestart` spawns a DETACHED `web restart`, which stops whatever daemon the
430
- // record names and starts a fresh one that outlives the caller. Every way of asking it
431
- // a question runs it: `tests/web_golden.py` would have its own server stopped and a
432
- // replacement orphaned into a sandbox about to be deleted, and
433
- // `tests/test_web_server.py`'s dispatcher probe runs in the DEVELOPER'S environment,
434
- // where the replacement would bind 4747 and serve the checkout forever. That is the
435
- // same reasoning P6g used to ban a job cell that starts real work, one step further:
436
- // there the child was merely orphaned, here it is a second daemon.
437
- //
438
- // So the gate is `tests/test_web_daemon.py`, which reads both implementations and
439
- // asserts they dispatch this path to `request_restart(state.theme)` and answer the
440
- // same body. Rule 7 says a declaration is not a dispatcher — this is the one route
441
- // where probing the dispatcher costs more than the assurance is worth, and saying so
442
- // beats a silently missing probe.
443
- requestRestart(state.theme);
444
- return sendJson(res, { restarting: true }, 200, ae);
445
- }
446
- if (path === '/api/reveal') {
447
- // "It asks for a token and a URL and I don't know where to put them." The screen already
448
- // PRINTS the config path above each target's rows; what it could not do is take you there.
449
- //
450
- // THE ALLOWLIST IS THE WHOLE SECURITY OF THIS ENDPOINT, same as the toggle beside it, and
451
- // it is the same allowlist — `mcpTargetPaths()`, one function with two callers rather than
452
- // two copies that drift. An unlisted path is a 404 before anything is opened. Without it
453
- // this is "ask the desktop to open any path on the machine", behind a CSRF token and
454
- // nothing else, and on Windows `openUrl` interpolates into a `cmd` command line with
455
- // `windowsVerbatimArguments` — so the exact-match lookup is also what bounds the strings
456
- // that can ever reach a shell to the ones the machine's own install registry produced.
457
- //
458
- // THE FOLDER, NOT THE FILE. `open`/`start`/`xdg-open` on a `.json` hands it to whatever
459
- // claims that extension — which on a stock Windows box is a browser, i.e. a read-only view
460
- // of the exact file the user is trying to EDIT. The containing folder lands in Explorer /
461
- // Finder / the file manager every time, and the filename is already on screen beside the
462
- // button.
463
- const want = String(readJsonBody(body).path ?? '');
464
- if (!mcpTargetPaths().has(want)) {
465
- return sendJson(res, { error: `not found: ${want || '(none)'}` }, 404, ae);
466
- }
467
- const dir = dirname(want);
468
- // `openUrl` SWALLOWS EVERY FAILURE BY DESIGN (a headless box has no opener), so `ok` here
469
- // means "the path was allowed and the request was made", never "a window appeared". The
470
- // response says which directory, so the UI can print it as the fallback instruction.
471
- openUrl(dir);
472
- return sendJson(res, { ok: true, dir }, 200, ae);
473
- }
474
- const route = POST_ROUTES.get(path);
475
- if (route !== undefined) {
476
- const [fn, okIs409] = route;
477
- const obj = fn(state, readJsonBody(body));
478
- return sendJson(res, obj, (okIs409 && !pyTruthy(obj.ok)) ? 409 : 200, ae);
479
- }
480
- if (path.startsWith('/api/jobs/') && path.endsWith('/cancel')) {
481
- // `path.split("/")[3]`, positional — so `/api/jobs/<id>/cancel` takes `<id>` and a
482
- // deeper path takes its third segment, which is what the reference looks up.
483
- const jid = path.split('/')[3];
484
- return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
485
- : sendJson(res, { error: 'no running job by that id' }, 404, ae);
486
- }
487
- if (path.startsWith('/api/actions/')) {
488
- return doAction(res, path.slice(path.lastIndexOf('/') + 1), readJsonBody(body), ae);
489
- }
490
- if (notPortedPost(path)) {
491
- return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
492
- }
493
- return sendJson(res, { error: 'not found' }, 404, ae);
494
- }
495
-
496
- /**
497
- * `/api/actions/<x>` — the reference's own order, which is the part a port gets wrong.
498
- *
499
- * `restore` is answered BEFORE anything else because it is synchronous and returns a result
500
- * rather than a job id. `install` and `deploy` resolve their argv from the body, and their
501
- * refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
502
- * means the world changed under the client) and 400 for deploy (its path came from the body,
503
- * so a refusal means the body was wrong). That asymmetry is the reference's; a port that
504
- * unified them would be wrong on one of the two and no cell would say which.
505
- *
506
- * `update` COMES NEXT AND IS INLINE, since P8c: it is gated on a LOCAL-ONLY preflight before
507
- * a job is ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI
508
- * renders as an info popup instead of a job that spawns and then fails. Its finish handler is
509
- * its own too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run`), so
510
- * the restart happens HERE, after the job is saved as finished, and only when it succeeded: a
511
- * failed update changed nothing worth reloading and bouncing would disconnect the PWA for no
512
- * reason.
513
- *
514
- * THEN the unported rows, and only then the table. Order matters: `link`/`unlink` are REAL
515
- * actions whose verb has not crossed, and letting them fall through to `actionCommands` would
516
- * answer `{"error": "unknown action link"}` — the same thing a typo gets.
517
- */
518
- function doAction(res, action, body, ae) {
519
- if (action === 'restore') {
520
- return sendJson(res, apiRestore(state, pyTruthy(body.files) ? body.files : []), 200, ae);
521
- }
522
- if (action === 'install') {
523
- const plan = apiInstallCmd(state, body);
524
- if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 409, ae);
525
- return startJob(res, 'install', [plan.cmd], ae);
526
- }
527
- if (action === 'deploy') {
528
- const plan = apiDeployCmd(state, body);
529
- if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 400, ae);
530
- return startJob(res, 'deploy', [plan.cmd], ae);
531
- }
532
- if (action === 'update') {
533
- const pre = preflight();
534
- if (!pre.ok) {
535
- return sendJson(res,
536
- { precondition: pre.code, kind: pre.kind, message: pre.message }, 422, ae);
537
- }
538
- const jid = jm.start('update', actionCommands('update'), (rc) => {
539
- state.refresh();
540
- if (rc === 0) requestRestart(state.theme);
541
- });
542
- if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
543
- return sendJson(res, { job_id: jid }, 202, ae);
544
- }
545
- if (NOT_PORTED_ACTIONS.has(action)) {
546
- return sendJson(res, { error: `not ported yet: /api/actions/${action}` }, 501, ae);
547
- }
548
- // Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
549
- // the deployed theme downstream. Footprint, posture and mode always follow the current
550
- // install, so a re-theme preserves lean/full, the register and the operating mode.
551
- const [theme, emit] = action === 'build'
552
- ? buildOverride(state, body) : [state.theme, state.emit];
553
- // The pack selection follows the install like footprint/posture/mode, and is read here
554
- // rather than left to the table's default: a console Build must re-emit the constitution
555
- // the deployment already carries. Unknown (no `Active packs:` marker) resolves to ALL
556
- // packs — never to `harness.config.json`, which would drop the consent gate.
557
- const cmds = actionCommands(action, {
558
- theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
559
- doctrines: doctrinesForBuild(state.target),
560
- });
561
- if (!pyTruthy(cmds)) {
562
- return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
563
- }
564
- return startJob(res, action, cmds, ae);
565
- }
566
-
567
- /**
568
- * The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
569
- * Build may re-theme the install, and the re-detect must read the new `.geneseed-emit` or
570
- * the console keeps serving the old mode (the line P6b dropped and P6f restored).
571
- */
572
- function startJob(res, action, cmds, ae) {
573
- const jid = jm.start(action, cmds, () => state.refresh());
574
- if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
575
- return sendJson(res, { job_id: jid }, 202, ae);
576
- }
577
-
578
- return function handler(req, res) {
579
- const ae = req.headers['accept-encoding'] || '';
580
- const path = (req.url || '').split('?', 1)[0];
581
- try {
582
- if (req.method === 'GET') {
583
- if (!localHost(req.headers.host)) {
584
- return sendJson(res, { error: 'forbidden host' }, 403, ae);
585
- }
586
- try {
587
- return doGet(req, res, path, ae);
588
- } catch (e) {
589
- // The `NotFound` → 404 convention, which P6c is the first phase to raise. It
590
- // wraps the ROUTE DISPATCH and not each handler, exactly as the reference's
591
- // `try` around `do_GET`'s body does — an endpoint several frames down raises and
592
- // the shell decides the status. Anything else is still the outer 500.
593
- if (e instanceof NotFound) {
594
- return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
595
- }
596
- throw e;
597
- }
598
- }
599
- if (req.method === 'POST') {
600
- // Drain the body BEFORE routing, as the reference does and for its stated
601
- // reason — under keep-alive an unread body is parsed as the next request line,
602
- // and both guards answer without ever reaching a route that would read it.
603
- //
604
- // THIS HALF OF IT IS INDISTINGUISHABLE HERE, and measured rather than assumed:
605
- // two mutations — reading zero bytes, then neither reading nor resuming the
606
- // stream — both survived the keep-alive cell, which the reference needs the
607
- // drain to pass. Node's HTTP parser owns the message boundary, so unread body
608
- // bytes are discarded when the response ends instead of being re-parsed. It
609
- // stays because `_read_json_body` needs the bytes from P6f on, and because a
610
- // shell whose two implementations diverged only under load would be the worst
611
- // kind of divergence to carry. UNREACHABLE is not the word for it: the branch
612
- // runs on every POST, it simply cannot be observed to matter on this runtime.
613
- const length = pyInt(String(req.headers['content-length'] ?? '')) ?? 0;
614
- return readBody(req, length, (buf) => {
615
- req._body = buf;
616
- try {
617
- return doPost(req, res, path, ae, buf);
618
- } catch (e) {
619
- // `do_POST`'s own two-armed catch, and P6f is the phase that needs the first
620
- // arm: `api_mcp_toggle`, `api_select_view`, `api_memory_delete`,
621
- // `api_rules_mutate` and `api_rules_promote` all raise `NotFound` for an
622
- // unknown target, and the reference answers 404. The 500 arm below it is what
623
- // carries `_rule_fields`' ValueError message, which is a 500 there too.
624
- if (e instanceof NotFound) {
625
- return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
626
- }
627
- return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
628
- }
629
- });
630
- }
631
- return sendJson(res, { error: 'not found' }, 404, ae);
632
- } catch (e) {
633
- return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
634
- }
635
- };
636
- }
637
-
638
- /**
639
- * `Handler._harness` — the `?harness=` query param, `null` when absent.
640
- *
641
- * `urllib.parse.parse_qs` with its defaults, reproduced only as far as this one caller
642
- * needs and no further: pairs split on `&` then `=`, values unquoted with `+` meaning a
643
- * space, and a BLANK value dropped (`keep_blank_values=False`), so `?harness=` resolves to
644
- * the installed default rather than to the empty string. The first occurrence wins,
645
- * because `parse_qs` returns a list and the reference takes `[0]`.
646
- *
647
- * THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
648
- * mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
649
- * fall through to the installed default. It stays for the reason `parse_qs`' own default
650
- * exists — the next parameter read through this function may be one whose empty string
651
- * means something — and because a shell whose two implementations agreed only by accident
652
- * is the kind of agreement that stops holding when a caller is added. UNREACHABLE is not
653
- * the word: the branch runs on every `?harness=`, it simply cannot be observed to matter
654
- * through the one consumer it has.
655
- */
656
- function harnessParam(url) {
657
- const at = (url || '').indexOf('?');
658
- if (at < 0) return null;
659
- for (const pair of url.slice(at + 1).split('&')) {
660
- if (!pair) continue;
661
- const eq = pair.indexOf('=');
662
- const k = pyUnquote((eq < 0 ? pair : pair.slice(0, eq)).replace(/\+/g, ' '));
663
- const v = eq < 0 ? '' : pyUnquote(pair.slice(eq + 1).replace(/\+/g, ' '));
664
- if (k === 'harness' && v !== '') return v;
665
- }
666
- return null;
667
- }
668
-
669
- function readBody(req, length, done) {
670
- if (!(length > 0)) {
671
- req.resume();
672
- return done(Buffer.alloc(0));
673
- }
674
- const chunks = [];
675
- let seen = 0;
676
- req.on('data', (c) => {
677
- // Exactly `Content-Length` bytes, as `rfile.read(length)` takes: a longer body is
678
- // the next request's bytes and belongs to the parser, not to this handler.
679
- if (seen >= length) return;
680
- chunks.push(c.subarray(0, length - seen));
681
- seen += c.length;
682
- });
683
- req.on('end', () => done(Buffer.concat(chunks)));
684
- }
685
-
686
- function isFile(p) {
687
- try {
688
- return statSync(p).isFile();
689
- } catch {
690
- return false;
691
- }
692
- }
693
-
694
- /** `bytes.replace(old, new, 1)` — the first occurrence only. */
695
- function replaceOnce(buf, find, repl) {
696
- const at = buf.indexOf(find);
697
- if (at < 0) return buf;
698
- return Buffer.concat([buf.subarray(0, at), repl, buf.subarray(at + find.length)]);
699
- }
700
-
701
- // ---- the daemon record -----------------------------------------------------
702
-
703
- const statePath = (target) => join(target, '.geneseed-web.json');
704
-
705
- export function readDaemon(target) {
706
- try {
707
- return JSON.parse(readFileSync(statePath(target), 'utf-8'));
708
- } catch {
709
- return null;
710
- }
711
- }
712
-
713
- export function writeDaemon(target, data) {
714
- try {
715
- mkdirSync(target, { recursive: true });
716
- const p = statePath(target);
717
- writeText(p, jsonDumpsCompact(data, { bareInts: true }));
718
- // Owner-only: the record carries the API token. A no-op on Windows, where the
719
- // mode only maps to the read-only attribute — same as the reference's os.chmod.
720
- chmodSync(p, 0o600);
721
- } catch { /* the reference swallows OSError here too */ }
722
- }
723
-
724
- export function clearDaemon(target) {
725
- try {
726
- unlinkSync(statePath(target));
727
- } catch { /* already gone */ }
728
- }
729
-
730
- // ---- daemon mode -----------------------------------------------------------
731
- //
732
- // `geneseed web start|stop|status|restart`. The reference's own banner explains the design
733
- // — the running server records pid/port/token/url in a small JSON file and control is over
734
- // HTTP, so nothing here needs OS process-kill semantics, only a localhost request with the
735
- // token. Two things about the TWIN, neither of which is in that banner:
736
- //
737
- // * `_probe` and `_post_shutdown` are `urllib.request.urlopen` with a timeout, which is
738
- // synchronous. Node has no synchronous HTTP client, so every function below that
739
- // reaches one is `async` and `cmdWeb` awaits it — which is why `bin/geneseed-cli.mjs`
740
- // awaits its dispatch. Nothing about the ORDER of the reference's steps changes.
741
- // * `agent: false` on both requests. Node's global agent has kept sockets alive by
742
- // default since v19, and a pooled idle socket keeps the event loop referenced — a
743
- // `web status` that answered correctly would then hang for the keep-alive timeout
744
- // instead of exiting. Python's `urlopen` opens and closes a connection per call.
745
-
746
- /** `f"{url}/api/ping"` answered 200 — the cheap liveness probe both `status` and the
747
- * launcher's wait loop are built on. Every failure mode is False, as the reference's
748
- * `(URLError, OSError, ValueError)` catch is. */
749
- export function probe(url, timeout = 1.5) {
750
- return new Promise((done) => {
751
- let settled = false;
752
- const finish = (v) => { if (!settled) { settled = true; done(v); } };
753
- let req;
754
- try {
755
- req = httpRequest(`${url}/api/ping`, { method: 'GET', agent: false }, (res) => {
756
- res.resume(); // drain, or the socket never closes
757
- finish(res.statusCode === 200);
758
- });
759
- } catch {
760
- return finish(false); // a malformed url is `ValueError` there and a throw here
761
- }
762
- req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
763
- req.on('error', () => finish(false));
764
- req.end();
765
- return undefined;
766
- });
767
- }
768
-
769
- /** The token-carrying POST `geneseed web stop` and the in-page Stop button both send. */
770
- export function postShutdown(url, token, timeout = 3.0) {
771
- return new Promise((done) => {
772
- let settled = false;
773
- const finish = (v) => { if (!settled) { settled = true; done(v); } };
774
- let req;
775
- try {
776
- req = httpRequest(`${url}/api/shutdown`, {
777
- method: 'POST',
778
- agent: false,
779
- headers: { 'X-Geneseed-Token': token, 'Content-Type': 'application/json' },
780
- }, (res) => {
781
- res.resume();
782
- finish(res.statusCode === 200);
783
- });
784
- } catch {
785
- return finish(false);
786
- }
787
- req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
788
- req.on('error', () => finish(false));
789
- req.end('{}');
790
- return undefined;
791
- });
792
- }
793
-
794
- /**
795
- * The record ONLY if a server is answering; otherwise the stale file is deleted.
796
- *
797
- * The deletion is the part a port drops, and it is what stops a machine that crashed with
798
- * a daemon running from re-probing a dead URL on every `status` forever. Both of its arms
799
- * are gated by a `harness_golden` cell — a record whose url nothing answers, and a record
800
- * carrying no url at all, which skips the probe and still clears.
801
- */
802
- export async function liveDaemon(target) {
803
- const st = readDaemon(target);
804
- if (st && pyTruthy(st.url) && await probe(st.url)) return st;
805
- if (pyTruthy(st)) clearDaemon(target);
806
- return null;
807
- }
808
-
809
- const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
810
-
811
- /** `st.get(k)` rendered as Python renders it — a missing key prints `None`, not
812
- * `undefined`. Only reachable through a hand-edited record, and one character of
813
- * divergence in a message is still a divergence. */
814
- const none = (v) => (v === undefined || v === null ? 'None' : String(v));
815
-
816
- /**
817
- * `_spawn_detached` — the daemon launcher, and the second `_ALLOWED_SPAWNS` entry this
818
- * module owns.
819
- *
820
- * WHY THERE IS NO IN-PROCESS EQUIVALENT: the whole contract is that the server OUTLIVES
821
- * the process that started it. `geneseed web start` prints a URL and returns to the shell;
822
- * the server it started must still be there. A thread, a `worker_thread` or an in-process
823
- * `serve()` all die with the launcher.
824
- *
825
- * WHAT IT STARTS IS THIS PROGRAM, named as `process.execPath` + `bin/geneseed-cli.mjs` —
826
- * the running interpreter by absolute path and this repo's own CLI. The reference names
827
- * `sys.executable` + `rituals/harness.py`; each side re-executes ITSELF, which is what
828
- * makes this not the passthrough the port exists to remove, and
829
- * `tests/test_web_daemon.py` asserts the twin's argv contains no `python`, no `harness.py`
830
- * and no `build.py` at all.
831
- */
832
- function spawnDetached(webArgs, log) {
833
- const cmd = [process.execPath, join(ROOT, 'bin', 'geneseed-cli.mjs'), 'web', ...webArgs];
834
- let out = 'ignore';
835
- try {
836
- out = openSync(log, 'a'); // `open(log, "ab")`; DEVNULL on OSError, as there
837
- } catch { /* the reference falls back to DEVNULL on exactly this failure */ }
838
- const child = spawn(cmd[0], cmd.slice(1), {
839
- // `DETACHED_PROCESS | NEW_PROCESS_GROUP` on Windows and `start_new_session` on POSIX
840
- // are both `detached` here; `unref()` is what lets the launcher's event loop end while
841
- // the child runs on, which is `Popen`'s default and Node's is not.
842
- detached: true, windowsHide: true, stdio: ['ignore', out, out],
843
- });
844
- child.unref();
845
- }
846
-
847
- /** The `web` argv the launcher passes. Pure, and compared against `_daemon_args` by the
848
- * corpus: the binary differs per side by design, the FLAGS may not. */
849
- export function daemonArgs(port, theme) {
850
- const args = ['--daemon-internal', '--port', String(port), '--no-browser'];
851
- if (pyTruthy(theme)) args.push('--theme', theme);
852
- return args;
853
- }
854
-
855
- /** The same for the out-of-band restart — `_restart_args`. */
856
- export function restartArgs(theme) {
857
- const args = ['restart', '--no-browser'];
858
- if (pyTruthy(theme)) args.push('--theme', theme);
859
- return args;
860
- }
861
-
862
- /** `request_restart` — a detached `web restart`, so the new server survives the exit of
863
- * the very process asking for it. Called by POST `/api/restart`. */
864
- export function requestRestart(theme) {
865
- const target = webState(theme).target;
866
- spawnDetached(restartArgs(theme), join(target, '.geneseed-web.log'));
867
- }
868
-
869
- /**
870
- * `webbrowser.open(url)`, which has no Node equivalent at all — there is no stdlib module
871
- * that knows what a desktop's default browser is.
872
- *
873
- * IT IS A MACHINE PRIMITIVE, the same class as `taskkill` and `java -version` and the
874
- * opposite of a passthrough: it asks the OS to open a URL and hands it nothing of this
875
- * program's work. Declining it instead would have been a silent regression in the one verb
876
- * whose job is to put a UI on screen — `geneseed web` would print an address and do
877
- * nothing, and no cell would say so because every cell passes `--no-browser`.
878
- *
879
- * The reference swallows every failure here (`contextlib.suppress(Exception)` in two call
880
- * sites and a bare `except` in the third), and so does this.
881
- */
882
- function openUrl(url) {
883
- const [file, args, extra] = process.platform === 'win32'
884
- // `start` is a cmd BUILTIN, not a program. The empty `""` is its title argument —
885
- // without it `start "http://…"` treats the quoted URL as the window title and opens
886
- // nothing. Verbatim args because Node would otherwise re-quote the whole string.
887
- ? [process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
888
- { windowsVerbatimArguments: true }]
889
- : (process.platform === 'darwin' ? ['open', [url], {}] : ['xdg-open', [url], {}]);
890
- try {
891
- const child = spawn(file, args, {
892
- detached: true, stdio: 'ignore', windowsHide: true, ...extra,
893
- });
894
- // ⚠ ENOENT ARRIVES AS AN EVENT, NOT AS A THROW, AND THE `try` ABOVE CANNOT SEE IT.
895
- // This is where the reference's suppression failed to survive translation: Python's
896
- // `subprocess` raises `FileNotFoundError` synchronously, so `contextlib.suppress(Exception)`
897
- // really did swallow a missing opener. Node's `spawn` reports the same condition on the
898
- // child's `error` event, and an 'error' with no listener is re-thrown as an uncaught
899
- // exception. So the port's `try/catch` read as equivalent and suppressed nothing.
900
- //
901
- // MEASURED, in the packaged no-python container: `geneseed web` on a headless Linux box
902
- // printed `Error: spawn xdg-open ENOENT`. Found by the acceptance gate rather than by the
903
- // 690-cell corpus, and the docblock above says why the corpus never could — every cell
904
- // passes `--no-browser`, so no recorded cell has ever executed this line.
905
- child.on('error', () => { /* no opener on this machine; the daemon is still serving */ });
906
- child.unref();
907
- } catch { /* the reference suppresses every exception from this call too */ }
908
- }
909
-
910
- export async function startDaemon(theme, port, openBrowser = true) {
911
- const { target } = webState(theme);
912
- const st = await liveDaemon(target);
913
- if (st) {
914
- pyPrint(`[web] already running on ${st.url} (pid ${none(st.pid)})\n`);
915
- if (openBrowser) openUrl(st.url);
916
- return 0;
917
- }
918
- clearDaemon(target);
919
- const log = join(target, '.geneseed-web.log');
920
- spawnDetached(daemonArgs(port, theme), log);
921
- // `for _ in range(60): ... time.sleep(0.2)` — twelve seconds for the child to bind and
922
- // write its record. The probe timeout is the reference's shorter 0.5 here, not 1.5.
923
- for (let i = 0; i < 60; i += 1) {
924
- const rec = readDaemon(target);
925
- // eslint-disable-next-line no-await-in-loop
926
- if (rec && pyTruthy(rec.url) && await probe(rec.url, 0.5)) {
927
- pyPrint(`[web] Geneseed UI on ${rec.url} (theme: ${none(rec.theme)}, `
928
- + `pid ${none(rec.pid)})\n`);
929
- pyPrint('[web] running in the background — `geneseed web stop` to stop it.\n');
930
- if (openBrowser) openUrl(rec.url);
931
- return 0;
932
- }
933
- // eslint-disable-next-line no-await-in-loop
934
- await sleep(200);
935
- }
936
- pyPrint('[web] daemon did not come up in time — check the log:\n');
937
- pyPrint(` ${log}\n`);
938
- return 1;
939
- }
940
-
941
- export async function stopDaemon(theme = null) {
942
- const { target } = webState(theme);
943
- const st = readDaemon(target);
944
- if (!pyTruthy(st) || !pyTruthy(st.url)) {
945
- // NO `clearDaemon` HERE, and the asymmetry with `liveDaemon` is the reference's. A
946
- // record that names no url is left exactly where it is; `web status` on the same file
947
- // deletes it. Both directions are cells, because a port that unified them would delete
948
- // a file the reference keeps and stdout would look identical.
949
- pyPrint('[web] no running server recorded.\n');
950
- return 0;
951
- }
952
- if (await postShutdown(st.url, pyTruthy(st.token) ? st.token : '')) {
953
- clearDaemon(target);
954
- pyPrint(`[web] stopped (pid ${none(st.pid)}).\n`);
955
- return 0;
956
- }
957
- clearDaemon(target);
958
- pyPrint('[web] no live server (cleared a stale record).\n');
959
- return 0;
960
- }
961
-
962
- export async function statusDaemon(theme = null) {
963
- const { target } = webState(theme);
964
- const st = await liveDaemon(target);
965
- if (st) {
966
- pyPrint(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
967
- return 0;
968
- }
969
- // EXIT 1, which is the verb's whole contract for a script. The snapshot's `<exit>`
970
- // column is what gates it.
971
- pyPrint('[web] not running.\n');
972
- return 1;
973
- }
974
-
975
- export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
976
- onlyIfRunning = false) {
977
- const { target } = webState(theme);
978
- const st = readDaemon(target);
979
- const live = (await liveDaemon(target)) !== null;
980
- if (onlyIfRunning && !live) return 0;
981
- const usePort = (st && pyTruthy(st.port) ? st.port : null) || port;
982
- let open = openBrowser;
983
- if (live) {
984
- // A live daemon means a tab is already open on this (preserved) port and will
985
- // reconnect on its own — the reference's `ponytail:` note, kept.
986
- open = false;
987
- await stopDaemon(theme);
988
- for (let i = 0; i < 50; i += 1) {
989
- // eslint-disable-next-line no-await-in-loop
990
- if (!await probe(`http://127.0.0.1:${usePort}`, 0.2)) break;
991
- // eslint-disable-next-line no-await-in-loop
992
- await sleep(100);
993
- }
994
- }
995
- return startDaemon(theme, usePort, open);
996
- }
64
+ // Re-exported so the console's state lives in one place and its importers are unchanged.
65
+ export { webState };
997
66
 
998
67
  // ---- serve -----------------------------------------------------------------
999
68
 
@@ -1028,7 +97,7 @@ export function buildPlan(dist, webDir, npm, interactive) {
1028
97
  * library to call instead; a Node twin that reimplemented `npm install` is not a thing that
1029
98
  * exists.
1030
99
  *
1031
- * `shell` ON WINDOWS, and it is required rather than stylistic: `pyWhich('npm')` resolves
100
+ * `shell` ON WINDOWS, and it is required rather than stylistic: `which('npm')` resolves
1032
101
  * to `npm.CMD` through PATHEXT, and Node refuses to `spawn` a `.cmd` directly. The path is
1033
102
  * quoted because Node wraps the whole command in one more pair and `cmd /s` strips only
1034
103
  * the outermost — `C:\Program Files\…` would otherwise split at the space.
@@ -1036,13 +105,13 @@ export function buildPlan(dist, webDir, npm, interactive) {
1036
105
  export function npmBuild(npm, webDir) {
1037
106
  const win = process.platform === 'win32';
1038
107
  for (const step of [['install'], ['run', 'build']]) {
1039
- pyPrint(`[web] npm ${step.join(' ')} ...\n`);
108
+ printOut(`[web] npm ${step.join(' ')} ...\n`);
1040
109
  const r = spawnSync(win ? `"${npm}"` : npm, step, {
1041
110
  cwd: webDir, stdio: 'inherit', shell: win,
1042
111
  });
1043
112
  const code = r.status === null || r.status === undefined ? 1 : r.status;
1044
113
  if (code) {
1045
- pyPrint(`[web] npm ${step.join(' ')} failed (exit ${code}).\n`);
114
+ printOut(`[web] npm ${step.join(' ')} failed (exit ${code}).\n`);
1046
115
  return code;
1047
116
  }
1048
117
  }
@@ -1056,22 +125,22 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
1056
125
  const manual = ' cd web && npm install && npm run build';
1057
126
  // `sys.stdin.isatty()`. `process.stdin.isTTY` is `undefined` rather than false on a pipe,
1058
127
  // which is the same trap `cmdSetup` documents.
1059
- const plan = buildPlan(dist, webDir, pyWhich('npm'), Boolean(process.stdin.isTTY));
128
+ const plan = buildPlan(dist, webDir, which('npm'), Boolean(process.stdin.isTTY));
1060
129
  if (plan === 'no-source') {
1061
- pyPrint(`[web] web/ sources are missing from ${ROOT}.\n`);
1062
- pyPrint(' Run `geneseed upgrade` to fetch them (twice on installs whose\n');
1063
- pyPrint(' updater predates web/ in the sync list).\n');
130
+ printOut(`[web] web/ sources are missing from ${ROOT}.\n`);
131
+ printOut(' Run `geneseed upgrade` to fetch them (twice on installs whose\n');
132
+ printOut(' updater predates web/ in the sync list).\n');
1064
133
  return 1;
1065
134
  }
1066
135
  if (plan === 'no-npm') {
1067
- pyPrint('[web] web/dist is missing and npm was not found. Install Node.js,\n');
1068
- pyPrint(' then build the UI:\n');
1069
- pyPrint(`${manual}\n`);
136
+ printOut('[web] web/dist is missing and npm was not found. Install Node.js,\n');
137
+ printOut(' then build the UI:\n');
138
+ printOut(`${manual}\n`);
1070
139
  return 1;
1071
140
  }
1072
141
  if (plan === 'no-tty') {
1073
- pyPrint('[web] web/dist is missing. Build the UI first:\n');
1074
- pyPrint(`${manual}\n`);
142
+ printOut('[web] web/dist is missing. Build the UI first:\n');
143
+ printOut(`${manual}\n`);
1075
144
  return 1;
1076
145
  }
1077
146
  if (plan === 'ask') {
@@ -1079,22 +148,22 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
1079
148
  // it is the one a naive port gets backwards: `input()` RAISES at EOF, so the reference
1080
149
  // reads it as "no", while a read that returned `''` would fall into the empty-answer
1081
150
  // arm and start an npm install nobody asked for. `promptLine` returns null for exactly
1082
- // that case — see its docblock in js/setup.mjs.
151
+ // that case — see its docblock in js/maintain/setup.mjs.
1083
152
  const line = promptLine('[web] UI not built — run npm install && npm run build now? [Y/n] ');
1084
153
  const answer = line === null ? 'n' : line;
1085
154
  if (['', 'y', 'yes'].includes(answer.trim().toLowerCase())) {
1086
- const code = npmBuild(pyWhich('npm'), webDir);
155
+ const code = npmBuild(which('npm'), webDir);
1087
156
  if (code) return code;
1088
157
  } else {
1089
- pyPrint('[web] skipped. Build the UI manually:\n');
1090
- pyPrint(`${manual}\n`);
158
+ printOut('[web] skipped. Build the UI manually:\n');
159
+ printOut(`${manual}\n`);
1091
160
  return 0;
1092
161
  }
1093
162
  }
1094
163
  const state = webState(theme);
1095
164
  if (!existsSync(join(state.target, GLOBAL_MANIFEST))) {
1096
- pyPrint(`[web] no deployed harness at ${state.target}.\n`);
1097
- pyPrint(' Run `geneseed setup` first — serving anyway (read-only UI).\n');
165
+ printOut(`[web] no deployed harness at ${state.target}.\n`);
166
+ printOut(' Run `geneseed setup` first — serving anyway (read-only UI).\n');
1098
167
  }
1099
168
  const token = randomBytes(24).toString('base64url');
1100
169
  const holder = {};
@@ -1119,15 +188,15 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
1119
188
  token, theme: state.theme, started: Math.floor(Date.now() / 1000),
1120
189
  });
1121
190
  }
1122
- pyPrint(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
1123
- pyPrint(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
191
+ printOut(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
192
+ printOut(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
1124
193
  if (openBrowser) openUrl(url);
1125
194
  // `except KeyboardInterrupt: print("\n[web] stopped.")` around `serve_forever`. Node
1126
195
  // kills on SIGINT by default, which would skip the message AND the record cleanup in
1127
196
  // the `finally` below — a foreground `--daemon-internal` server stopped with Ctrl-C
1128
197
  // would leave a record pointing at a dead port.
1129
198
  process.on('SIGINT', () => {
1130
- pyPrint('\n[web] stopped.\n');
199
+ printOut('\n[web] stopped.\n');
1131
200
  srv.close();
1132
201
  srv.closeAllConnections();
1133
202
  });
@@ -1156,8 +225,8 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
1156
225
  */
1157
226
  export async function cmdWeb(args) {
1158
227
  // `--port` is `type=int` on the reference, and `bin/geneseed-cli.mjs`'s `ints` column
1159
- // refuses a non-integer before this runs — so `pyInt` cannot be null here.
1160
- const port = args.port === null ? 4747 : pyInt(args.port);
228
+ // refuses a non-integer before this runs — so `parseIntStrict` cannot be null here.
229
+ const port = args.port === null ? 4747 : parseIntStrict(args.port);
1161
230
  const openBrowser = !args.noBrowser;
1162
231
  if (args.action === 'start') return startDaemon(args.theme, port, openBrowser);
1163
232
  if (args.action === 'stop') return stopDaemon(args.theme);
@@ -1181,12 +250,12 @@ async function main(argv) {
1181
250
  let daemon = false;
1182
251
  let openBrowser = true;
1183
252
  for (let i = 0; i < argv.length; i += 1) {
1184
- if (argv[i] === '--theme') { theme = argv[i + 1]; i += 1; } else if (argv[i] === '--port') { port = pyInt(String(argv[i + 1])) ?? 4747; i += 1; } else if (argv[i] === '--daemon-internal') daemon = true;
253
+ if (argv[i] === '--theme') { theme = argv[i + 1]; i += 1; } else if (argv[i] === '--port') { port = parseIntStrict(String(argv[i + 1])) ?? 4747; i += 1; } else if (argv[i] === '--daemon-internal') daemon = true;
1185
254
  else if (argv[i] === '--no-browser') openBrowser = false;
1186
255
  }
1187
256
  return serve({ theme, port, openBrowser, daemon });
1188
257
  }
1189
258
 
1190
- if (process.argv[1] && pyResolve(process.argv[1]) === pyResolve(fileURLToPath(import.meta.url))) {
259
+ if (process.argv[1] && resolvePath(process.argv[1]) === resolvePath(fileURLToPath(import.meta.url))) {
1191
260
  process.exitCode = await main(process.argv.slice(2));
1192
261
  }