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.
Files changed (113) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +3 -3
  4. package/bin/build-driver.mjs +127 -79
  5. package/bin/geneseed-cli.mjs +42 -84
  6. package/bin/geneseed-hook.mjs +5 -2
  7. package/harness.config.json +1 -1
  8. package/js/README.md +1 -10
  9. package/js/build/emit-claude.mjs +4 -5
  10. package/js/build/emit-common.mjs +63 -114
  11. package/js/build/emit-opencode.mjs +74 -66
  12. package/js/build/generate.mjs +4 -6
  13. package/js/build/render.mjs +13 -13
  14. package/js/build/source.mjs +7 -6
  15. package/js/build/stubs.mjs +25 -33
  16. package/js/build/themes.mjs +1 -1
  17. package/js/build/version.mjs +1 -3
  18. package/js/hosts/hooks.mjs +3 -3
  19. package/js/hosts/hosts.mjs +2 -4
  20. package/js/hosts/installs.mjs +35 -28
  21. package/js/hosts/link.mjs +1 -1
  22. package/js/hosts/native.mjs +37 -19
  23. package/js/hosts/opencode.mjs +39 -28
  24. package/js/hosts/settings.mjs +33 -110
  25. package/js/inspect/checks-authoring.mjs +5 -4
  26. package/js/inspect/checks-build.mjs +1 -1
  27. package/js/inspect/diff.mjs +1 -3
  28. package/js/inspect/doctor.mjs +59 -44
  29. package/js/inspect/excludes.mjs +4 -10
  30. package/js/inspect/inventory.mjs +37 -55
  31. package/js/inspect/registry.mjs +4 -7
  32. package/js/inspect/scan.mjs +8 -15
  33. package/js/inspect/status.mjs +1 -1
  34. package/js/lib/fs.mjs +34 -0
  35. package/js/lib/json.mjs +28 -1
  36. package/js/lib/paths.mjs +0 -10
  37. package/js/lib/udiff.mjs +11 -16
  38. package/js/maintain/memory.mjs +2 -4
  39. package/js/maintain/migrate.mjs +3 -3
  40. package/js/maintain/uninstall.mjs +90 -78
  41. package/js/maintain/update.mjs +2 -2
  42. package/js/ui/anim.mjs +1 -1
  43. package/js/ui/menu.mjs +1 -1
  44. package/js/ui/tui.mjs +3 -20
  45. package/js/web/actions.mjs +230 -217
  46. package/js/web/activity.mjs +71 -89
  47. package/js/web/api.mjs +133 -224
  48. package/js/web/daemon.mjs +92 -110
  49. package/js/web/docs.mjs +66 -111
  50. package/js/web/graph.mjs +0 -0
  51. package/js/web/handler.mjs +78 -97
  52. package/js/web/jobs.mjs +124 -165
  53. package/js/web/routes.mjs +73 -152
  54. package/js/web/server.mjs +47 -82
  55. package/package.json +1 -1
  56. package/src/agents/explorer.md +2 -1
  57. package/src/doctrines/process.md +4 -2
  58. package/src/laws/universal.md +55 -43
  59. package/src/skills/clarify.md +1 -1
  60. package/src/skills/forge-mcp.md +2 -1
  61. package/src/skills/fresh-eyes.md +1 -1
  62. package/src/skills/ingest.md +2 -1
  63. package/src/skills/parallel-agents.md +1 -1
  64. package/src/skills/ponytail.md +2 -2
  65. package/src/skills/research.md +4 -2
  66. package/themes/_TEMPLATE.json +2 -0
  67. package/themes/biker.json +2 -0
  68. package/themes/commentator.json +2 -0
  69. package/themes/cyberpunk.json +2 -0
  70. package/themes/gamer.json +2 -0
  71. package/themes/imperial.json +2 -0
  72. package/themes/joker.json +2 -0
  73. package/themes/marvin.json +2 -0
  74. package/themes/mean.json +2 -0
  75. package/themes/military.json +2 -0
  76. package/themes/neutral.json +2 -0
  77. package/themes/pirate.json +2 -0
  78. package/themes/sports.json +2 -0
  79. package/themes/verstappen.json +2 -0
  80. package/themes/wizard.json +2 -0
  81. package/web/dist/assets/{Activity-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
  82. package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
  83. package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
  84. package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
  85. package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
  86. package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
  87. package/web/dist/assets/Harness-vRYs11kX.js +1 -0
  88. package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
  89. package/web/dist/assets/Library-CjC7BAWN.js +1 -0
  90. package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
  91. package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
  92. package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
  93. package/web/dist/assets/Skills-BvatJGfe.js +1 -0
  94. package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
  95. package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
  96. package/web/dist/assets/index-BnRWQbZb.js +12 -0
  97. package/web/dist/assets/index-Bx3I7cRW.js +2 -0
  98. package/web/dist/assets/index-D0f9tJlA.css +1 -0
  99. package/web/dist/assets/index-DJOhOwwR.js +1 -0
  100. package/web/dist/index.html +2 -2
  101. package/web/src/pages/Laws.jsx +42 -71
  102. package/web/dist/assets/Diff-D3_bVkih.js +0 -5
  103. package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
  104. package/web/dist/assets/Harness-BIWei7N5.js +0 -1
  105. package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
  106. package/web/dist/assets/Library-MjbOkHIW.js +0 -1
  107. package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
  108. package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
  109. package/web/dist/assets/Skills-DArA6IHS.js +0 -1
  110. package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
  111. package/web/dist/assets/index-DC3-GlCY.js +0 -1
  112. package/web/dist/assets/index-DP035wPa.js +0 -12
  113. 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 — `rituals/_web_jobs.py`'s `JobManager`, its process-tree
3
- * kill and the `action_commands` table.
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 THE PORT IS ALLOWED TO.
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
- * (`tests/test_hook_cli_parity.py::_ALLOWED_SPAWNS`) whose criterion is not "this verb
10
- * spawns" — `build` spawns on the reference and is refused there — but "there is no
11
- * in-process equivalent, and the spawned thing is not this program". This module spawns THIS
12
- * PROGRAM, which reads like the passthrough the whole port exists to remove, so the argument
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, and it is a property of the RUNTIME, not of the verbs.
16
- * The reference's daemon is threaded: `start()` hands the work to a `threading.Thread` and
17
- * the HTTP server keeps answering from another. Node's server is single-threaded and every
18
- * ported `cmdX` is synchronous, so calling one in-process would block the event loop for the
19
- * whole run — and the console's only progress mechanism is `web/src/api/jobs.js` polling
20
- * `GET /api/jobs/<id>`. An in-process job therefore freezes the poll that exists to watch it,
21
- * which is worse than not showing progress: the UI cannot tell a running job from a hung
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` reads `p.returncode` and stops
33
- * the chain on the first non-zero. In-process that would be a return value threaded through
34
- * every verb's error paths, several of which currently end at `process.exitCode`.
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 args>`
37
- * for the three rows that run the GENERATOR, which is `build.py`'s twin and the same program
38
- * by a different entry. `process.execPath` is the running interpreter by absolute path, so
39
- * stripping `node` off PATH cannot change what starts. Nothing here can name `python`.
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 — the reference's comment says why: killing only the direct child
43
- * leaves ITS children holding the stdout pipe, the read loop stays blocked and the job wedges
44
- * on `running` forever. POSIX gets that in-process (`detached: true` makes a process group and
45
- * `process.kill(-pid)` signals it). Windows has no process-group kill in Node's standard
46
- * library at all, and `taskkill /T` is what the reference reaches for too. It is a machine
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. `link`/`unlink` were the last two: they ran
54
- * `harness.py link|unlink` until P10b, and a runner that spawned the Python harness would
55
- * have put Python back into a "no Python needed" install.
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 bounce
65
- * the daemon mid-job — which killed the job's own tracking and left the console on `running`
66
- * forever. It was set here before any ported row read it, because it was P6g's CONTRACT WITH
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
- * `PYTHONUNBUFFERED=1` reaches the Python child AND its own Python children, or their stdout
72
- * is block-buffered into the pipe and the console looks stuck. THE EQUIVALENT THOUGHT FOR
73
- * NODE, which is the port and not the copy: there is no unbuffering flag to set, because
74
- * Node's `process.stdout` on a pipe is not block-buffered by libc — every `write` is queued on
75
- * the stream and flushed by the event loop. The analogous hazard is a child calling
76
- * `process.exit()` with writes still queued, which truncates them; both `bin/` entries set
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. `stderr=subprocess.STDOUT` merges them in the OPERATING
88
- * SYSTEM, so the reference's interleaving is exact. Node's `stdio` cannot name an
89
- * already-created pipe, so `stdout` and `stderr` are read separately and appended in
90
- * arrival order — identical for a line-buffered child writing to one stream (which is
91
- * every ported row: `rebuild-all` writes stdout only, `diff` writes stderr only), and
92
- * potentially reordered for a child interleaving both within one tick. No cell can see it,
93
- * and it is named here rather than left to be discovered.
94
- * 2. `_append`'s cap slices UTF-16 code UNITS where Python slices code POINTS. It differs only
95
- * past two million characters and only by a lone surrogate at the truncation front. The
96
- * history file's cap does the exact thing (`Array.from`), because it runs once per job
97
- * rather than once per line — doing it here would make a runaway job quadratic, which is
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
- * `_kill_job_tree` — terminate a job's WHOLE process tree.
100
+ * Terminate a job's WHOLE process tree.
121
101
  *
122
- * NEVER THROWS, as the reference's two nested `except`s do not: it runs from `cancel()` and
123
- * from the crash path in `_run`'s `catch`, and an exception in either would leave the child
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
- // `os.killpg(p.pid, SIGTERM)`. The group exists because `_run` spawns with
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 { /* `except OSError: pass` */ }
119
+ } catch { /* last resort already failed; nothing more to try */ }
142
120
  }
143
121
  }
144
122
 
145
123
  /**
146
- * `Popen(..., text=True, encoding="utf-8", errors="replace")` — the decode Python's text mode
147
- * performs and Node's pipes do not.
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, and the second is the one a port drops. `StringDecoder` holds a partial UTF-8
150
- * sequence across a chunk boundary (a naive `buf.toString()` per chunk turns one multi-byte
151
- * character split across two reads into two replacement characters — the exact defect P5i
152
- * found when a helper reached its second owner). And Python decodes in UNIVERSAL NEWLINES
153
- * mode, so `\r\n` and a lone `\r` are both `\n` before the caller ever sees them — while Node
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
- /** `s[-n:]` by CODE POINT, which is what Python slices. */
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 Python reports. */
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
- * `_web_jobs.JobManager` — one mutating action at a time, in the background, output captured.
170
+ * One mutating action at a time, in the background, output captured.
197
171
  *
198
- * A CLASS rather than P6b's factory-of-a-plain-object, because this one has real state and
199
- * eleven methods over it; `webState` is a factory because its only behaviour is two cached
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
- * THE LOCK IS GONE AND ITS ABSENCE IS THE PORT. `self._lock` guards `_jobs`, `_busy` and
203
- * `_procs` against the run thread and the request threads. Node has one thread and no
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.
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
- // `sort(key=...)` is STABLE in both languages, which matters: two jobs started inside the
246
- // same clock tick keep insertion order rather than swapping between the two runtimes.
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 { /* `except OSError: pass` — a console that cannot persist still runs */ }
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
- * `start(action, *cmds, on_done=None)` — `cmds` is a LIST OF ARGVS, run in order, stopping
264
- * at the first failure. `null` when a job is already running, which the HTTP layer maps
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
- // The reference's `threading.Thread(...).start()`: hand the work to the scheduler and
281
- // return the id NOW, so the 202 goes out before the child has produced a byte.
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
- // `harness.NO_WINDOW`: no console window for a child of a windowless daemon.
280
+ // No console window for a child of a windowless daemon.
307
281
  windowsHide: true,
308
- // `start_new_session=True` on POSIX — the process GROUP `killJobTree` signals.
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 is the `p is not None`
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
- // `round(x, 1)`. ponytail: JS rounds a .x5 tie away from zero where Python rounds to
335
- // even — unreachable for a wall-clock delta, and the field is a display value.
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
- * `for line in p.stdout: ...` then `p.wait()` — read to EOF, then the exit code.
321
+ * Read to EOF, then the exit code.
349
322
  *
350
- * BOTH `close` AND the two stream ENDS are awaited, not just `exit`: `exit` fires when the
351
- * process dies and `close` when its stdio have also closed, and resolving on the former
352
- * would let a job report `done` with its last lines still in the pipe. That is the same
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
- // `p.returncode` for a signalled child is `-SIGNUM`, and Node splits the exit code
367
- // and the signal into two arguments with the code `null` when a signal won. Only
368
- // `cancel()` produces one, and only on POSIX — which no cell can reach, so the map
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
- * `_web_jobs.action_commands` — action name -> a list of argvs, each a separate step, stopping
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 it
399
- * (the rule `POST_ROUTES` established one file over, for the reason M23 gave).
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 all
403
- * five. `update` and `export` self-resolve the deployed theme downstream and take no args.
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 THE PORT. The reference names `sys.executable` and `rituals/harness.py` (or
406
- * `build.py`); this names `process.execPath` and `bin/geneseed-cli.mjs` (or `bin/build-driver.mjs`,
407
- * which is `build.py`'s twin). The two can never be byte-equal, `tests/web_golden.py`
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. **EMPTY SINCE P10b, AND STILL DECLARED.**
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. 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.
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. `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.
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 `_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.
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
- // P8c. The row names `upgrade`, which is the VERB; `update` is the action name here and
476
- // argparse's alias there, and the reference's own row is `[py, harness.py, "upgrade"]` for
477
- // the same reason. The daemon bounce this job must not do itself is `GENESEED_WEB_JOB`'s
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
- // `dict.get(action)` — `None` for anything the table does not name, which the HTTP layer
492
- // maps to `{"error": "unknown action <x>"}` at 404.
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
- /** `action_commands`' own keys — the table IS the declaration, so this cannot drift from it. */
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());