geneseed 3.1.3 → 3.2.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 +70 -0
- package/README.md +2 -2
- package/SHIPPED.md +3 -3
- package/bin/build-driver.mjs +127 -79
- package/bin/geneseed-cli.mjs +42 -84
- package/bin/geneseed-hook.mjs +5 -2
- package/harness.config.json +1 -1
- package/js/README.md +1 -10
- package/js/build/emit-claude.mjs +4 -5
- 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 +13 -13
- package/js/build/source.mjs +7 -6
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/hosts/hooks.mjs +3 -3
- package/js/hosts/hosts.mjs +2 -4
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +1 -1
- package/js/hosts/native.mjs +37 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +33 -110
- 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 +4 -10
- package/js/inspect/inventory.mjs +37 -55
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +1 -1
- 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 +90 -78
- package/js/maintain/update.mjs +2 -2
- 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 +133 -224
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +66 -111
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +78 -97
- package/js/web/jobs.mjs +124 -165
- package/js/web/routes.mjs +73 -152
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/process.md +4 -2
- package/src/laws/universal.md +55 -43
- package/src/skills/clarify.md +1 -1
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/ingest.md +2 -1
- 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 +2 -0
- package/themes/biker.json +2 -0
- package/themes/commentator.json +2 -0
- package/themes/cyberpunk.json +2 -0
- package/themes/gamer.json +2 -0
- package/themes/imperial.json +2 -0
- package/themes/joker.json +2 -0
- package/themes/marvin.json +2 -0
- package/themes/mean.json +2 -0
- package/themes/military.json +2 -0
- package/themes/neutral.json +2 -0
- package/themes/pirate.json +2 -0
- package/themes/sports.json +2 -0
- package/themes/verstappen.json +2 -0
- package/themes/wizard.json +2 -0
- package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
- package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
- package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
- package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
- package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
- package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
- package/web/dist/assets/Harness-vRYs11kX.js +1 -0
- package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
- package/web/dist/assets/Library-CjC7BAWN.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
- package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
- package/web/dist/assets/Skills-BvatJGfe.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
- package/web/dist/assets/index-BnRWQbZb.js +12 -0
- package/web/dist/assets/index-Bx3I7cRW.js +2 -0
- package/web/dist/assets/index-D0f9tJlA.css +1 -0
- package/web/dist/assets/index-DJOhOwwR.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +42 -71
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.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/js/web/jobs.mjs
CHANGED
|
@@ -1,26 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The web console's BACKGROUND JOBS — `
|
|
3
|
-
*
|
|
2
|
+
* The web console's BACKGROUND JOBS — `JobManager`, its process-tree kill and the
|
|
3
|
+
* `actionTable`.
|
|
4
4
|
*
|
|
5
5
|
* ---------------------------------------------------------------------------------------
|
|
6
|
-
* WHY THIS SPAWNS, WHEN NOTHING ELSE IN
|
|
6
|
+
* WHY THIS SPAWNS, WHEN NOTHING ELSE IN THIS REPO IS ALLOWED TO.
|
|
7
7
|
*
|
|
8
|
-
* `bin/geneseed-cli.mjs` carries a transitive `child_process` ALLOW-LIST
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* has to be made rather than assumed.
|
|
8
|
+
* `bin/geneseed-cli.mjs` carries a transitive `child_process` ALLOW-LIST (`ALLOWED_SPAWNS`
|
|
9
|
+
* in `tests/unit/hook_cli.test.mjs`) whose criterion is not "this verb spawns" but "there
|
|
10
|
+
* is no in-process equivalent, and the spawned thing is not this program". This module
|
|
11
|
+
* spawns THIS PROGRAM, which reads like the passthrough the ban exists to prevent, so the
|
|
12
|
+
* argument has to be made rather than assumed.
|
|
14
13
|
*
|
|
15
|
-
* ISOLATION IS THE DISCRIMINATOR
|
|
16
|
-
*
|
|
17
|
-
* the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* daemon. And a `process.exit` anywhere in a callee kills the DAEMON instead of the job —
|
|
23
|
-
* `cmdRebuildAll` already had to grow a throwing `die` for exactly that reason (P5f).
|
|
14
|
+
* ISOLATION IS THE DISCRIMINATOR. Node's server is single-threaded and every ported `cmdX`
|
|
15
|
+
* is synchronous, so calling one in-process would block the event loop for the whole run —
|
|
16
|
+
* and the console's only progress mechanism is `web/src/api/jobs.js` polling
|
|
17
|
+
* `GET /api/jobs/<id>`. An in-process job therefore freezes the poll that exists to watch
|
|
18
|
+
* it, which is worse than not showing progress: the UI cannot tell a running job from a
|
|
19
|
+
* hung daemon. And a `process.exit` anywhere in a callee kills the DAEMON instead of the
|
|
20
|
+
* job — `cmdRebuildAll` already had to grow a throwing `die` for exactly that reason.
|
|
24
21
|
*
|
|
25
22
|
* `worker_threads` IS THE ALTERNATIVE AND IT IS WORSE. It restores concurrency, but a worker
|
|
26
23
|
* re-imports the world per job (the generator, the renderer, every host module), needs a
|
|
@@ -29,30 +26,28 @@
|
|
|
29
26
|
* takes the worker down mid-write, which is the same truncation with more machinery. Strictly
|
|
30
27
|
* more work for strictly less isolation.
|
|
31
28
|
*
|
|
32
|
-
* THE EXIT CODE IS EASIER OUT OF PROCESS, not harder. `_run`
|
|
33
|
-
* the chain on the first non-zero. In-process that would be a return value
|
|
34
|
-
* every verb's error paths, several of which currently end at
|
|
29
|
+
* THE EXIT CODE IS EASIER OUT OF PROCESS, not harder. `_run` awaits the child's exit code
|
|
30
|
+
* and stops the chain on the first non-zero. In-process that would be a return value
|
|
31
|
+
* threaded through every verb's error paths, several of which currently end at
|
|
32
|
+
* `process.exitCode`.
|
|
35
33
|
*
|
|
36
|
-
* SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/build-driver.mjs <build
|
|
37
|
-
* for the three rows that run the GENERATOR,
|
|
38
|
-
*
|
|
39
|
-
*
|
|
34
|
+
* SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/build-driver.mjs <build
|
|
35
|
+
* args>` for the three rows that run the GENERATOR, the same program by a different entry.
|
|
36
|
+
* `process.execPath` is the running interpreter by absolute path, so stripping `node` off
|
|
37
|
+
* PATH cannot change what starts. Nothing here can name `python`.
|
|
40
38
|
*
|
|
41
39
|
* THE SECOND SPAWN IS `taskkill`, and it is a different argument. `cancel()` must kill the
|
|
42
|
-
* job's whole process TREE
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* primitive, not this program.
|
|
40
|
+
* job's whole process TREE: killing only the direct child leaves ITS children holding the
|
|
41
|
+
* stdout pipe, the read loop stays blocked and the job wedges on `running` forever. POSIX
|
|
42
|
+
* gets that in-process (`detached: true` makes a process group and `process.kill(-pid)`
|
|
43
|
+
* signals it). Windows has no process-group kill in Node's standard library at all, so
|
|
44
|
+
* `taskkill /T` is a machine primitive here, not this program.
|
|
48
45
|
*
|
|
49
46
|
* ---------------------------------------------------------------------------------------
|
|
50
47
|
* THE PARTITION — all eight action rows cross, and the declaration outlives them.
|
|
51
48
|
*
|
|
52
49
|
* `doctor`, `build`, `build-all`, `export`, `uninstall`, `update`, `link` and `unlink` each
|
|
53
|
-
* name a verb `bin/geneseed-cli.mjs` runs.
|
|
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.
|
|
50
|
+
* name a verb `bin/geneseed-cli.mjs` runs.
|
|
56
51
|
*
|
|
57
52
|
* `NOT_PORTED_ACTIONS` IS EMPTY AND STILL DECLARED, which is the point of it: a ninth action
|
|
58
53
|
* added later cannot quietly answer "unknown action", because `tests/unit/web_jobs.test.mjs`
|
|
@@ -61,22 +56,16 @@
|
|
|
61
56
|
* ---------------------------------------------------------------------------------------
|
|
62
57
|
* THREE DETAILS OF `_run`, EACH A COMMENT EXPLAINING A BUG IT ALREADY FIXED.
|
|
63
58
|
*
|
|
64
|
-
* `GENESEED_WEB_JOB=1` tells `upgrade` it is running INSIDE this daemon so it must not
|
|
65
|
-
* the daemon mid-job — which
|
|
66
|
-
* forever.
|
|
67
|
-
* P8, and a contract written after the fact is a contract that was broken once first. P8a is
|
|
68
|
-
* where `js/maintain/update.mjs` began honouring it, and P8c is where the `update` row that triggers
|
|
69
|
-
* it crossed — so it is a live pair now rather than a promise.
|
|
59
|
+
* `GENESEED_WEB_JOB=1` tells `upgrade` it is running INSIDE this daemon so it must not
|
|
60
|
+
* bounce the daemon mid-job — which would kill the job's own tracking and leave the
|
|
61
|
+
* console on `running` forever. Set here and read by `js/maintain/update.mjs`.
|
|
70
62
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* `process.exitCode` and return instead, which is what makes the flag unnecessary rather than
|
|
78
|
-
* merely inapplicable. So the variable is NOT set: it would be cargo, and the honest
|
|
79
|
-
* statement is this paragraph.
|
|
63
|
+
* NO UNBUFFERING FLAG IS SET, and that absence is deliberate rather than an oversight:
|
|
64
|
+
* Node's `process.stdout` on a pipe is not block-buffered by libc — every `write` is
|
|
65
|
+
* queued on the stream and flushed by the event loop. The real hazard here is a child
|
|
66
|
+
* calling `process.exit()` with writes still queued, which truncates them; both `bin/`
|
|
67
|
+
* entries set `process.exitCode` and return instead, which is what makes an unbuffering
|
|
68
|
+
* flag unnecessary rather than merely inapplicable.
|
|
80
69
|
*
|
|
81
70
|
* `MEM_CAP` truncates a runaway job's output FROM THE FRONT, so a job that prints forever
|
|
82
71
|
* cannot eat the daemon's memory. `OUTPUT_CAP` is the smaller cap the history FILE keeps.
|
|
@@ -84,44 +73,34 @@
|
|
|
84
73
|
* ---------------------------------------------------------------------------------------
|
|
85
74
|
* TWO DIVERGENCES, DECLARED.
|
|
86
75
|
*
|
|
87
|
-
* 1. THE TWO STREAMS ARE TWO PIPES
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* the failure the cap exists to prevent.
|
|
99
|
-
*
|
|
100
|
-
* NOT PORTED: `JobManager.wait`. It has no caller in `rituals/` — `tests/test_web.py` is its
|
|
101
|
-
* only consumer, twenty times over. Its Node twin is owed the day a Node-side corpus drives
|
|
102
|
-
* the manager directly, which is P9's; until then it would be a function with no caller and no
|
|
103
|
-
* cell, which is the shape this port keeps being bitten by.
|
|
76
|
+
* 1. THE TWO STREAMS ARE TWO PIPES, read and appended separately in arrival order — exact
|
|
77
|
+
* for a line-buffered child writing to only one stream (which is every row today:
|
|
78
|
+
* `rebuild-all` writes stdout only, `diff` writes stderr only), and potentially
|
|
79
|
+
* reordered for a child interleaving both within one tick. Named here rather than left
|
|
80
|
+
* to be discovered, should a future row ever write to both.
|
|
81
|
+
* 2. `_append`'s cap slices UTF-16 code UNITS rather than code POINTS, unlike `tailChars`
|
|
82
|
+
* (used by the history file's cap via `Array.from`), which differs only past two
|
|
83
|
+
* million characters and only by a lone surrogate at the truncation front. The history
|
|
84
|
+
* cap can afford code-point precision because it runs once per job; doing that here,
|
|
85
|
+
* once per streamed chunk, would make a runaway job quadratic — the exact failure the
|
|
86
|
+
* cap exists to prevent.
|
|
104
87
|
*/
|
|
105
88
|
import { spawn, spawnSync } from 'node:child_process';
|
|
106
89
|
import { randomBytes } from 'node:crypto';
|
|
107
|
-
import { statSync } from 'node:fs';
|
|
108
90
|
import { StringDecoder } from 'node:string_decoder';
|
|
109
91
|
import path from 'node:path';
|
|
110
92
|
|
|
111
93
|
import { ROOT, PACK_ORDER } from '../build/source.mjs';
|
|
112
94
|
import { setupBuildArgs } from '../build/generate.mjs';
|
|
113
95
|
import { isDict } from '../hosts/mcp.mjs';
|
|
114
|
-
import { readText, writeText } from '../lib/fs.mjs';
|
|
96
|
+
import { readText, writeText, isFile } from '../lib/fs.mjs';
|
|
115
97
|
import { jsonDumpsCompact, parseJson, isTruthy } from '../lib/json.mjs';
|
|
116
98
|
|
|
117
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
118
|
-
|
|
119
99
|
/**
|
|
120
|
-
*
|
|
100
|
+
* Terminate a job's WHOLE process tree.
|
|
121
101
|
*
|
|
122
|
-
* NEVER THROWS
|
|
123
|
-
*
|
|
124
|
-
* alive with the manager believing it dead.
|
|
102
|
+
* NEVER THROWS: it runs from `cancel()` and from the crash path in `_run`'s `catch`, and an
|
|
103
|
+
* exception in either would leave the child alive with the manager believing it dead.
|
|
125
104
|
*/
|
|
126
105
|
export function killJobTree(child) {
|
|
127
106
|
try {
|
|
@@ -131,30 +110,25 @@ export function killJobTree(child) {
|
|
|
131
110
|
timeout: 15000, windowsHide: true, stdio: 'ignore',
|
|
132
111
|
});
|
|
133
112
|
} else {
|
|
134
|
-
//
|
|
135
|
-
// `detached: true`, which is `start_new_session=True`.
|
|
113
|
+
// The process GROUP exists because `_run` spawns with `detached: true`.
|
|
136
114
|
process.kill(-child.pid, 'SIGTERM');
|
|
137
115
|
}
|
|
138
116
|
} catch {
|
|
139
117
|
try {
|
|
140
118
|
child.kill();
|
|
141
|
-
} catch { /*
|
|
119
|
+
} catch { /* last resort already failed; nothing more to try */ }
|
|
142
120
|
}
|
|
143
121
|
}
|
|
144
122
|
|
|
145
123
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
124
|
+
* Decodes a child's raw stdout/stderr bytes the way a text-mode read would: UTF-8, and
|
|
125
|
+
* newlines folded regardless of the platform.
|
|
148
126
|
*
|
|
149
|
-
* TWO THINGS
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* hands back the bytes. The reference's children are Python processes writing CRLF on Windows;
|
|
155
|
-
* the twin's are Node processes doing the same. Without the fold the job output would carry
|
|
156
|
-
* CRLF on one side and LF on the other, which is P5b's eighth coverage hole with the
|
|
157
|
-
* normalising transport removed.
|
|
127
|
+
* TWO THINGS. `StringDecoder` holds a partial UTF-8 sequence across a chunk boundary — a
|
|
128
|
+
* naive `buf.toString()` per chunk would turn one multi-byte character split across two
|
|
129
|
+
* reads into two replacement characters. And CRLF / a lone CR are both folded to LF before
|
|
130
|
+
* the caller ever sees them: spawned children write CRLF on Windows, and without the fold
|
|
131
|
+
* the job output would carry CRLF on one platform and LF on the other.
|
|
158
132
|
*
|
|
159
133
|
* The pending `\r` is held rather than translated on the spot: a `\r\n` split across two
|
|
160
134
|
* chunks would otherwise become two newlines.
|
|
@@ -186,25 +160,26 @@ function textDecoder(sink) {
|
|
|
186
160
|
};
|
|
187
161
|
}
|
|
188
162
|
|
|
189
|
-
/** `
|
|
163
|
+
/** Last `n` CODE POINTS (not UTF-16 units) of a string. */
|
|
190
164
|
const tailChars = (s, n) => (s.length <= n ? s : Array.from(s).slice(-n).join(''));
|
|
191
165
|
|
|
192
|
-
/** The signals `killJobTree` sends, for the `-SIGNUM` return code
|
|
166
|
+
/** The signals `killJobTree` sends, mapped for the `-SIGNUM` return code convention. */
|
|
193
167
|
const SIGNUM = { SIGTERM: 15, SIGKILL: 9, SIGINT: 2, SIGHUP: 1 };
|
|
194
168
|
|
|
195
169
|
/**
|
|
196
|
-
*
|
|
170
|
+
* One mutating action at a time, in the background, output captured.
|
|
197
171
|
*
|
|
198
|
-
* A CLASS rather than
|
|
199
|
-
* eleven methods over it; `webState` is a factory because its only
|
|
200
|
-
* getters.
|
|
172
|
+
* A CLASS rather than a factory-of-a-plain-object like `webState`, because this one has
|
|
173
|
+
* real state and eleven methods over it; `webState` is a factory because its only
|
|
174
|
+
* behaviour is two cached getters.
|
|
201
175
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
176
|
+
* NO LOCK GUARDS `_jobs`, `_busy` and `_procs`, and that absence is deliberate: Node has
|
|
177
|
+
* one thread and no pre-emption inside a synchronous block, so every critical section here
|
|
178
|
+
* is already atomic — a mutex would be a no-op object claiming a hazard that cannot occur.
|
|
179
|
+
* What matters instead is which sequences must not be interrupted, and none of them await.
|
|
180
|
+
*
|
|
181
|
+
* DELIBERATELY NO `wait()`: nothing here calls it, and an untested, caller-less method is
|
|
182
|
+
* exactly the shape this repo keeps getting bitten by.
|
|
208
183
|
*/
|
|
209
184
|
export class JobManager {
|
|
210
185
|
static HISTORY_MAX = 20;
|
|
@@ -242,14 +217,14 @@ export class JobManager {
|
|
|
242
217
|
if (!this._historyPath) return;
|
|
243
218
|
const jobs = [...this._jobs.values()].filter((j) => j.status !== 'running')
|
|
244
219
|
.map((j) => ({ ...j }));
|
|
245
|
-
//
|
|
246
|
-
//
|
|
220
|
+
// STABLE sort matters: two jobs started inside the same clock tick keep insertion
|
|
221
|
+
// order rather than swapping unpredictably.
|
|
247
222
|
jobs.sort((a, b) => (Number(a.started ?? 0) || 0) - (Number(b.started ?? 0) || 0));
|
|
248
223
|
const kept = jobs.slice(-JobManager.HISTORY_MAX)
|
|
249
224
|
.map((j) => ({ ...j, output: tailChars(String(j.output), JobManager.OUTPUT_CAP) }));
|
|
250
225
|
try {
|
|
251
226
|
writeText(this._historyPath, jsonDumpsCompact(kept, { bareInts: true }));
|
|
252
|
-
} catch { /*
|
|
227
|
+
} catch { /* a console that cannot persist its history still runs */ }
|
|
253
228
|
}
|
|
254
229
|
|
|
255
230
|
/** Last `n` jobs, oldest first — the order the console appends in. */
|
|
@@ -260,9 +235,8 @@ export class JobManager {
|
|
|
260
235
|
}
|
|
261
236
|
|
|
262
237
|
/**
|
|
263
|
-
* `
|
|
264
|
-
*
|
|
265
|
-
* to 409.
|
|
238
|
+
* `cmds` is a LIST OF ARGVS, run in order, stopping at the first failure. `null` when a
|
|
239
|
+
* job is already running, which the HTTP layer maps to 409.
|
|
266
240
|
*/
|
|
267
241
|
start(action, cmds, onDone = null) {
|
|
268
242
|
if (this._busy) return null;
|
|
@@ -277,8 +251,8 @@ export class JobManager {
|
|
|
277
251
|
started: Date.now() / 1000,
|
|
278
252
|
duration: null,
|
|
279
253
|
});
|
|
280
|
-
//
|
|
281
|
-
//
|
|
254
|
+
// Hand the work to the scheduler and return the id NOW, so the 202 goes out before the
|
|
255
|
+
// child has produced a byte.
|
|
282
256
|
void this._run(jid, cmds, onDone);
|
|
283
257
|
return jid;
|
|
284
258
|
}
|
|
@@ -303,9 +277,9 @@ export class JobManager {
|
|
|
303
277
|
p = spawn(cmd[0], cmd.slice(1), {
|
|
304
278
|
cwd: ROOT,
|
|
305
279
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
306
|
-
//
|
|
280
|
+
// No console window for a child of a windowless daemon.
|
|
307
281
|
windowsHide: true,
|
|
308
|
-
//
|
|
282
|
+
// POSIX only: the process GROUP `killJobTree` signals.
|
|
309
283
|
detached: process.platform !== 'win32',
|
|
310
284
|
env: { ...process.env, GENESEED_WEB_JOB: '1' },
|
|
311
285
|
});
|
|
@@ -323,16 +297,15 @@ export class JobManager {
|
|
|
323
297
|
this._append(jid, `\n[web] job crashed: ${e && e.message ? e.message : e}`);
|
|
324
298
|
rc = 1;
|
|
325
299
|
// Don't orphan the child on a crash of OUR side: kill its tree, or it lingers.
|
|
326
|
-
// `p.pid` is undefined when the spawn itself failed, which
|
|
327
|
-
// arm the reference cannot reach at all (its `Popen` raises before the assignment).
|
|
300
|
+
// `p.pid` is undefined when the spawn itself failed, which this check guards against.
|
|
328
301
|
if (p !== null && p.pid && p.exitCode === null && p.signalCode === null) killJobTree(p);
|
|
329
302
|
} finally {
|
|
330
303
|
this._procs.delete(jid);
|
|
331
304
|
const j = this._jobs.get(jid);
|
|
332
305
|
j.status = rc === 0 ? 'done' : 'failed';
|
|
333
306
|
j.returncode = rc;
|
|
334
|
-
//
|
|
335
|
-
//
|
|
307
|
+
// ponytail: `Math.round` breaks a .x5 tie away from zero rather than to even —
|
|
308
|
+
// unreachable for a wall-clock delta, and the field is a display value only.
|
|
336
309
|
j.duration = Math.round((Date.now() / 1000 - Number(j.started)) * 10) / 10;
|
|
337
310
|
this._busy = false;
|
|
338
311
|
this._saveHistory();
|
|
@@ -345,12 +318,11 @@ export class JobManager {
|
|
|
345
318
|
}
|
|
346
319
|
|
|
347
320
|
/**
|
|
348
|
-
*
|
|
321
|
+
* Read to EOF, then the exit code.
|
|
349
322
|
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* ordering the reference gets for free by draining `p.stdout` to EOF before `p.wait()`.
|
|
323
|
+
* Resolves on `close`, not `exit`: `exit` fires when the process dies and `close` only
|
|
324
|
+
* once its stdio have also closed, and resolving on the former would let a job report
|
|
325
|
+
* `done` with its last lines still sitting in the pipe.
|
|
354
326
|
*/
|
|
355
327
|
_pump(jid, p) {
|
|
356
328
|
return new Promise((resolve, reject) => {
|
|
@@ -363,10 +335,9 @@ export class JobManager {
|
|
|
363
335
|
p.on('close', (code, signal) => {
|
|
364
336
|
out.end();
|
|
365
337
|
err.end();
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// covers what this program SENDS rather than every signal that exists.
|
|
338
|
+
// Node splits the exit code and the signal into two arguments, with the code
|
|
339
|
+
// `null` when a signal won. Only `cancel()` produces one, and only on POSIX, so the
|
|
340
|
+
// map above covers what this program SENDS rather than every signal that exists.
|
|
370
341
|
resolve(code === null ? -(SIGNUM[signal] ?? 1) : code);
|
|
371
342
|
});
|
|
372
343
|
});
|
|
@@ -392,57 +363,46 @@ export class JobManager {
|
|
|
392
363
|
}
|
|
393
364
|
|
|
394
365
|
/**
|
|
395
|
-
*
|
|
396
|
-
* at the first failure.
|
|
366
|
+
* Action name -> a list of argvs, each a separate step, stopping at the first failure.
|
|
397
367
|
*
|
|
398
|
-
* THE TABLE IS THE DISPATCH, so `PORTED_ACTIONS` is its own keys rather than a list beside
|
|
399
|
-
*
|
|
368
|
+
* THE TABLE IS THE DISPATCH, so `PORTED_ACTIONS` is its own keys rather than a list beside
|
|
369
|
+
* it — the same rule `POST_ROUTES` establishes one file over.
|
|
400
370
|
*
|
|
401
371
|
* `build` renders the DEPLOYED install in its detected theme + emit + footprint + posture +
|
|
402
|
-
* mode, so a rebuild from an imperial, lean, mentor, foreman opencode-global install stays
|
|
403
|
-
* five. `update` and `export` self-resolve the deployed theme downstream and take no
|
|
372
|
+
* mode, so a rebuild from an imperial, lean, mentor, foreman opencode-global install stays
|
|
373
|
+
* all five. `update` and `export` self-resolve the deployed theme downstream and take no
|
|
374
|
+
* args.
|
|
404
375
|
*
|
|
405
|
-
* THE ARGV HEAD IS
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
* normalises the `$ <argv>` echo line for that reason, and `tests/test_web_jobs.py` asserts
|
|
409
|
-
* each side's head ABSOLUTELY while comparing the tails literally — which is where a real port
|
|
410
|
-
* bug would live.
|
|
376
|
+
* THE ARGV HEAD IS `process.execPath` + `bin/geneseed-cli.mjs` (or `bin/build-driver.mjs`
|
|
377
|
+
* for the rows that run the generator) — the running interpreter by absolute path and this
|
|
378
|
+
* repo's own CLI, never a name any other runtime could shadow.
|
|
411
379
|
*/
|
|
412
380
|
const NODE = () => process.execPath;
|
|
413
381
|
const CLI = () => path.join(ROOT, 'bin', 'geneseed-cli.mjs');
|
|
414
382
|
const GEN = () => path.join(ROOT, 'bin', 'build-driver.mjs');
|
|
415
383
|
|
|
416
384
|
/**
|
|
417
|
-
* The rows whose VERB has not crossed.
|
|
418
|
-
*
|
|
419
|
-
* P6g opened it with three rows and wrote the rule that closes it: each removed by the phase
|
|
420
|
-
* that ports its verb. `update` left in P8c; `link` and `unlink` left in P10b, which is the
|
|
421
|
-
* phase that also had to decide whether those two verbs should exist under npm at all
|
|
422
|
-
* (`js/hosts/link.mjs`'s docblock carries the argument, and the deciding vote was these two rows —
|
|
423
|
-
* the console's Settings buttons POST here, and only a ported verb can answer them).
|
|
385
|
+
* The rows whose VERB has not crossed. EMPTY, AND STILL DECLARED.
|
|
424
386
|
*
|
|
425
|
-
* WHY AN EMPTY SET RATHER THAN A DELETED ONE.
|
|
426
|
-
* `
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
* 501 exists to prevent.
|
|
387
|
+
* WHY AN EMPTY SET RATHER THAN A DELETED ONE. This set is cross-checked against
|
|
388
|
+
* `actionCommands`' own keys, and an empty set is the strongest form that check ever takes:
|
|
389
|
+
* it asserts that EVERY action this daemon knows how to dispatch on is answered by the
|
|
390
|
+
* runner. Deleting the declaration would delete that assertion, and the next unported
|
|
391
|
+
* action would come back as a silent 404 — the thing the 501 exists to prevent.
|
|
431
392
|
*
|
|
432
|
-
* A 501 AND NOT THE 404 THE TABLE WOULD PRODUCE. `
|
|
433
|
-
* unknown action
|
|
434
|
-
*
|
|
435
|
-
* this set empty the 501 branch is
|
|
436
|
-
*
|
|
437
|
-
* only an invented one answers 404.
|
|
393
|
+
* A 501 AND NOT THE 404 THE TABLE WOULD PRODUCE. `actionCommands` returns `null` for an
|
|
394
|
+
* unknown action, mapped to `{"error": "unknown action <x>"}` at 404 — so a
|
|
395
|
+
* real-but-unported action falling through would answer the same thing a typo gets. With
|
|
396
|
+
* this set empty the 501 branch is UNREACHABLE by construction: every real action answers
|
|
397
|
+
* 202 and only an invented one answers 404.
|
|
438
398
|
*/
|
|
439
399
|
export const NOT_PORTED_ACTIONS = new Set();
|
|
440
400
|
|
|
441
401
|
/**
|
|
442
|
-
* The three actions
|
|
443
|
-
* synchronous and returns a result rather than a job id, and `install`/`deploy`
|
|
444
|
-
* argv from the request body first. Declared so the
|
|
445
|
-
* the
|
|
402
|
+
* The three actions the dispatcher answers INLINE, without consulting the table above:
|
|
403
|
+
* `restore` is synchronous and returns a result rather than a job id, and `install`/`deploy`
|
|
404
|
+
* resolve their argv from the request body first. Declared so the partition covers every
|
|
405
|
+
* action the dispatcher answers, not only the table's keys.
|
|
446
406
|
*/
|
|
447
407
|
export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
|
|
448
408
|
|
|
@@ -472,10 +432,9 @@ function actionTable({
|
|
|
472
432
|
// Rebuild EVERY active install in place (each in its own theme+emit). The per-install
|
|
473
433
|
// resolution lives in the rebuild-all subcommand, so the web layer threads no theme/emit.
|
|
474
434
|
'build-all': [[NODE(), CLI(), 'rebuild-all']],
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
// job, set by `_run` above and read by `js/maintain/update.mjs` — P6g's contract with P8, honoured.
|
|
435
|
+
// `update` is the action name here; `upgrade` is the VERB it runs. The daemon bounce
|
|
436
|
+
// this job must not do itself is `GENESEED_WEB_JOB`'s job, set by `_run` above and read
|
|
437
|
+
// by `js/maintain/update.mjs`.
|
|
479
438
|
update: [[NODE(), CLI(), 'upgrade']],
|
|
480
439
|
export: [[NODE(), CLI(), 'diff', '--out']],
|
|
481
440
|
// Local-machine maintenance, surfaced in the web Settings. uninstall keeps memory (never
|
|
@@ -488,10 +447,10 @@ function actionTable({
|
|
|
488
447
|
|
|
489
448
|
export function actionCommands(action, opts = {}) {
|
|
490
449
|
const table = actionTable(opts);
|
|
491
|
-
// `
|
|
492
|
-
//
|
|
450
|
+
// `null` for anything the table does not name, which the HTTP layer maps to
|
|
451
|
+
// `{"error": "unknown action <x>"}` at 404.
|
|
493
452
|
return Object.hasOwn(table, action) ? table[action] : null;
|
|
494
453
|
}
|
|
495
454
|
|
|
496
|
-
/** `
|
|
455
|
+
/** `actionTable`'s own keys — the table IS the declaration, so this cannot drift from it. */
|
|
497
456
|
export const PORTED_ACTIONS = Object.keys(actionTable());
|