geneseed 3.1.1 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -1
- package/DESIGN.md +15 -15
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +111 -83
- package/docs/web/exclusions.md +1 -1
- package/docs/web/footprint.md +1 -1
- package/docs/web/foreman-mode.md +2 -2
- package/docs/web/themes-author.md +1 -1
- package/docs/web-ui.md +41 -29
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +27 -28
- package/js/{render.mjs → build/render.mjs} +13 -9
- package/js/{checkout.mjs → build/source.mjs} +9 -9
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +18 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +63 -55
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +34 -32
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +84 -85
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +154 -51
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +21 -19
- package/js/web/routes.mjs +245 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-B49fE_ug.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-abt7hzD2.js} +1 -1
- package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-D3_bVkih.js} +1 -1
- package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-hXp7srsx.js} +1 -1
- package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-DvYMtlvl.js} +1 -1
- package/web/dist/assets/Harness-BIWei7N5.js +1 -0
- package/web/dist/assets/Laws-CxmnRdwt.js +3 -0
- package/web/dist/assets/{Library-DXhTN9ju.js → Library-MjbOkHIW.js} +1 -1
- package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-Dx_w0faR.js} +1 -1
- package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-BRFvv2gr.js} +1 -1
- package/web/dist/assets/{Rules-eND7hJv9.js → Rules-CfsV1EVy.js} +1 -1
- package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DArA6IHS.js} +1 -1
- package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-C9bZOEZk.js} +1 -1
- package/web/dist/assets/{activity-Dmb0FTAx.js → activity-Cc0n2jwx.js} +1 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +1 -0
- package/web/dist/assets/index-DC3-GlCY.js +1 -0
- package/web/dist/assets/index-DP035wPa.js +12 -0
- package/web/dist/assets/{index-Dg58ScBe.js → index-dHi-7M-F.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +5 -19
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1750
- package/js/emit.mjs +0 -1411
- package/web/dist/assets/About-DyK7yRy7.js +0 -1
- package/web/dist/assets/Harnesses-DgRyOviS.js +0 -1
- package/web/dist/assets/Laws-CWk3Mpri.js +0 -3
- package/web/dist/assets/Themes-CoAWuIeM.js +0 -2
- package/web/dist/assets/index-B-mXquBZ.css +0 -1
- package/web/dist/assets/index-CH_Z725C.js +0 -12
- package/web/dist/assets/index-jrY4m9jP.js +0 -1
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request handler — one function per request, from method dispatch to bytes on the wire.
|
|
3
|
+
*
|
|
4
|
+
* `makeHandler(state, jm, token, dist, holder)` is a FACTORY and the shape is load-bearing: three
|
|
5
|
+
* test call sites build one directly and drive it without a socket, which is what makes the web
|
|
6
|
+
* surface testable at all. Keep the arity.
|
|
7
|
+
*
|
|
8
|
+
* What it actually uses of HTTP is small and deliberate — a whole-buffer `gzipSync` over a few
|
|
9
|
+
* content types, two Cache-Control policies, a CSRF token on POST only, and a Host guard against
|
|
10
|
+
* DNS rebinding. No streaming, no SSE, no ranges, no cookies, no CORS. The routes it honours are
|
|
11
|
+
* declared next door in `routes.mjs`; the daemon that starts it is in `daemon.mjs`.
|
|
12
|
+
*/
|
|
13
|
+
import { resolvePath } from '../hosts/hosts.mjs';
|
|
14
|
+
import { doctrinesForBuild } from '../hosts/installs.mjs';
|
|
15
|
+
import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
|
|
16
|
+
import { parseIntStrict, percentDecode } from '../lib/text.mjs';
|
|
17
|
+
import { preflight } from '../maintain/update.mjs';
|
|
18
|
+
import { apiDeployCmd, apiInstallCmd, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
|
|
19
|
+
import { NotFound, PREFIX_ROUTES, STATE_ROUTES } from './api.mjs';
|
|
20
|
+
import { openUrl, requestRestart } from './daemon.mjs';
|
|
21
|
+
import { apiDocs, apiDocsPage } from './docs.mjs';
|
|
22
|
+
import { NOT_PORTED_ACTIONS, actionCommands } from './jobs.mjs';
|
|
23
|
+
import { POST_ROUTES, notPorted, notPortedPost, readJsonBody } from './routes.mjs';
|
|
24
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
25
|
+
import { basename, dirname, extname, join, sep } from 'node:path';
|
|
26
|
+
import { gzipSync } from 'node:zlib';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* DNS-rebinding guard. The reference's own comment carries the why; the shape is the
|
|
30
|
+
* part a port gets wrong. `h.split(":", 1)[0]` keeps everything before the FIRST colon,
|
|
31
|
+
* so `localhost:4747` passes and a bare IPv6 literal would not — which is what the
|
|
32
|
+
* `[::1]` prefix test above it is for.
|
|
33
|
+
*/
|
|
34
|
+
export function localHost(host) {
|
|
35
|
+
const h = (host || '').trim().toLowerCase();
|
|
36
|
+
if (h.startsWith('[::1]')) return true;
|
|
37
|
+
return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
|
|
41
|
+
'application/manifest+json', 'application/javascript'];
|
|
42
|
+
const GZIP_MIN = 1024;
|
|
43
|
+
|
|
44
|
+
const CTYPES = {
|
|
45
|
+
'.html': 'text/html', '.js': 'text/javascript',
|
|
46
|
+
'.css': 'text/css', '.json': 'application/json',
|
|
47
|
+
'.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
48
|
+
'.woff2': 'font/woff2', '.woff': 'font/woff',
|
|
49
|
+
'.webmanifest': 'application/manifest+json',
|
|
50
|
+
'.png': 'image/png',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ---- the request handler ---------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `make_handler(state, jm, token, dist, holder)` — `jm` is the SECOND argument here as it is
|
|
57
|
+
* there. P6a shipped this without it because there was no `JobManager` to pass; every caller
|
|
58
|
+
* moved in the same commit that added one.
|
|
59
|
+
*/
|
|
60
|
+
export function makeHandler(state, jm, token, dist, holder = null) {
|
|
61
|
+
const staticCache = new Map();
|
|
62
|
+
|
|
63
|
+
function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '') {
|
|
64
|
+
const headers = { ...(extra || {}) };
|
|
65
|
+
if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
|
|
66
|
+
&& (acceptEncoding || '').includes('gzip')) {
|
|
67
|
+
body = gzipSync(body, { level: 6 });
|
|
68
|
+
headers['Content-Encoding'] = 'gzip';
|
|
69
|
+
headers.Vary = 'Accept-Encoding';
|
|
70
|
+
}
|
|
71
|
+
res.writeHead(code, {
|
|
72
|
+
'Content-Type': ctype,
|
|
73
|
+
'Content-Length': String(body.length),
|
|
74
|
+
...headers,
|
|
75
|
+
});
|
|
76
|
+
res.end(body);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sendJson(res, obj, code = 200, acceptEncoding = '') {
|
|
80
|
+
sendBytes(res, Buffer.from(jsonDumpsCompact(obj, { bareInts: true }), 'utf-8'),
|
|
81
|
+
'application/json', code, null, acceptEncoding);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function serveStatic(res, path, acceptEncoding) {
|
|
85
|
+
const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
|
|
86
|
+
const index = resolvePath(join(dist, 'index.html'));
|
|
87
|
+
let fp = resolvePath(join(dist, rel));
|
|
88
|
+
// `dist not in fp.parents`: STRICTLY under dist. `Path.parents` never contains the
|
|
89
|
+
// path itself, so `fp == dist` takes the fallback too.
|
|
90
|
+
if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
|
|
91
|
+
if (!isFile(fp)) fp = join(dist, 'index.html');
|
|
92
|
+
if (!isFile(fp)) {
|
|
93
|
+
return sendJson(res, { error: 'web/dist missing — run the UI build' }, 500, acceptEncoding);
|
|
94
|
+
}
|
|
95
|
+
let data = staticCache.get(fp);
|
|
96
|
+
if (data === undefined) {
|
|
97
|
+
data = readFileSync(fp);
|
|
98
|
+
staticCache.set(fp, data);
|
|
99
|
+
}
|
|
100
|
+
let extra = null;
|
|
101
|
+
if (basename(fp) === 'index.html') {
|
|
102
|
+
const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
|
|
103
|
+
data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
|
|
104
|
+
extra = { 'Cache-Control': 'no-store' };
|
|
105
|
+
} else if (path.includes('/assets/')) {
|
|
106
|
+
extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
|
|
107
|
+
}
|
|
108
|
+
return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
|
|
109
|
+
200, extra, acceptEncoding);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function doGet(req, res, path, ae) {
|
|
113
|
+
// The reference's own table first, then the routes that parse the path — and `ping`
|
|
114
|
+
// sits between them there too, so the order here is its order.
|
|
115
|
+
const route = STATE_ROUTES[path];
|
|
116
|
+
if (route !== undefined) return sendJson(res, route(state), 200, ae);
|
|
117
|
+
if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
|
|
118
|
+
for (const [prefix, handler] of PREFIX_ROUTES) {
|
|
119
|
+
if (path.startsWith(prefix)) return sendJson(res, handler(state, path), 200, ae);
|
|
120
|
+
}
|
|
121
|
+
if (path === '/api/docs') {
|
|
122
|
+
return sendJson(res, apiDocs(state, harnessParam(req.url)), 200, ae);
|
|
123
|
+
}
|
|
124
|
+
if (path.startsWith('/api/docs/page/')) {
|
|
125
|
+
const pid = percentDecode(path.slice('/api/docs/page/'.length));
|
|
126
|
+
try {
|
|
127
|
+
return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
// A kind P6d does not answer says so, rather than falling through to the
|
|
130
|
+
// `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
|
|
131
|
+
if (e && e.notPorted) return sendJson(res, { error: e.message }, 501, ae);
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
|
|
136
|
+
if (path.startsWith('/api/jobs/')) {
|
|
137
|
+
// `path.rsplit("/", 1)[1]` — the LAST segment, so `/api/jobs/a/b` looks up `b` and
|
|
138
|
+
// misses, rather than 404ing on the shape.
|
|
139
|
+
const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
|
|
140
|
+
return j ? sendJson(res, j, 200, ae)
|
|
141
|
+
: sendJson(res, { error: 'no such job' }, 404, ae);
|
|
142
|
+
}
|
|
143
|
+
if (notPorted(path)) {
|
|
144
|
+
return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
|
|
145
|
+
}
|
|
146
|
+
return serveStatic(res, path, ae);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function doPost(req, res, path, ae, body) {
|
|
150
|
+
if (!localHost(req.headers.host)) {
|
|
151
|
+
return sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
152
|
+
}
|
|
153
|
+
if (req.headers['x-geneseed-token'] !== token) {
|
|
154
|
+
return sendJson(res, { error: 'forbidden' }, 403, ae);
|
|
155
|
+
}
|
|
156
|
+
if (path === '/api/shutdown') {
|
|
157
|
+
// The one POST route the SHELL owns. It must answer BEFORE it stops, which is why
|
|
158
|
+
// the reference runs `srv.shutdown()` on its own thread — called inline it would
|
|
159
|
+
// deadlock against `serve_forever`. Here the equivalent is closing after the
|
|
160
|
+
// response has flushed. `closeAllConnections` is the other half: `close()` alone
|
|
161
|
+
// waits for idle keep-alive sockets, and the client that just sent this request is
|
|
162
|
+
// holding one open. The reference drops them by exiting.
|
|
163
|
+
res.on('finish', () => {
|
|
164
|
+
holder.srv.close();
|
|
165
|
+
holder.srv.closeAllConnections();
|
|
166
|
+
});
|
|
167
|
+
return sendJson(res, { stopping: true }, 200, ae);
|
|
168
|
+
}
|
|
169
|
+
if (path === '/api/restart') {
|
|
170
|
+
// THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
|
|
171
|
+
// `requestRestart` spawns a DETACHED `web restart`, which stops whatever daemon the
|
|
172
|
+
// record names and starts a fresh one that outlives the caller. Every way of asking it
|
|
173
|
+
// a question runs it: `tests/web_golden.py` would have its own server stopped and a
|
|
174
|
+
// replacement orphaned into a sandbox about to be deleted, and
|
|
175
|
+
// `tests/test_web_server.py`'s dispatcher probe runs in the DEVELOPER'S environment,
|
|
176
|
+
// where the replacement would bind 4747 and serve the checkout forever. That is the
|
|
177
|
+
// same reasoning P6g used to ban a job cell that starts real work, one step further:
|
|
178
|
+
// there the child was merely orphaned, here it is a second daemon.
|
|
179
|
+
//
|
|
180
|
+
// So the gate is `tests/test_web_daemon.py`, which reads both implementations and
|
|
181
|
+
// asserts they dispatch this path to `request_restart(state.theme)` and answer the
|
|
182
|
+
// same body. Rule 7 says a declaration is not a dispatcher — this is the one route
|
|
183
|
+
// where probing the dispatcher costs more than the assurance is worth, and saying so
|
|
184
|
+
// beats a silently missing probe.
|
|
185
|
+
requestRestart(state.theme);
|
|
186
|
+
return sendJson(res, { restarting: true }, 200, ae);
|
|
187
|
+
}
|
|
188
|
+
if (path === '/api/reveal') {
|
|
189
|
+
// "It asks for a token and a URL and I don't know where to put them." The screen already
|
|
190
|
+
// PRINTS the config path above each target's rows; what it could not do is take you there.
|
|
191
|
+
//
|
|
192
|
+
// THE ALLOWLIST IS THE WHOLE SECURITY OF THIS ENDPOINT, same as the toggle beside it, and
|
|
193
|
+
// it is the same allowlist — `mcpTargetPaths()`, one function with two callers rather than
|
|
194
|
+
// two copies that drift. An unlisted path is a 404 before anything is opened. Without it
|
|
195
|
+
// this is "ask the desktop to open any path on the machine", behind a CSRF token and
|
|
196
|
+
// nothing else, and on Windows `openUrl` interpolates into a `cmd` command line with
|
|
197
|
+
// `windowsVerbatimArguments` — so the exact-match lookup is also what bounds the strings
|
|
198
|
+
// that can ever reach a shell to the ones the machine's own install registry produced.
|
|
199
|
+
//
|
|
200
|
+
// THE FOLDER, NOT THE FILE. `open`/`start`/`xdg-open` on a `.json` hands it to whatever
|
|
201
|
+
// claims that extension — which on a stock Windows box is a browser, i.e. a read-only view
|
|
202
|
+
// of the exact file the user is trying to EDIT. The containing folder lands in Explorer /
|
|
203
|
+
// Finder / the file manager every time, and the filename is already on screen beside the
|
|
204
|
+
// button.
|
|
205
|
+
const want = String(readJsonBody(body).path ?? '');
|
|
206
|
+
if (!mcpTargetPaths().has(want)) {
|
|
207
|
+
return sendJson(res, { error: `not found: ${want || '(none)'}` }, 404, ae);
|
|
208
|
+
}
|
|
209
|
+
const dir = dirname(want);
|
|
210
|
+
// `openUrl` SWALLOWS EVERY FAILURE BY DESIGN (a headless box has no opener), so `ok` here
|
|
211
|
+
// means "the path was allowed and the request was made", never "a window appeared". The
|
|
212
|
+
// response says which directory, so the UI can print it as the fallback instruction.
|
|
213
|
+
openUrl(dir);
|
|
214
|
+
return sendJson(res, { ok: true, dir }, 200, ae);
|
|
215
|
+
}
|
|
216
|
+
const route = POST_ROUTES.get(path);
|
|
217
|
+
if (route !== undefined) {
|
|
218
|
+
const [fn, okIs409] = route;
|
|
219
|
+
const obj = fn(state, readJsonBody(body));
|
|
220
|
+
return sendJson(res, obj, (okIs409 && !isTruthy(obj.ok)) ? 409 : 200, ae);
|
|
221
|
+
}
|
|
222
|
+
if (path.startsWith('/api/jobs/') && path.endsWith('/cancel')) {
|
|
223
|
+
// `path.split("/")[3]`, positional — so `/api/jobs/<id>/cancel` takes `<id>` and a
|
|
224
|
+
// deeper path takes its third segment, which is what the reference looks up.
|
|
225
|
+
const jid = path.split('/')[3];
|
|
226
|
+
return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
|
|
227
|
+
: sendJson(res, { error: 'no running job by that id' }, 404, ae);
|
|
228
|
+
}
|
|
229
|
+
if (path.startsWith('/api/actions/')) {
|
|
230
|
+
return doAction(res, path.slice(path.lastIndexOf('/') + 1), readJsonBody(body), ae);
|
|
231
|
+
}
|
|
232
|
+
if (notPortedPost(path)) {
|
|
233
|
+
return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
|
|
234
|
+
}
|
|
235
|
+
return sendJson(res, { error: 'not found' }, 404, ae);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `/api/actions/<x>` — the reference's own order, which is the part a port gets wrong.
|
|
240
|
+
*
|
|
241
|
+
* `restore` is answered BEFORE anything else because it is synchronous and returns a result
|
|
242
|
+
* rather than a job id. `install` and `deploy` resolve their argv from the body, and their
|
|
243
|
+
* refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
|
|
244
|
+
* means the world changed under the client) and 400 for deploy (its path came from the body,
|
|
245
|
+
* so a refusal means the body was wrong). That asymmetry is the reference's; a port that
|
|
246
|
+
* unified them would be wrong on one of the two and no cell would say which.
|
|
247
|
+
*
|
|
248
|
+
* `update` COMES NEXT AND IS INLINE, since P8c: it is gated on a LOCAL-ONLY preflight before
|
|
249
|
+
* a job is ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI
|
|
250
|
+
* renders as an info popup instead of a job that spawns and then fails. Its finish handler is
|
|
251
|
+
* its own too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run`), so
|
|
252
|
+
* the restart happens HERE, after the job is saved as finished, and only when it succeeded: a
|
|
253
|
+
* failed update changed nothing worth reloading and bouncing would disconnect the PWA for no
|
|
254
|
+
* reason.
|
|
255
|
+
*
|
|
256
|
+
* THEN the unported rows, and only then the table. Order matters: `link`/`unlink` are REAL
|
|
257
|
+
* actions whose verb has not crossed, and letting them fall through to `actionCommands` would
|
|
258
|
+
* answer `{"error": "unknown action link"}` — the same thing a typo gets.
|
|
259
|
+
*/
|
|
260
|
+
function doAction(res, action, body, ae) {
|
|
261
|
+
if (action === 'restore') {
|
|
262
|
+
return sendJson(res, apiRestore(state, isTruthy(body.files) ? body.files : []), 200, ae);
|
|
263
|
+
}
|
|
264
|
+
if (action === 'install') {
|
|
265
|
+
const plan = apiInstallCmd(state, body);
|
|
266
|
+
if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 409, ae);
|
|
267
|
+
return startJob(res, 'install', [plan.cmd], ae);
|
|
268
|
+
}
|
|
269
|
+
if (action === 'deploy') {
|
|
270
|
+
const plan = apiDeployCmd(state, body);
|
|
271
|
+
if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 400, ae);
|
|
272
|
+
return startJob(res, 'deploy', [plan.cmd], ae);
|
|
273
|
+
}
|
|
274
|
+
if (action === 'update') {
|
|
275
|
+
const pre = preflight();
|
|
276
|
+
if (!pre.ok) {
|
|
277
|
+
return sendJson(res,
|
|
278
|
+
{ precondition: pre.code, kind: pre.kind, message: pre.message }, 422, ae);
|
|
279
|
+
}
|
|
280
|
+
const jid = jm.start('update', actionCommands('update'), (rc) => {
|
|
281
|
+
state.refresh();
|
|
282
|
+
if (rc === 0) requestRestart(state.theme);
|
|
283
|
+
});
|
|
284
|
+
if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
|
|
285
|
+
return sendJson(res, { job_id: jid }, 202, ae);
|
|
286
|
+
}
|
|
287
|
+
if (NOT_PORTED_ACTIONS.has(action)) {
|
|
288
|
+
return sendJson(res, { error: `not ported yet: /api/actions/${action}` }, 501, ae);
|
|
289
|
+
}
|
|
290
|
+
// Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
|
|
291
|
+
// the deployed theme downstream. Footprint, posture and mode always follow the current
|
|
292
|
+
// install, so a re-theme preserves lean/full, the register and the operating mode.
|
|
293
|
+
const [theme, emit] = action === 'build'
|
|
294
|
+
? buildOverride(state, body) : [state.theme, state.emit];
|
|
295
|
+
// The pack selection follows the install like footprint/posture/mode, and is read here
|
|
296
|
+
// rather than left to the table's default: a console Build must re-emit the constitution
|
|
297
|
+
// the deployment already carries. Unknown (no `Active packs:` marker) resolves to ALL
|
|
298
|
+
// packs — never to `harness.config.json`, which would drop the consent gate.
|
|
299
|
+
const cmds = actionCommands(action, {
|
|
300
|
+
theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
|
|
301
|
+
doctrines: doctrinesForBuild(state.target),
|
|
302
|
+
});
|
|
303
|
+
if (!isTruthy(cmds)) {
|
|
304
|
+
return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
|
|
305
|
+
}
|
|
306
|
+
return startJob(res, action, cmds, ae);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
|
|
311
|
+
* Build may re-theme the install, and the re-detect must read the new `.geneseed-emit` or
|
|
312
|
+
* the console keeps serving the old mode (the line P6b dropped and P6f restored).
|
|
313
|
+
*/
|
|
314
|
+
function startJob(res, action, cmds, ae) {
|
|
315
|
+
const jid = jm.start(action, cmds, () => state.refresh());
|
|
316
|
+
if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
|
|
317
|
+
return sendJson(res, { job_id: jid }, 202, ae);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return function handler(req, res) {
|
|
321
|
+
const ae = req.headers['accept-encoding'] || '';
|
|
322
|
+
const path = (req.url || '').split('?', 1)[0];
|
|
323
|
+
try {
|
|
324
|
+
if (req.method === 'GET') {
|
|
325
|
+
if (!localHost(req.headers.host)) {
|
|
326
|
+
return sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
return doGet(req, res, path, ae);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// The `NotFound` → 404 convention, which P6c is the first phase to raise. It
|
|
332
|
+
// wraps the ROUTE DISPATCH and not each handler, exactly as the reference's
|
|
333
|
+
// `try` around `do_GET`'s body does — an endpoint several frames down raises and
|
|
334
|
+
// the shell decides the status. Anything else is still the outer 500.
|
|
335
|
+
if (e instanceof NotFound) {
|
|
336
|
+
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
337
|
+
}
|
|
338
|
+
throw e;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (req.method === 'POST') {
|
|
342
|
+
// Drain the body BEFORE routing, as the reference does and for its stated
|
|
343
|
+
// reason — under keep-alive an unread body is parsed as the next request line,
|
|
344
|
+
// and both guards answer without ever reaching a route that would read it.
|
|
345
|
+
//
|
|
346
|
+
// THIS HALF OF IT IS INDISTINGUISHABLE HERE, and measured rather than assumed:
|
|
347
|
+
// two mutations — reading zero bytes, then neither reading nor resuming the
|
|
348
|
+
// stream — both survived the keep-alive cell, which the reference needs the
|
|
349
|
+
// drain to pass. Node's HTTP parser owns the message boundary, so unread body
|
|
350
|
+
// bytes are discarded when the response ends instead of being re-parsed. It
|
|
351
|
+
// stays because `_read_json_body` needs the bytes from P6f on, and because a
|
|
352
|
+
// shell whose two implementations diverged only under load would be the worst
|
|
353
|
+
// kind of divergence to carry. UNREACHABLE is not the word for it: the branch
|
|
354
|
+
// runs on every POST, it simply cannot be observed to matter on this runtime.
|
|
355
|
+
const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
|
|
356
|
+
return readBody(req, length, (buf) => {
|
|
357
|
+
req._body = buf;
|
|
358
|
+
try {
|
|
359
|
+
return doPost(req, res, path, ae, buf);
|
|
360
|
+
} catch (e) {
|
|
361
|
+
// `do_POST`'s own two-armed catch, and P6f is the phase that needs the first
|
|
362
|
+
// arm: `api_mcp_toggle`, `api_select_view`, `api_memory_delete`,
|
|
363
|
+
// `api_rules_mutate` and `api_rules_promote` all raise `NotFound` for an
|
|
364
|
+
// unknown target, and the reference answers 404. The 500 arm below it is what
|
|
365
|
+
// carries `_rule_fields`' ValueError message, which is a 500 there too.
|
|
366
|
+
if (e instanceof NotFound) {
|
|
367
|
+
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
368
|
+
}
|
|
369
|
+
return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return sendJson(res, { error: 'not found' }, 404, ae);
|
|
374
|
+
} catch (e) {
|
|
375
|
+
return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `Handler._harness` — the `?harness=` query param, `null` when absent.
|
|
382
|
+
*
|
|
383
|
+
* `urllib.parse.parse_qs` with its defaults, reproduced only as far as this one caller
|
|
384
|
+
* needs and no further: pairs split on `&` then `=`, values unquoted with `+` meaning a
|
|
385
|
+
* space, and a BLANK value dropped (`keep_blank_values=False`), so `?harness=` resolves to
|
|
386
|
+
* the installed default rather than to the empty string. The first occurrence wins,
|
|
387
|
+
* because `parse_qs` returns a list and the reference takes `[0]`.
|
|
388
|
+
*
|
|
389
|
+
* THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
|
|
390
|
+
* mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
|
|
391
|
+
* fall through to the installed default. It stays for the reason `parse_qs`' own default
|
|
392
|
+
* exists — the next parameter read through this function may be one whose empty string
|
|
393
|
+
* means something — and because a shell whose two implementations agreed only by accident
|
|
394
|
+
* is the kind of agreement that stops holding when a caller is added. UNREACHABLE is not
|
|
395
|
+
* the word: the branch runs on every `?harness=`, it simply cannot be observed to matter
|
|
396
|
+
* through the one consumer it has.
|
|
397
|
+
*/
|
|
398
|
+
function harnessParam(url) {
|
|
399
|
+
const at = (url || '').indexOf('?');
|
|
400
|
+
if (at < 0) return null;
|
|
401
|
+
for (const pair of url.slice(at + 1).split('&')) {
|
|
402
|
+
if (!pair) continue;
|
|
403
|
+
const eq = pair.indexOf('=');
|
|
404
|
+
const k = percentDecode((eq < 0 ? pair : pair.slice(0, eq)).replace(/\+/g, ' '));
|
|
405
|
+
const v = eq < 0 ? '' : percentDecode(pair.slice(eq + 1).replace(/\+/g, ' '));
|
|
406
|
+
if (k === 'harness' && v !== '') return v;
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readBody(req, length, done) {
|
|
412
|
+
if (!(length > 0)) {
|
|
413
|
+
req.resume();
|
|
414
|
+
return done(Buffer.alloc(0));
|
|
415
|
+
}
|
|
416
|
+
const chunks = [];
|
|
417
|
+
let seen = 0;
|
|
418
|
+
req.on('data', (c) => {
|
|
419
|
+
// Exactly `Content-Length` bytes, as `rfile.read(length)` takes: a longer body is
|
|
420
|
+
// the next request's bytes and belongs to the parser, not to this handler.
|
|
421
|
+
if (seen >= length) return;
|
|
422
|
+
chunks.push(c.subarray(0, length - seen));
|
|
423
|
+
seen += c.length;
|
|
424
|
+
});
|
|
425
|
+
req.on('end', () => done(Buffer.concat(chunks)));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function isFile(p) {
|
|
429
|
+
try {
|
|
430
|
+
return statSync(p).isFile();
|
|
431
|
+
} catch {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** `bytes.replace(old, new, 1)` — the first occurrence only. */
|
|
437
|
+
function replaceOnce(buf, find, repl) {
|
|
438
|
+
const at = buf.indexOf(find);
|
|
439
|
+
if (at < 0) return buf;
|
|
440
|
+
return Buffer.concat([buf.subarray(0, at), repl, buf.subarray(at + find.length)]);
|
|
441
|
+
}
|
|
442
|
+
|
package/js/web/jobs.mjs
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* the chain on the first non-zero. In-process that would be a return value threaded through
|
|
34
34
|
* every verb's error paths, several of which currently end at `process.exitCode`.
|
|
35
35
|
*
|
|
36
|
-
* SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/
|
|
36
|
+
* SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/build-driver.mjs <build args>`
|
|
37
37
|
* for the three rows that run the GENERATOR, which is `build.py`'s twin and the same program
|
|
38
38
|
* by a different entry. `process.execPath` is the running interpreter by absolute path, so
|
|
39
39
|
* stripping `node` off PATH cannot change what starts. Nothing here can name `python`.
|
|
@@ -47,15 +47,16 @@
|
|
|
47
47
|
* primitive, not this program.
|
|
48
48
|
*
|
|
49
49
|
* ---------------------------------------------------------------------------------------
|
|
50
|
-
* THE PARTITION —
|
|
50
|
+
* THE PARTITION — all eight action rows cross, and the declaration outlives them.
|
|
51
51
|
*
|
|
52
|
-
* `doctor`, `build`, `build-all`, `export`, `uninstall` and
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
52
|
+
* `doctor`, `build`, `build-all`, `export`, `uninstall`, `update`, `link` and `unlink` each
|
|
53
|
+
* name a verb `bin/geneseed-cli.mjs` runs. `link`/`unlink` were the last two: they ran
|
|
54
|
+
* `harness.py link|unlink` until P10b, and a runner that spawned the Python harness would
|
|
55
|
+
* have put Python back into a "no Python needed" install.
|
|
56
|
+
*
|
|
57
|
+
* `NOT_PORTED_ACTIONS` IS EMPTY AND STILL DECLARED, which is the point of it: a ninth action
|
|
58
|
+
* added later cannot quietly answer "unknown action", because `tests/unit/web_jobs.test.mjs`
|
|
59
|
+
* holds the two sets against `actionCommands`' own keys.
|
|
59
60
|
*
|
|
60
61
|
* ---------------------------------------------------------------------------------------
|
|
61
62
|
* THREE DETAILS OF `_run`, EACH A COMMENT EXPLAINING A BUG IT ALREADY FIXED.
|
|
@@ -64,7 +65,7 @@
|
|
|
64
65
|
* the daemon mid-job — which killed the job's own tracking and left the console on `running`
|
|
65
66
|
* forever. It was set here before any ported row read it, because it was P6g's CONTRACT WITH
|
|
66
67
|
* P8, and a contract written after the fact is a contract that was broken once first. P8a is
|
|
67
|
-
* where `js/update.mjs` began honouring it, and P8c is where the `update` row that triggers
|
|
68
|
+
* where `js/maintain/update.mjs` began honouring it, and P8c is where the `update` row that triggers
|
|
68
69
|
* it crossed — so it is a live pair now rather than a promise.
|
|
69
70
|
*
|
|
70
71
|
* `PYTHONUNBUFFERED=1` reaches the Python child AND its own Python children, or their stdout
|
|
@@ -107,10 +108,11 @@ import { statSync } from 'node:fs';
|
|
|
107
108
|
import { StringDecoder } from 'node:string_decoder';
|
|
108
109
|
import path from 'node:path';
|
|
109
110
|
|
|
110
|
-
import { ROOT, PACK_ORDER } from '../
|
|
111
|
-
import { setupBuildArgs } from '../generate.mjs';
|
|
112
|
-
import { isDict } from '../mcp.mjs';
|
|
113
|
-
import {
|
|
111
|
+
import { ROOT, PACK_ORDER } from '../build/source.mjs';
|
|
112
|
+
import { setupBuildArgs } from '../build/generate.mjs';
|
|
113
|
+
import { isDict } from '../hosts/mcp.mjs';
|
|
114
|
+
import { readText, writeText } from '../lib/fs.mjs';
|
|
115
|
+
import { jsonDumpsCompact, parseJson, isTruthy } from '../lib/json.mjs';
|
|
114
116
|
|
|
115
117
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
116
118
|
|
|
@@ -232,7 +234,7 @@ export class JobManager {
|
|
|
232
234
|
return;
|
|
233
235
|
}
|
|
234
236
|
for (const j of Array.isArray(jobs) ? jobs : []) {
|
|
235
|
-
if (isDict(j) &&
|
|
237
|
+
if (isDict(j) && isTruthy(j.id) && j.status !== 'running') this._jobs.set(String(j.id), j);
|
|
236
238
|
}
|
|
237
239
|
}
|
|
238
240
|
|
|
@@ -401,7 +403,7 @@ export class JobManager {
|
|
|
401
403
|
* five. `update` and `export` self-resolve the deployed theme downstream and take no args.
|
|
402
404
|
*
|
|
403
405
|
* THE ARGV HEAD IS THE PORT. The reference names `sys.executable` and `rituals/harness.py` (or
|
|
404
|
-
* `build.py`); this names `process.execPath` and `bin/geneseed-cli.mjs` (or `bin/
|
|
406
|
+
* `build.py`); this names `process.execPath` and `bin/geneseed-cli.mjs` (or `bin/build-driver.mjs`,
|
|
405
407
|
* which is `build.py`'s twin). The two can never be byte-equal, `tests/web_golden.py`
|
|
406
408
|
* normalises the `$ <argv>` echo line for that reason, and `tests/test_web_jobs.py` asserts
|
|
407
409
|
* each side's head ABSOLUTELY while comparing the tails literally — which is where a real port
|
|
@@ -409,7 +411,7 @@ export class JobManager {
|
|
|
409
411
|
*/
|
|
410
412
|
const NODE = () => process.execPath;
|
|
411
413
|
const CLI = () => path.join(ROOT, 'bin', 'geneseed-cli.mjs');
|
|
412
|
-
const GEN = () => path.join(ROOT, 'bin', '
|
|
414
|
+
const GEN = () => path.join(ROOT, 'bin', 'build-driver.mjs');
|
|
413
415
|
|
|
414
416
|
/**
|
|
415
417
|
* The rows whose VERB has not crossed. **EMPTY SINCE P10b, AND STILL DECLARED.**
|
|
@@ -417,7 +419,7 @@ const GEN = () => path.join(ROOT, 'bin', 'geneseed.mjs');
|
|
|
417
419
|
* P6g opened it with three rows and wrote the rule that closes it: each removed by the phase
|
|
418
420
|
* that ports its verb. `update` left in P8c; `link` and `unlink` left in P10b, which is the
|
|
419
421
|
* phase that also had to decide whether those two verbs should exist under npm at all
|
|
420
|
-
* (`js/link.mjs`'s docblock carries the argument, and the deciding vote was these two rows —
|
|
422
|
+
* (`js/hosts/link.mjs`'s docblock carries the argument, and the deciding vote was these two rows —
|
|
421
423
|
* the console's Settings buttons POST here, and only a ported verb can answer them).
|
|
422
424
|
*
|
|
423
425
|
* WHY AN EMPTY SET RATHER THAN A DELETED ONE. The `ast` cross-check in
|
|
@@ -473,7 +475,7 @@ function actionTable({
|
|
|
473
475
|
// P8c. The row names `upgrade`, which is the VERB; `update` is the action name here and
|
|
474
476
|
// argparse's alias there, and the reference's own row is `[py, harness.py, "upgrade"]` for
|
|
475
477
|
// the same reason. The daemon bounce this job must not do itself is `GENESEED_WEB_JOB`'s
|
|
476
|
-
// job, set by `_run` above and read by `js/update.mjs` — P6g's contract with P8, honoured.
|
|
478
|
+
// job, set by `_run` above and read by `js/maintain/update.mjs` — P6g's contract with P8, honoured.
|
|
477
479
|
update: [[NODE(), CLI(), 'upgrade']],
|
|
478
480
|
export: [[NODE(), CLI(), 'diff', '--out']],
|
|
479
481
|
// Local-machine maintenance, surfaced in the web Settings. uninstall keeps memory (never
|