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/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,54 +26,42 @@
|
|
|
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
|
-
* THE
|
|
51
|
-
*
|
|
52
|
-
* `
|
|
53
|
-
*
|
|
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.
|
|
47
|
+
* THE ACTION SURFACE — `doctor`, `build`, `build-all`, `export`, `uninstall`, `update`,
|
|
48
|
+
* `link` and `unlink` each name a verb `bin/geneseed-cli.mjs` runs, plus the three
|
|
49
|
+
* `INLINE_ACTIONS` the dispatcher answers without the table. `tests/unit/web_jobs.test.mjs`
|
|
50
|
+
* holds the union against a written-out list, so a ninth action cannot appear unchecked.
|
|
60
51
|
*
|
|
61
52
|
* ---------------------------------------------------------------------------------------
|
|
62
53
|
* THREE DETAILS OF `_run`, EACH A COMMENT EXPLAINING A BUG IT ALREADY FIXED.
|
|
63
54
|
*
|
|
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.
|
|
55
|
+
* `GENESEED_WEB_JOB=1` tells `upgrade` it is running INSIDE this daemon so it must not
|
|
56
|
+
* bounce the daemon mid-job — which would kill the job's own tracking and leave the
|
|
57
|
+
* console on `running` forever. Set here and read by `js/maintain/update.mjs`.
|
|
70
58
|
*
|
|
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.
|
|
59
|
+
* NO UNBUFFERING FLAG IS SET, and that absence is deliberate rather than an oversight:
|
|
60
|
+
* Node's `process.stdout` on a pipe is not block-buffered by libc — every `write` is
|
|
61
|
+
* queued on the stream and flushed by the event loop. The real hazard here is a child
|
|
62
|
+
* calling `process.exit()` with writes still queued, which truncates them; both `bin/`
|
|
63
|
+
* entries set `process.exitCode` and return instead, which is what makes an unbuffering
|
|
64
|
+
* flag unnecessary rather than merely inapplicable.
|
|
80
65
|
*
|
|
81
66
|
* `MEM_CAP` truncates a runaway job's output FROM THE FRONT, so a job that prints forever
|
|
82
67
|
* cannot eat the daemon's memory. `OUTPUT_CAP` is the smaller cap the history FILE keeps.
|
|
@@ -84,44 +69,34 @@
|
|
|
84
69
|
* ---------------------------------------------------------------------------------------
|
|
85
70
|
* TWO DIVERGENCES, DECLARED.
|
|
86
71
|
*
|
|
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.
|
|
72
|
+
* 1. THE TWO STREAMS ARE TWO PIPES, read and appended separately in arrival order — exact
|
|
73
|
+
* for a line-buffered child writing to only one stream (which is every row today:
|
|
74
|
+
* `rebuild-all` writes stdout only, `diff` writes stderr only), and potentially
|
|
75
|
+
* reordered for a child interleaving both within one tick. Named here rather than left
|
|
76
|
+
* to be discovered, should a future row ever write to both.
|
|
77
|
+
* 2. `_append`'s cap slices UTF-16 code UNITS rather than code POINTS, unlike `tailChars`
|
|
78
|
+
* (used by the history file's cap via `Array.from`), which differs only past two
|
|
79
|
+
* million characters and only by a lone surrogate at the truncation front. The history
|
|
80
|
+
* cap can afford code-point precision because it runs once per job; doing that here,
|
|
81
|
+
* once per streamed chunk, would make a runaway job quadratic — the exact failure the
|
|
82
|
+
* cap exists to prevent.
|
|
104
83
|
*/
|
|
105
84
|
import { spawn, spawnSync } from 'node:child_process';
|
|
106
85
|
import { randomBytes } from 'node:crypto';
|
|
107
|
-
import { statSync } from 'node:fs';
|
|
108
86
|
import { StringDecoder } from 'node:string_decoder';
|
|
109
87
|
import path from 'node:path';
|
|
110
88
|
|
|
111
89
|
import { ROOT, PACK_ORDER } from '../build/source.mjs';
|
|
112
90
|
import { setupBuildArgs } from '../build/generate.mjs';
|
|
113
91
|
import { isDict } from '../hosts/mcp.mjs';
|
|
114
|
-
import { readText, writeText } from '../lib/fs.mjs';
|
|
92
|
+
import { readText, writeText, isFile } from '../lib/fs.mjs';
|
|
115
93
|
import { jsonDumpsCompact, parseJson, isTruthy } from '../lib/json.mjs';
|
|
116
94
|
|
|
117
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
118
|
-
|
|
119
95
|
/**
|
|
120
|
-
*
|
|
96
|
+
* Terminate a job's WHOLE process tree.
|
|
121
97
|
*
|
|
122
|
-
* NEVER THROWS
|
|
123
|
-
*
|
|
124
|
-
* alive with the manager believing it dead.
|
|
98
|
+
* NEVER THROWS: it runs from `cancel()` and from the crash path in `_run`'s `catch`, and an
|
|
99
|
+
* exception in either would leave the child alive with the manager believing it dead.
|
|
125
100
|
*/
|
|
126
101
|
export function killJobTree(child) {
|
|
127
102
|
try {
|
|
@@ -131,30 +106,25 @@ export function killJobTree(child) {
|
|
|
131
106
|
timeout: 15000, windowsHide: true, stdio: 'ignore',
|
|
132
107
|
});
|
|
133
108
|
} else {
|
|
134
|
-
//
|
|
135
|
-
// `detached: true`, which is `start_new_session=True`.
|
|
109
|
+
// The process GROUP exists because `_run` spawns with `detached: true`.
|
|
136
110
|
process.kill(-child.pid, 'SIGTERM');
|
|
137
111
|
}
|
|
138
112
|
} catch {
|
|
139
113
|
try {
|
|
140
114
|
child.kill();
|
|
141
|
-
} catch { /*
|
|
115
|
+
} catch { /* last resort already failed; nothing more to try */ }
|
|
142
116
|
}
|
|
143
117
|
}
|
|
144
118
|
|
|
145
119
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
120
|
+
* Decodes a child's raw stdout/stderr bytes the way a text-mode read would: UTF-8, and
|
|
121
|
+
* newlines folded regardless of the platform.
|
|
148
122
|
*
|
|
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.
|
|
123
|
+
* TWO THINGS. `StringDecoder` holds a partial UTF-8 sequence across a chunk boundary — a
|
|
124
|
+
* naive `buf.toString()` per chunk would turn one multi-byte character split across two
|
|
125
|
+
* reads into two replacement characters. And CRLF / a lone CR are both folded to LF before
|
|
126
|
+
* the caller ever sees them: spawned children write CRLF on Windows, and without the fold
|
|
127
|
+
* the job output would carry CRLF on one platform and LF on the other.
|
|
158
128
|
*
|
|
159
129
|
* The pending `\r` is held rather than translated on the spot: a `\r\n` split across two
|
|
160
130
|
* chunks would otherwise become two newlines.
|
|
@@ -186,25 +156,26 @@ function textDecoder(sink) {
|
|
|
186
156
|
};
|
|
187
157
|
}
|
|
188
158
|
|
|
189
|
-
/** `
|
|
159
|
+
/** Last `n` CODE POINTS (not UTF-16 units) of a string. */
|
|
190
160
|
const tailChars = (s, n) => (s.length <= n ? s : Array.from(s).slice(-n).join(''));
|
|
191
161
|
|
|
192
|
-
/** The signals `killJobTree` sends, for the `-SIGNUM` return code
|
|
162
|
+
/** The signals `killJobTree` sends, mapped for the `-SIGNUM` return code convention. */
|
|
193
163
|
const SIGNUM = { SIGTERM: 15, SIGKILL: 9, SIGINT: 2, SIGHUP: 1 };
|
|
194
164
|
|
|
195
165
|
/**
|
|
196
|
-
*
|
|
166
|
+
* One mutating action at a time, in the background, output captured.
|
|
167
|
+
*
|
|
168
|
+
* A CLASS rather than a factory-of-a-plain-object like `webState`, because this one has
|
|
169
|
+
* real state and eleven methods over it; `webState` is a factory because its only
|
|
170
|
+
* behaviour is two cached getters.
|
|
197
171
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
172
|
+
* NO LOCK GUARDS `_jobs`, `_busy` and `_procs`, and that absence is deliberate: Node has
|
|
173
|
+
* one thread and no pre-emption inside a synchronous block, so every critical section here
|
|
174
|
+
* is already atomic — a mutex would be a no-op object claiming a hazard that cannot occur.
|
|
175
|
+
* What matters instead is which sequences must not be interrupted, and none of them await.
|
|
201
176
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* pre-emption inside a synchronous block, so every critical section here is already atomic —
|
|
205
|
-
* a mutex would be a no-op object whose presence claimed a hazard that cannot occur. What the
|
|
206
|
-
* lock's placement DOES carry over is which sequences must not be interrupted, and none of
|
|
207
|
-
* them awaits.
|
|
177
|
+
* DELIBERATELY NO `wait()`: nothing here calls it, and an untested, caller-less method is
|
|
178
|
+
* exactly the shape this repo keeps getting bitten by.
|
|
208
179
|
*/
|
|
209
180
|
export class JobManager {
|
|
210
181
|
static HISTORY_MAX = 20;
|
|
@@ -242,14 +213,14 @@ export class JobManager {
|
|
|
242
213
|
if (!this._historyPath) return;
|
|
243
214
|
const jobs = [...this._jobs.values()].filter((j) => j.status !== 'running')
|
|
244
215
|
.map((j) => ({ ...j }));
|
|
245
|
-
//
|
|
246
|
-
//
|
|
216
|
+
// STABLE sort matters: two jobs started inside the same clock tick keep insertion
|
|
217
|
+
// order rather than swapping unpredictably.
|
|
247
218
|
jobs.sort((a, b) => (Number(a.started ?? 0) || 0) - (Number(b.started ?? 0) || 0));
|
|
248
219
|
const kept = jobs.slice(-JobManager.HISTORY_MAX)
|
|
249
220
|
.map((j) => ({ ...j, output: tailChars(String(j.output), JobManager.OUTPUT_CAP) }));
|
|
250
221
|
try {
|
|
251
222
|
writeText(this._historyPath, jsonDumpsCompact(kept, { bareInts: true }));
|
|
252
|
-
} catch { /*
|
|
223
|
+
} catch { /* a console that cannot persist its history still runs */ }
|
|
253
224
|
}
|
|
254
225
|
|
|
255
226
|
/** Last `n` jobs, oldest first — the order the console appends in. */
|
|
@@ -260,9 +231,8 @@ export class JobManager {
|
|
|
260
231
|
}
|
|
261
232
|
|
|
262
233
|
/**
|
|
263
|
-
* `
|
|
264
|
-
*
|
|
265
|
-
* to 409.
|
|
234
|
+
* `cmds` is a LIST OF ARGVS, run in order, stopping at the first failure. `null` when a
|
|
235
|
+
* job is already running, which the HTTP layer maps to 409.
|
|
266
236
|
*/
|
|
267
237
|
start(action, cmds, onDone = null) {
|
|
268
238
|
if (this._busy) return null;
|
|
@@ -277,8 +247,8 @@ export class JobManager {
|
|
|
277
247
|
started: Date.now() / 1000,
|
|
278
248
|
duration: null,
|
|
279
249
|
});
|
|
280
|
-
//
|
|
281
|
-
//
|
|
250
|
+
// Hand the work to the scheduler and return the id NOW, so the 202 goes out before the
|
|
251
|
+
// child has produced a byte.
|
|
282
252
|
void this._run(jid, cmds, onDone);
|
|
283
253
|
return jid;
|
|
284
254
|
}
|
|
@@ -303,9 +273,9 @@ export class JobManager {
|
|
|
303
273
|
p = spawn(cmd[0], cmd.slice(1), {
|
|
304
274
|
cwd: ROOT,
|
|
305
275
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
306
|
-
//
|
|
276
|
+
// No console window for a child of a windowless daemon.
|
|
307
277
|
windowsHide: true,
|
|
308
|
-
//
|
|
278
|
+
// POSIX only: the process GROUP `killJobTree` signals.
|
|
309
279
|
detached: process.platform !== 'win32',
|
|
310
280
|
env: { ...process.env, GENESEED_WEB_JOB: '1' },
|
|
311
281
|
});
|
|
@@ -323,16 +293,15 @@ export class JobManager {
|
|
|
323
293
|
this._append(jid, `\n[web] job crashed: ${e && e.message ? e.message : e}`);
|
|
324
294
|
rc = 1;
|
|
325
295
|
// 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).
|
|
296
|
+
// `p.pid` is undefined when the spawn itself failed, which this check guards against.
|
|
328
297
|
if (p !== null && p.pid && p.exitCode === null && p.signalCode === null) killJobTree(p);
|
|
329
298
|
} finally {
|
|
330
299
|
this._procs.delete(jid);
|
|
331
300
|
const j = this._jobs.get(jid);
|
|
332
301
|
j.status = rc === 0 ? 'done' : 'failed';
|
|
333
302
|
j.returncode = rc;
|
|
334
|
-
//
|
|
335
|
-
//
|
|
303
|
+
// ponytail: `Math.round` breaks a .x5 tie away from zero rather than to even —
|
|
304
|
+
// unreachable for a wall-clock delta, and the field is a display value only.
|
|
336
305
|
j.duration = Math.round((Date.now() / 1000 - Number(j.started)) * 10) / 10;
|
|
337
306
|
this._busy = false;
|
|
338
307
|
this._saveHistory();
|
|
@@ -345,12 +314,11 @@ export class JobManager {
|
|
|
345
314
|
}
|
|
346
315
|
|
|
347
316
|
/**
|
|
348
|
-
*
|
|
317
|
+
* Read to EOF, then the exit code.
|
|
349
318
|
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* ordering the reference gets for free by draining `p.stdout` to EOF before `p.wait()`.
|
|
319
|
+
* Resolves on `close`, not `exit`: `exit` fires when the process dies and `close` only
|
|
320
|
+
* once its stdio have also closed, and resolving on the former would let a job report
|
|
321
|
+
* `done` with its last lines still sitting in the pipe.
|
|
354
322
|
*/
|
|
355
323
|
_pump(jid, p) {
|
|
356
324
|
return new Promise((resolve, reject) => {
|
|
@@ -363,10 +331,9 @@ export class JobManager {
|
|
|
363
331
|
p.on('close', (code, signal) => {
|
|
364
332
|
out.end();
|
|
365
333
|
err.end();
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
// covers what this program SENDS rather than every signal that exists.
|
|
334
|
+
// Node splits the exit code and the signal into two arguments, with the code
|
|
335
|
+
// `null` when a signal won. Only `cancel()` produces one, and only on POSIX, so the
|
|
336
|
+
// map above covers what this program SENDS rather than every signal that exists.
|
|
370
337
|
resolve(code === null ? -(SIGNUM[signal] ?? 1) : code);
|
|
371
338
|
});
|
|
372
339
|
});
|
|
@@ -392,57 +359,29 @@ export class JobManager {
|
|
|
392
359
|
}
|
|
393
360
|
|
|
394
361
|
/**
|
|
395
|
-
*
|
|
396
|
-
* at the first failure.
|
|
362
|
+
* Action name -> a list of argvs, each a separate step, stopping at the first failure.
|
|
397
363
|
*
|
|
398
|
-
* THE TABLE IS THE DISPATCH, so `PORTED_ACTIONS` is its own keys rather than a list beside
|
|
399
|
-
*
|
|
364
|
+
* THE TABLE IS THE DISPATCH, so `PORTED_ACTIONS` is its own keys rather than a list beside
|
|
365
|
+
* it — the same rule `POST_ROUTES` establishes one file over.
|
|
400
366
|
*
|
|
401
367
|
* `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
|
|
368
|
+
* mode, so a rebuild from an imperial, lean, mentor, foreman opencode-global install stays
|
|
369
|
+
* all five. `update` and `export` self-resolve the deployed theme downstream and take no
|
|
370
|
+
* args.
|
|
404
371
|
*
|
|
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.
|
|
372
|
+
* THE ARGV HEAD IS `process.execPath` + `bin/geneseed-cli.mjs` (or `bin/build-driver.mjs`
|
|
373
|
+
* for the rows that run the generator) — the running interpreter by absolute path and this
|
|
374
|
+
* repo's own CLI, never a name any other runtime could shadow.
|
|
411
375
|
*/
|
|
412
376
|
const NODE = () => process.execPath;
|
|
413
377
|
const CLI = () => path.join(ROOT, 'bin', 'geneseed-cli.mjs');
|
|
414
378
|
const GEN = () => path.join(ROOT, 'bin', 'build-driver.mjs');
|
|
415
379
|
|
|
416
380
|
/**
|
|
417
|
-
* The
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
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).
|
|
424
|
-
*
|
|
425
|
-
* WHY AN EMPTY SET RATHER THAN A DELETED ONE. The `ast` cross-check in
|
|
426
|
-
* `tests/test_web_jobs.py` reads this set against `action_commands`' own keys, and an empty
|
|
427
|
-
* set is the strongest form that check ever takes: it asserts that EVERY action the reference
|
|
428
|
-
* dispatches on is answered by the Node runner. Deleting the declaration would delete that
|
|
429
|
-
* assertion, and the next unported action would come back as a silent 404 — the thing the
|
|
430
|
-
* 501 exists to prevent.
|
|
431
|
-
*
|
|
432
|
-
* A 501 AND NOT THE 404 THE TABLE WOULD PRODUCE. `action_commands` returns `None` for an
|
|
433
|
-
* unknown action and the reference maps that to `{"error": "unknown action <x>"}` at 404 — so
|
|
434
|
-
* a real-but-unported action falling through would answer the same thing a typo gets. With
|
|
435
|
-
* this set empty the 501 branch is now UNREACHABLE by construction, which is exactly what the
|
|
436
|
-
* dispatcher probe in `tests/test_web_server.py` asserts: every real action answers 202 and
|
|
437
|
-
* only an invented one answers 404.
|
|
438
|
-
*/
|
|
439
|
-
export const NOT_PORTED_ACTIONS = new Set();
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* The three actions `_post_routes` answers INLINE, without consulting the table: `restore` is
|
|
443
|
-
* synchronous and returns a result rather than a job id, and `install`/`deploy` resolve their
|
|
444
|
-
* argv from the request body first. Declared so the `ast` cross-check covers every `action ==`
|
|
445
|
-
* the reference dispatches on, not only the table's keys.
|
|
381
|
+
* The three actions the dispatcher answers INLINE, without consulting the table above:
|
|
382
|
+
* `restore` is synchronous and returns a result rather than a job id, and `install`/`deploy`
|
|
383
|
+
* resolve their argv from the request body first. Declared so the surface check covers every
|
|
384
|
+
* action the dispatcher answers, not only the table's keys.
|
|
446
385
|
*/
|
|
447
386
|
export const INLINE_ACTIONS = ['restore', 'install', 'deploy'];
|
|
448
387
|
|
|
@@ -472,10 +411,9 @@ function actionTable({
|
|
|
472
411
|
// Rebuild EVERY active install in place (each in its own theme+emit). The per-install
|
|
473
412
|
// resolution lives in the rebuild-all subcommand, so the web layer threads no theme/emit.
|
|
474
413
|
'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.
|
|
414
|
+
// `update` is the action name here; `upgrade` is the VERB it runs. The daemon bounce
|
|
415
|
+
// this job must not do itself is `GENESEED_WEB_JOB`'s job, set by `_run` above and read
|
|
416
|
+
// by `js/maintain/update.mjs`.
|
|
479
417
|
update: [[NODE(), CLI(), 'upgrade']],
|
|
480
418
|
export: [[NODE(), CLI(), 'diff', '--out']],
|
|
481
419
|
// Local-machine maintenance, surfaced in the web Settings. uninstall keeps memory (never
|
|
@@ -488,10 +426,10 @@ function actionTable({
|
|
|
488
426
|
|
|
489
427
|
export function actionCommands(action, opts = {}) {
|
|
490
428
|
const table = actionTable(opts);
|
|
491
|
-
// `
|
|
492
|
-
//
|
|
429
|
+
// `null` for anything the table does not name, which the HTTP layer maps to
|
|
430
|
+
// `{"error": "unknown action <x>"}` at 404.
|
|
493
431
|
return Object.hasOwn(table, action) ? table[action] : null;
|
|
494
432
|
}
|
|
495
433
|
|
|
496
|
-
/** `
|
|
434
|
+
/** `actionTable`'s own keys — the table IS the declaration, so this cannot drift from it. */
|
|
497
435
|
export const PORTED_ACTIONS = Object.keys(actionTable());
|