geneseed 3.1.3 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -0
- package/README.md +24 -14
- package/SETUP.md +38 -7
- package/SHIPPED.md +6 -5
- package/adapters/bob/README.md +40 -10
- package/adapters/claude-code/README.md +5 -0
- package/adapters/claude-code/settings.json +8 -1
- package/adapters/copilot/README.md +50 -24
- package/adapters/opencode/README.md +5 -1
- package/adapters/opencode/plugins/geneseed-guard.js +40 -6
- package/bin/build-driver.mjs +143 -89
- package/bin/geneseed-cli.mjs +63 -106
- package/bin/geneseed-hook.mjs +26 -16
- package/docs/candidate-hosts.md +74 -0
- package/docs/extending.md +6 -5
- package/docs/token-footprint.md +32 -21
- package/docs/web-ui.md +5 -13
- package/harness.config.json +1 -1
- package/js/README.md +5 -15
- package/js/build/emit-claude.mjs +26 -12
- package/js/build/emit-common.mjs +63 -114
- package/js/build/emit-opencode.mjs +74 -66
- package/js/build/generate.mjs +4 -6
- package/js/build/render.mjs +50 -32
- package/js/build/source.mjs +29 -8
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/cli-table.json +84 -0
- package/js/hosts/hooks.mjs +243 -81
- package/js/hosts/hosts.mjs +57 -5
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +2 -2
- package/js/hosts/mcp.mjs +3 -1
- package/js/hosts/native.mjs +54 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +206 -115
- package/js/inspect/checks-authoring.mjs +5 -4
- package/js/inspect/checks-build.mjs +1 -1
- package/js/inspect/diff.mjs +1 -3
- package/js/inspect/doctor.mjs +59 -44
- package/js/inspect/excludes.mjs +5 -11
- package/js/inspect/inventory.mjs +38 -56
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +51 -3
- package/js/lib/fs.mjs +34 -0
- package/js/lib/json.mjs +28 -1
- package/js/lib/paths.mjs +0 -10
- package/js/lib/udiff.mjs +11 -16
- package/js/maintain/memory.mjs +2 -4
- package/js/maintain/migrate.mjs +3 -3
- package/js/maintain/uninstall.mjs +127 -88
- package/js/maintain/update.mjs +4 -4
- package/js/ui/anim.mjs +1 -1
- package/js/ui/menu.mjs +1 -1
- package/js/ui/tui.mjs +3 -20
- package/js/web/actions.mjs +230 -217
- package/js/web/activity.mjs +71 -89
- package/js/web/api.mjs +130 -226
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +59 -126
- package/js/web/handler.mjs +94 -123
- package/js/web/jobs.mjs +116 -178
- package/js/web/routes.mjs +47 -192
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +55 -16
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/README.md +3 -3
- package/src/doctrines/process.md +4 -2
- package/src/doctrines/rigor.md +13 -0
- package/src/laws/universal.md +133 -43
- package/src/ontology/universal.md +38 -13
- package/src/skills/_template.md +5 -1
- package/src/skills/bruno-collection-generator.md +63 -0
- package/src/skills/bruno-test-writer.md +62 -0
- package/src/skills/clarify.md +1 -1
- package/src/skills/crash-course.md +1 -0
- package/src/skills/decode.md +1 -0
- package/src/skills/drill.md +1 -0
- package/src/skills/feynman.md +1 -0
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/herdr.md +1 -0
- package/src/skills/ingest.md +2 -1
- package/src/skills/learning-path.md +1 -0
- package/src/skills/opencode-theme.md +1 -0
- package/src/skills/parallel-agents.md +1 -1
- package/src/skills/ponytail.md +2 -2
- package/src/skills/research.md +4 -2
- package/themes/_TEMPLATE.json +5 -0
- package/themes/biker.json +5 -0
- package/themes/commentator.json +5 -0
- package/themes/cyberpunk.json +5 -0
- package/themes/gamer.json +5 -0
- package/themes/imperial.json +5 -0
- package/themes/joker.json +5 -0
- package/themes/marvin.json +5 -0
- package/themes/mean.json +5 -0
- package/themes/military.json +5 -0
- package/themes/neutral.json +5 -0
- package/themes/pirate.json +5 -0
- package/themes/sports.json +5 -0
- package/themes/verstappen.json +5 -0
- package/themes/wizard.json +5 -0
- package/web/dist/assets/Activity-BfVcLrda.js +1 -0
- package/web/dist/assets/ActivityDetail-CQXccGPl.js +1 -0
- package/web/dist/assets/CatalogRow-DALEQImZ.js +1 -0
- package/web/dist/assets/Diff-BIjxXx46.js +5 -0
- package/web/dist/assets/Doctor-CJLbgsrw.js +1 -0
- package/web/dist/assets/FilterInput-CkUCk_lY.js +1 -0
- package/web/dist/assets/GreenhouseView-DfC5IILJ.js +2 -0
- package/web/dist/assets/Harness-Dm5CCU-v.js +1 -0
- package/web/dist/assets/JournalView-Cpu2eI4s.js +1 -0
- package/web/dist/assets/Laws-DDDVFuQz.js +3 -0
- package/web/dist/assets/Library-Dao3T9na.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-C__nuWX5.js} +15 -15
- package/web/dist/assets/OperatorHudView-DEpCtAjP.js +2 -0
- package/web/dist/assets/Profile-BlXtUlh2.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-DIre6gxR.js} +1 -1
- package/web/dist/assets/Skills-rY13ZPhM.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-wW_RS_my.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-DpYPKNrA.js} +1 -1
- package/web/dist/assets/index-BEZvPvpF.js +1 -0
- package/web/dist/assets/index-DNQE5ydy.js +10 -0
- package/web/dist/assets/index-DzdQjvM1.css +1 -0
- package/web/dist/assets/index-PmQGRS1X.js +2 -0
- package/web/dist/assets/jobBuckets-DfTUJUxR.js +1 -0
- package/web/dist/assets/lawCats-DUS9UTH4.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +119 -130
- package/js/web/graph.mjs +0 -0
- package/web/dist/assets/Activity-B49fE_ug.js +0 -1
- package/web/dist/assets/ActivityDetail-abt7hzD2.js +0 -1
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Graph-DvYMtlvl.js +0 -1
- package/web/dist/assets/Harness-BIWei7N5.js +0 -1
- package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
- package/web/dist/assets/Library-MjbOkHIW.js +0 -1
- package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
- package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
- package/web/dist/assets/Skills-DArA6IHS.js +0 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
- package/web/dist/assets/index-DC3-GlCY.js +0 -1
- package/web/dist/assets/index-DP035wPa.js +0 -12
- package/web/dist/assets/index-dHi-7M-F.js +0 -2
- package/web/dist/assets/roman-DkYN0LX9.js +0 -1
package/js/web/handler.mjs
CHANGED
|
@@ -15,21 +15,25 @@ import { doctrinesForBuild } from '../hosts/installs.mjs';
|
|
|
15
15
|
import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
|
|
16
16
|
import { parseIntStrict, percentDecode } from '../lib/text.mjs';
|
|
17
17
|
import { preflight } from '../maintain/update.mjs';
|
|
18
|
-
import { apiDeployCmd, apiInstallCmd, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
|
|
18
|
+
import { apiDeployCmd, apiInstallCmd, apiPickFolder, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
|
|
19
19
|
import { NotFound, PREFIX_ROUTES, STATE_ROUTES } from './api.mjs';
|
|
20
20
|
import { openUrl, requestRestart } from './daemon.mjs';
|
|
21
21
|
import { apiDocs, apiDocsPage } from './docs.mjs';
|
|
22
|
-
import {
|
|
23
|
-
import { POST_ROUTES,
|
|
22
|
+
import { actionCommands } from './jobs.mjs';
|
|
23
|
+
import { POST_ROUTES, readJsonBody } from './routes.mjs';
|
|
24
|
+
import { isFile } from '../lib/fs.mjs';
|
|
24
25
|
import { readFileSync, statSync } from 'node:fs';
|
|
25
26
|
import { basename, dirname, extname, join, sep } from 'node:path';
|
|
26
27
|
import { gzipSync } from 'node:zlib';
|
|
27
28
|
|
|
29
|
+
// `isFile` is owned by `js/lib/fs.mjs` now (single owner). Re-exported here so
|
|
30
|
+
// `js/web/server.mjs`'s existing `import { isFile, ... } from './handler.mjs'` keeps working.
|
|
31
|
+
export { isFile };
|
|
32
|
+
|
|
28
33
|
/**
|
|
29
|
-
* DNS-rebinding guard.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* `[::1]` prefix test above it is for.
|
|
34
|
+
* DNS-rebinding guard. `h.split(":", 1)[0]` keeps everything before the FIRST colon, so
|
|
35
|
+
* `localhost:4747` passes and a bare IPv6 literal would not — which is what the `[::1]`
|
|
36
|
+
* prefix check above it is for.
|
|
33
37
|
*/
|
|
34
38
|
export function localHost(host) {
|
|
35
39
|
const h = (host || '').trim().toLowerCase();
|
|
@@ -52,19 +56,18 @@ const CTYPES = {
|
|
|
52
56
|
|
|
53
57
|
// ---- the request handler ---------------------------------------------------
|
|
54
58
|
|
|
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
59
|
export function makeHandler(state, jm, token, dist, holder = null) {
|
|
60
|
+
// fp -> { mtime, data, gz }. Keyed by path but VALIDATED by mtime on every hit, so a
|
|
61
|
+
// rebuilt web/dist serves fresh bytes without `web restart` — the re-stat costs one
|
|
62
|
+
// syscall against the readFileSync + gzipSync it saves. `gz` is the gzipped body,
|
|
63
|
+
// computed once on the first gzip-accepting request instead of per request.
|
|
61
64
|
const staticCache = new Map();
|
|
62
65
|
|
|
63
|
-
function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '') {
|
|
66
|
+
function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '', gzStore = null) {
|
|
64
67
|
const headers = { ...(extra || {}) };
|
|
65
68
|
if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
|
|
66
69
|
&& (acceptEncoding || '').includes('gzip')) {
|
|
67
|
-
body = gzipSync(body, { level: 6 });
|
|
70
|
+
body = gzStore ? (gzStore.gz ??= gzipSync(body, { level: 6 })) : gzipSync(body, { level: 6 });
|
|
68
71
|
headers['Content-Encoding'] = 'gzip';
|
|
69
72
|
headers.Vary = 'Accept-Encoding';
|
|
70
73
|
}
|
|
@@ -85,33 +88,35 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
85
88
|
const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
|
|
86
89
|
const index = resolvePath(join(dist, 'index.html'));
|
|
87
90
|
let fp = resolvePath(join(dist, rel));
|
|
88
|
-
// `dist
|
|
89
|
-
//
|
|
91
|
+
// STRICTLY under `dist`: `fp === dist` itself must also take the fallback below, which
|
|
92
|
+
// is why this checks `fp !== dist` and not just the prefix.
|
|
90
93
|
if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
|
|
91
94
|
if (!isFile(fp)) fp = join(dist, 'index.html');
|
|
92
95
|
if (!isFile(fp)) {
|
|
93
96
|
return sendJson(res, { error: 'web/dist missing — run the UI build' }, 500, acceptEncoding);
|
|
94
97
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
const mtime = statSync(fp).mtimeMs;
|
|
99
|
+
let entry = staticCache.get(fp);
|
|
100
|
+
if (entry === undefined || entry.mtime !== mtime) {
|
|
101
|
+
entry = { mtime, data: readFileSync(fp), gz: null };
|
|
102
|
+
staticCache.set(fp, entry);
|
|
99
103
|
}
|
|
104
|
+
let data = entry.data;
|
|
100
105
|
let extra = null;
|
|
101
106
|
if (basename(fp) === 'index.html') {
|
|
102
107
|
const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
|
|
103
108
|
data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
|
|
104
109
|
extra = { 'Cache-Control': 'no-store' };
|
|
110
|
+
// The injected bytes differ from `entry.data`, so the gz cache must not serve them.
|
|
111
|
+
entry = null;
|
|
105
112
|
} else if (path.includes('/assets/')) {
|
|
106
113
|
extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
|
|
107
114
|
}
|
|
108
115
|
return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
|
|
109
|
-
200, extra, acceptEncoding);
|
|
116
|
+
200, extra, acceptEncoding, entry);
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
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
120
|
const route = STATE_ROUTES[path];
|
|
116
121
|
if (route !== undefined) return sendJson(res, route(state), 200, ae);
|
|
117
122
|
if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
|
|
@@ -123,26 +128,16 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
123
128
|
}
|
|
124
129
|
if (path.startsWith('/api/docs/page/')) {
|
|
125
130
|
const pid = percentDecode(path.slice('/api/docs/page/'.length));
|
|
126
|
-
|
|
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
|
-
}
|
|
131
|
+
return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
|
|
134
132
|
}
|
|
135
133
|
if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
|
|
136
134
|
if (path.startsWith('/api/jobs/')) {
|
|
137
|
-
//
|
|
138
|
-
//
|
|
135
|
+
// The LAST segment: `/api/jobs/a/b` looks up `b` and misses (no such job) rather than
|
|
136
|
+
// 404ing on the URL shape.
|
|
139
137
|
const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
|
|
140
138
|
return j ? sendJson(res, j, 200, ae)
|
|
141
139
|
: sendJson(res, { error: 'no such job' }, 404, ae);
|
|
142
140
|
}
|
|
143
|
-
if (notPorted(path)) {
|
|
144
|
-
return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
|
|
145
|
-
}
|
|
146
141
|
return serveStatic(res, path, ae);
|
|
147
142
|
}
|
|
148
143
|
|
|
@@ -154,12 +149,10 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
154
149
|
return sendJson(res, { error: 'forbidden' }, 403, ae);
|
|
155
150
|
}
|
|
156
151
|
if (path === '/api/shutdown') {
|
|
157
|
-
// The one POST route the SHELL owns. It must answer BEFORE it stops,
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
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.
|
|
152
|
+
// The one POST route the SHELL owns. It must answer BEFORE it stops, so closing is
|
|
153
|
+
// deferred to `res.on('finish', ...)` so the response flushes first.
|
|
154
|
+
// `closeAllConnections` is the other half: `close()` alone waits for idle keep-alive
|
|
155
|
+
// sockets, and the client that just sent this request is holding one open.
|
|
163
156
|
res.on('finish', () => {
|
|
164
157
|
holder.srv.close();
|
|
165
158
|
holder.srv.closeAllConnections();
|
|
@@ -169,19 +162,15 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
169
162
|
if (path === '/api/restart') {
|
|
170
163
|
// THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
|
|
171
164
|
// `requestRestart` spawns a DETACHED `web restart`, which stops whatever daemon the
|
|
172
|
-
// record names and starts a fresh one that outlives the caller.
|
|
173
|
-
// a
|
|
174
|
-
//
|
|
175
|
-
//
|
|
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.
|
|
165
|
+
// record names and starts a fresh one that outlives the caller. Actually driving this
|
|
166
|
+
// route from a test would either stop the test's own server or, worse, orphan a real
|
|
167
|
+
// daemon that binds 4747 and serves the checkout forever in the developer's own
|
|
168
|
+
// environment.
|
|
179
169
|
//
|
|
180
|
-
// So the gate
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// beats a silently missing probe.
|
|
170
|
+
// So the gate asserts this dispatches to `requestRestart(state.theme)` directly rather
|
|
171
|
+
// than by hitting the route through a live request — a declaration is not normally
|
|
172
|
+
// enough to stand in for a dispatcher, but here probing the dispatcher costs more than
|
|
173
|
+
// the assurance is worth.
|
|
185
174
|
requestRestart(state.theme);
|
|
186
175
|
return sendJson(res, { restarting: true }, 200, ae);
|
|
187
176
|
}
|
|
@@ -213,6 +202,16 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
213
202
|
openUrl(dir);
|
|
214
203
|
return sendJson(res, { ok: true, dir }, 200, ae);
|
|
215
204
|
}
|
|
205
|
+
if (path === '/api/pick-folder') {
|
|
206
|
+
// INLINE, NOT `POST_ROUTES`: that table's `fn(state, body) -> object` shape answers the
|
|
207
|
+
// instant `fn` returns, and this endpoint cannot — it spawns a native dialog that a human
|
|
208
|
+
// may sit in front of for a while. `doPost` returns to its caller immediately, unresolved;
|
|
209
|
+
// `apiPickFolder`'s `done` calls `sendJson` whenever the child actually exits, which is
|
|
210
|
+
// what lets the daemon keep answering every OTHER request while the dialog is open — see
|
|
211
|
+
// `apiPickFolder` in `js/web/actions.mjs` for the async-spawn reasoning.
|
|
212
|
+
apiPickFolder((result) => sendJson(res, result, 200, ae));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
216
215
|
const route = POST_ROUTES.get(path);
|
|
217
216
|
if (route !== undefined) {
|
|
218
217
|
const [fn, okIs409] = route;
|
|
@@ -220,8 +219,8 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
220
219
|
return sendJson(res, obj, (okIs409 && !isTruthy(obj.ok)) ? 409 : 200, ae);
|
|
221
220
|
}
|
|
222
221
|
if (path.startsWith('/api/jobs/') && path.endsWith('/cancel')) {
|
|
223
|
-
//
|
|
224
|
-
//
|
|
222
|
+
// Positional: `/api/jobs/<id>/cancel` takes `<id>` and a deeper path takes its third
|
|
223
|
+
// segment regardless.
|
|
225
224
|
const jid = path.split('/')[3];
|
|
226
225
|
return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
|
|
227
226
|
: sendJson(res, { error: 'no running job by that id' }, 404, ae);
|
|
@@ -229,33 +228,27 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
229
228
|
if (path.startsWith('/api/actions/')) {
|
|
230
229
|
return doAction(res, path.slice(path.lastIndexOf('/') + 1), readJsonBody(body), ae);
|
|
231
230
|
}
|
|
232
|
-
if (notPortedPost(path)) {
|
|
233
|
-
return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
|
|
234
|
-
}
|
|
235
231
|
return sendJson(res, { error: 'not found' }, 404, ae);
|
|
236
232
|
}
|
|
237
233
|
|
|
238
234
|
/**
|
|
239
|
-
* `/api/actions/<x>`
|
|
235
|
+
* `/api/actions/<x>` dispatch order, and the order is deliberate rather than incidental.
|
|
240
236
|
*
|
|
241
237
|
* `restore` is answered BEFORE anything else because it is synchronous and returns a result
|
|
242
238
|
* rather than a job id. `install` and `deploy` resolve their argv from the body, and their
|
|
243
239
|
* refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
|
|
244
240
|
* 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).
|
|
246
|
-
* unified them would be wrong on one of the two and no cell would say which.
|
|
241
|
+
* so a refusal means the body was wrong). Unifying the two would be wrong for one of them.
|
|
247
242
|
*
|
|
248
|
-
* `update` COMES NEXT AND IS INLINE
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* the restart happens HERE, after the job is saved as finished, and only when
|
|
253
|
-
* failed update changed nothing worth reloading and bouncing would disconnect
|
|
254
|
-
* reason.
|
|
243
|
+
* `update` COMES NEXT AND IS INLINE: it is gated on a LOCAL-ONLY preflight before a job is
|
|
244
|
+
* ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI renders
|
|
245
|
+
* as an info popup instead of a job that spawns and then fails. Its finish handler is its own
|
|
246
|
+
* too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run` in
|
|
247
|
+
* `jobs.mjs`), so the restart happens HERE, after the job is saved as finished, and only when
|
|
248
|
+
* it succeeded: a failed update changed nothing worth reloading and bouncing would disconnect
|
|
249
|
+
* the PWA for no reason.
|
|
255
250
|
*
|
|
256
|
-
* THEN the
|
|
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.
|
|
251
|
+
* THEN the table, which answers a 404 for any action it does not name.
|
|
259
252
|
*/
|
|
260
253
|
function doAction(res, action, body, ae) {
|
|
261
254
|
if (action === 'restore') {
|
|
@@ -284,9 +277,6 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
284
277
|
if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
|
|
285
278
|
return sendJson(res, { job_id: jid }, 202, ae);
|
|
286
279
|
}
|
|
287
|
-
if (NOT_PORTED_ACTIONS.has(action)) {
|
|
288
|
-
return sendJson(res, { error: `not ported yet: /api/actions/${action}` }, 501, ae);
|
|
289
|
-
}
|
|
290
280
|
// Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
|
|
291
281
|
// the deployed theme downstream. Footprint, posture and mode always follow the current
|
|
292
282
|
// install, so a re-theme preserves lean/full, the register and the operating mode.
|
|
@@ -309,7 +299,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
309
299
|
/**
|
|
310
300
|
* The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
|
|
311
301
|
* 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
|
|
302
|
+
* the console keeps serving the old mode.
|
|
313
303
|
*/
|
|
314
304
|
function startJob(res, action, cmds, ae) {
|
|
315
305
|
const jid = jm.start(action, cmds, () => state.refresh());
|
|
@@ -328,10 +318,9 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
328
318
|
try {
|
|
329
319
|
return doGet(req, res, path, ae);
|
|
330
320
|
} catch (e) {
|
|
331
|
-
// The `NotFound` → 404 convention
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
// the shell decides the status. Anything else is still the outer 500.
|
|
321
|
+
// The `NotFound` → 404 convention wraps the ROUTE DISPATCH as a whole, not each
|
|
322
|
+
// handler individually: an endpoint several frames down raises and this decides
|
|
323
|
+
// the status. Anything else still falls to the outer 500.
|
|
335
324
|
if (e instanceof NotFound) {
|
|
336
325
|
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
337
326
|
}
|
|
@@ -339,30 +328,20 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
339
328
|
}
|
|
340
329
|
}
|
|
341
330
|
if (req.method === 'POST') {
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
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.
|
|
331
|
+
// The body is drained before routing regardless of what the route needs it for:
|
|
332
|
+
// `readJsonBody` needs the bytes for a real POST body, and Node's own HTTP parser
|
|
333
|
+
// owns the message boundary anyway — unread bytes are discarded when the response
|
|
334
|
+
// ends rather than mis-parsed as the next request line, so this is where the bytes
|
|
335
|
+
// have to come from either way.
|
|
355
336
|
const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
|
|
356
337
|
return readBody(req, length, (buf) => {
|
|
357
338
|
req._body = buf;
|
|
358
339
|
try {
|
|
359
340
|
return doPost(req, res, path, ae, buf);
|
|
360
341
|
} catch (e) {
|
|
361
|
-
// `
|
|
362
|
-
//
|
|
363
|
-
//
|
|
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.
|
|
342
|
+
// `apiMcpToggle`, `apiSelectView`, `apiMemoryDelete`, `apiRulesMutate` and
|
|
343
|
+
// `apiRulesPromote` all raise `NotFound` for an unknown target, mapped to 404
|
|
344
|
+
// here; anything else falls to the 500 arm below, carrying its own message.
|
|
366
345
|
if (e instanceof NotFound) {
|
|
367
346
|
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
368
347
|
}
|
|
@@ -381,10 +360,19 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
381
360
|
* `Handler._harness` — the `?harness=` query param, `null` when absent.
|
|
382
361
|
*
|
|
383
362
|
* `urllib.parse.parse_qs` with its defaults, reproduced only as far as this one caller
|
|
384
|
-
* needs and no further:
|
|
385
|
-
*
|
|
386
|
-
* the
|
|
387
|
-
* because `parse_qs` returns a list and the reference takes `[0]
|
|
363
|
+
* needs and no further: values unquoted with `+` meaning a space, and a BLANK value dropped
|
|
364
|
+
* (`keep_blank_values=False`), so `?harness=` resolves to the installed default rather than
|
|
365
|
+
* to the empty string — `'' || null` is what drops it below. The first occurrence wins,
|
|
366
|
+
* because `parse_qs` returns a list and the reference takes `[0]`, and so does
|
|
367
|
+
* `URLSearchParams.get`.
|
|
368
|
+
*
|
|
369
|
+
* `new URL(url, 'http://x')` rather than a hand-split on `&`/`=`: the base is a throwaway,
|
|
370
|
+
* needed only because `URL` refuses a bare path-plus-query with no scheme. One measured
|
|
371
|
+
* difference from the hand-split version this replaced — found by diffing both against a
|
|
372
|
+
* battery of query strings before this landed — and it is a FIX, not a regression: a literal
|
|
373
|
+
* `#` in the query is a URL FRAGMENT, which `URLSearchParams` correctly excludes and the old
|
|
374
|
+
* split-on-`&` did not, because a fragment is a client-side-only construct an HTTP request
|
|
375
|
+
* line can never actually carry — no `req.url` this handler ever sees can contain one.
|
|
388
376
|
*
|
|
389
377
|
* THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
|
|
390
378
|
* mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
|
|
@@ -396,16 +384,7 @@ export function makeHandler(state, jm, token, dist, holder = null) {
|
|
|
396
384
|
* through the one consumer it has.
|
|
397
385
|
*/
|
|
398
386
|
function harnessParam(url) {
|
|
399
|
-
|
|
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;
|
|
387
|
+
return new URL(url || '', 'http://x').searchParams.get('harness') || null;
|
|
409
388
|
}
|
|
410
389
|
|
|
411
390
|
function readBody(req, length, done) {
|
|
@@ -416,8 +395,8 @@ function readBody(req, length, done) {
|
|
|
416
395
|
const chunks = [];
|
|
417
396
|
let seen = 0;
|
|
418
397
|
req.on('data', (c) => {
|
|
419
|
-
// Exactly `Content-Length` bytes
|
|
420
|
-
//
|
|
398
|
+
// Exactly `Content-Length` bytes: any more belongs to the next request and is the HTTP
|
|
399
|
+
// parser's concern, not this handler's.
|
|
421
400
|
if (seen >= length) return;
|
|
422
401
|
chunks.push(c.subarray(0, length - seen));
|
|
423
402
|
seen += c.length;
|
|
@@ -425,15 +404,7 @@ function readBody(req, length, done) {
|
|
|
425
404
|
req.on('end', () => done(Buffer.concat(chunks)));
|
|
426
405
|
}
|
|
427
406
|
|
|
428
|
-
|
|
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. */
|
|
407
|
+
/** Replaces the first occurrence only. */
|
|
437
408
|
function replaceOnce(buf, find, repl) {
|
|
438
409
|
const at = buf.indexOf(find);
|
|
439
410
|
if (at < 0) return buf;
|