animader-cli 0.0.0-stage → 0.1.1

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 (71) hide show
  1. package/LICENSE +121 -0
  2. package/adapters/build-node.mjs +265 -0
  3. package/adapters/devserver.mjs +481 -0
  4. package/adapters/devsession.mjs +619 -0
  5. package/adapters/export-node.mjs +603 -0
  6. package/adapters/export-run.mjs +126 -0
  7. package/adapters/generations.mjs +183 -0
  8. package/adapters/http.mjs +294 -0
  9. package/adapters/launch.mjs +83 -0
  10. package/adapters/node-fs.mjs +80 -0
  11. package/adapters/pngine.mjs +87 -0
  12. package/adapters/snapshot.mjs +143 -0
  13. package/adapters/target.mjs +104 -0
  14. package/adapters/watch.mjs +355 -0
  15. package/adapters/webaudio.mjs +102 -0
  16. package/bin/animader.mjs +31 -0
  17. package/hello/join.js +46 -0
  18. package/hello/run.js +259 -0
  19. package/lib/build.mjs +126 -0
  20. package/lib/buildloop.mjs +254 -0
  21. package/lib/contract.mjs +371 -0
  22. package/lib/errors.mjs +51 -0
  23. package/lib/export.mjs +445 -0
  24. package/lib/new.mjs +304 -0
  25. package/lib/pipeline.mjs +247 -0
  26. package/lib/receipt.mjs +331 -0
  27. package/lib/rmtree.mjs +49 -0
  28. package/lib/routes.mjs +170 -0
  29. package/lib/score.mjs +434 -0
  30. package/lib/show.mjs +458 -0
  31. package/lib/soundtrack.mjs +67 -0
  32. package/lib/vendor.mjs +99 -0
  33. package/lib/wav.mjs +197 -0
  34. package/package.json +47 -4
  35. package/player/boot.mjs +792 -0
  36. package/player/controller.mjs +366 -0
  37. package/player/devchannel.mjs +306 -0
  38. package/player/index.html +35 -0
  39. package/player/page.mjs +134 -0
  40. package/player/player.css +393 -0
  41. package/player/release.mjs +76 -0
  42. package/src/commands/build.mjs +201 -0
  43. package/src/commands/dev.mjs +214 -0
  44. package/src/commands/doctor.mjs +348 -0
  45. package/src/commands/export.mjs +158 -0
  46. package/src/commands/new.mjs +164 -0
  47. package/src/commands/project.mjs +46 -0
  48. package/src/config.mjs +110 -0
  49. package/src/router.mjs +149 -0
  50. package/src/toolchain.mjs +691 -0
  51. package/starters/circle/README.md +50 -0
  52. package/starters/circle/animader.json +4 -0
  53. package/starters/circle/gitignore +9 -0
  54. package/starters/circle/kernels/circle.wgsl +32 -0
  55. package/starters/circle/program.sjon +34 -0
  56. package/starters/circle/timeline.piece.sjon +26 -0
  57. package/starters/ember/bake/spans.bin +0 -0
  58. package/starters/ember/kernels/curl.wgsl +10 -0
  59. package/starters/ember/kernels/ember.wgsl +10 -0
  60. package/starters/ember/kernels/hash.wgsl +12 -0
  61. package/starters/ember/kernels/simplex.wgsl +27 -0
  62. package/starters/ember/program.sjon +328 -0
  63. package/starters/ember/timeline.piece.sjon +34 -0
  64. package/toolchain/licenses/animader.txt +121 -0
  65. package/toolchain/licenses/esbuild.txt +21 -0
  66. package/toolchain/licenses/pacer.txt +121 -0
  67. package/toolchain/licenses/pngine.txt +121 -0
  68. package/toolchain/manifest.json +212 -0
  69. package/toolchain/prepare.mjs +446 -0
  70. package/toolchain/uniform-type-name.js +184 -0
  71. package/README.md +0 -3
package/hello/run.js ADDED
@@ -0,0 +1,259 @@
1
+ // The hello player's deterministic run core: the piece of player.js that
2
+ // can run without a browser. The page injects pngine's `draw`/`setUniform`
3
+ // and pacer's compiled driver with its public routing pair
4
+ // (`routeWrites`/`toUniforms`); this module owns the 100-tick clock, the
5
+ // score's frame contract (pacer's pair, `eval(now)` in session microseconds
6
+ // returning write entries, split across the two buses in wire order and
7
+ // folded per entry into the `{ name: value }` shape the sinks take), the
8
+ // twelve input lanes pngine's `attachPointerTracking` holds, and the mapping
9
+ // from a session record back onto those lanes
10
+ // (npm/animader/test/pngine-normalizer.js is the same normaliser, kept for
11
+ // the comparison).
12
+ //
13
+ // The node test drives this module with recording fakes, so the tick
14
+ // arithmetic and the record-to-lane rules are pinned without WebGPU. The
15
+ // page's own run is the same code with the real viewer attached.
16
+
17
+ /** The piece's tick rate: a second is a hundred ticks. */
18
+ export const TICKS_PER_SECOND = 100;
19
+
20
+ /** One tick on the session clock, the same microseconds `:at` carries. */
21
+ export const MICROS_PER_TICK = 1_000_000 / TICKS_PER_SECOND;
22
+
23
+ /** The most ticks one paint may step, the page's catch-up cap. */
24
+ export const CATCH_UP_CAP = 12;
25
+
26
+ /** The buses the frame contract names: registry 0 visual, registry 1 audio. */
27
+ const REGISTRY_BUSES = 2;
28
+
29
+ const SPACE = 16;
30
+
31
+ /**
32
+ * Build one run over injected siblings.
33
+ *
34
+ * @param {object} options
35
+ * @param {{ setControl: Function, clearControl: Function, eval: Function }} options.pacer
36
+ * pacer's public pair as the frame contract injects it: `setControl(ref,
37
+ * value)` pins before `eval`, and `eval(now)` takes the frame's now in
38
+ * session microseconds and answers its write entries.
39
+ * @param {(entries: object[], registryCount: number) => object[][]} options.routeWrites
40
+ * pacer's public routing split (`pacer/route`): the frame's entries
41
+ * grouped by their `registry` bus index.
42
+ * @param {(entries: object[], registry?: number) => object} options.toUniforms
43
+ * pacer's public fold (`pacer/route`): one bus as `{ [name]: value }`, a
44
+ * number for one lane and a plain array for a wider target.
45
+ * @param {(name: string, value: number | number[], options: object) => void} options.setUniform
46
+ * writes one registry-0 entry by the target's own reflected name
47
+ * (pngine's `setUniform`).
48
+ * @param {(name: string, value: number | number[], options: object) => void} [options.setParam]
49
+ * writes one registry-1 entry (an audio host; absent on this page).
50
+ * @param {(options: {time: number}) => void} options.draw draws one frame
51
+ * (pngine's `draw`).
52
+ * @param {(frame: object) => void} [options.onFrame] the page's readout.
53
+ */
54
+ export function createPlayer({ pacer, routeWrites, toUniforms, setUniform, setParam = null, draw, onFrame = null }) {
55
+ const state = { tick: 0, heat: null, grain: null, held: null, draws: 0, writes: [] };
56
+ // pngine's twelve lanes: x, y, clickX, clickY, dx, dy, buttons,
57
+ // pressure, modifiers, scrollX, scrollY, pad.
58
+ const pointer = new Float32Array(12);
59
+ let lastX = 0;
60
+ let lastY = 0;
61
+ let mods = 0;
62
+
63
+ const lane = (value) => value / 65535;
64
+ /** The record's four modifier bits, the held Space bit kept. */
65
+ const modsOf = (bits) => (bits & 15) | (mods & SPACE);
66
+
67
+ /**
68
+ * One session record lands on the input lanes, the rule pngine's
69
+ * `attachPointerTracking` applies to the event the record came from
70
+ * (pointer position, accumulated deltas, buttons, pressure, modifiers;
71
+ * a release negates the click point and zeroes buttons and pressure; a
72
+ * wheel adds the sign of its 24.8 deltas to the scroll lanes; Space
73
+ * toggles the sixteenth modifier bit).
74
+ */
75
+ function apply(record) {
76
+ switch (record.kind) {
77
+ case "down": {
78
+ const x = lane(record.x);
79
+ const y = lane(record.y);
80
+ pointer[0] = x;
81
+ pointer[1] = y;
82
+ pointer[2] = x;
83
+ pointer[3] = y;
84
+ pointer[6] = record.buttons;
85
+ // pngine reads `e.pressure || 0.5` on a down, so a zero lane is a
86
+ // half press on the event the record rebuilds.
87
+ pointer[7] = lane(record.p) || 0.5;
88
+ pointer[8] = modsOf(record.mods);
89
+ mods = pointer[8];
90
+ lastX = x;
91
+ lastY = y;
92
+ break;
93
+ }
94
+ case "move": {
95
+ const x = lane(record.x);
96
+ const y = lane(record.y);
97
+ pointer[4] += x - lastX;
98
+ pointer[5] += y - lastY;
99
+ pointer[0] = x;
100
+ pointer[1] = y;
101
+ pointer[6] = record.buttons;
102
+ // `e.pressure || (e.buttons ? 0.5 : 0)` on a move.
103
+ pointer[7] = lane(record.p) || (record.buttons ? 0.5 : 0);
104
+ pointer[8] = modsOf(record.mods);
105
+ mods = pointer[8];
106
+ lastX = x;
107
+ lastY = y;
108
+ break;
109
+ }
110
+ case "up":
111
+ case "cancel": {
112
+ pointer[2] = -Math.abs(pointer[2]);
113
+ pointer[3] = -Math.abs(pointer[3]);
114
+ pointer[6] = 0;
115
+ pointer[7] = 0;
116
+ break;
117
+ }
118
+ case "wheel": {
119
+ pointer[9] += Math.sign(record.v[0]);
120
+ pointer[10] += Math.sign(record.v[1]);
121
+ pointer[8] = modsOf(record.mods);
122
+ mods = pointer[8];
123
+ break;
124
+ }
125
+ case "key": {
126
+ if (record.str === "Space") {
127
+ mods = record.buttons ? mods | SPACE : mods & ~SPACE;
128
+ pointer[8] = mods;
129
+ }
130
+ break;
131
+ }
132
+ default:
133
+ // resize, custom, input-write, mark, adjust and reset do not move the
134
+ // lanes; the page's input path is pointer, wheel and key.
135
+ break;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * One tick: the score's writes at the tick's own time, then one frame of
141
+ * the program at that time. pacer's route pair splits the frame's entries
142
+ * across the buses in wire order (`routeWrites`), and folding one entry at
143
+ * a time through `toUniforms` gives each write's value in the shape its bus
144
+ * sink takes: a number for one lane, a plain array otherwise. The writes
145
+ * reach the sinks in that order, so when two write the same name the later
146
+ * one wins, the frame contract's last-word rule. Every write rides
147
+ * `redraw: false`, and the tick's one draw carries them. Registry 0 lands
148
+ * by the target's own reflected name; registry 1 goes to `setParam` when
149
+ * the page has an audio sink. A declared-only target with no host name
150
+ * cannot key a by-name fold, so it is not written, exactly as the sinks
151
+ * could not have taken it.
152
+ */
153
+ function step() {
154
+ const t = state.tick / TICKS_PER_SECOND;
155
+ const entries = pacer.eval(state.tick * MICROS_PER_TICK);
156
+ state.writes = [];
157
+ if (entries) {
158
+ const groups = routeWrites(entries, REGISTRY_BUSES);
159
+ for (let registry = 0; registry < groups.length; registry++) {
160
+ for (const entry of groups[registry]) {
161
+ const value = toUniforms([entry], registry)[entry.name];
162
+ if (value === undefined) continue;
163
+ state.writes.push({ name: entry.name, registry, ncomp: entry.ncomp, value });
164
+ if (registry === 0) {
165
+ setUniform(entry.name, value, { redraw: false });
166
+ } else if (registry === 1 && setParam) {
167
+ setParam(entry.name, value, { redraw: false });
168
+ }
169
+ const scalar = Array.isArray(value) ? value[0] : value;
170
+ if (entry.name === "heat") state.heat = scalar;
171
+ if (entry.name === "grain") state.grain = scalar;
172
+ }
173
+ }
174
+ }
175
+ draw({ time: t });
176
+ state.tick += 1;
177
+ state.draws += 1;
178
+ // pngine drains dx, dy and the wheel sums after each frame
179
+ // (vendor/pngine.mjs, the draw path).
180
+ pointer[4] = 0;
181
+ pointer[5] = 0;
182
+ pointer[9] = 0;
183
+ pointer[10] = 0;
184
+ if (onFrame) onFrame(frame());
185
+ return state.tick;
186
+ }
187
+
188
+ /** The writes the last tick applied and the lanes it left, as plain values. */
189
+ function frame() {
190
+ return {
191
+ tick: state.tick,
192
+ heat: state.heat,
193
+ grain: state.grain,
194
+ held: state.held,
195
+ draws: state.draws,
196
+ writes: state.writes.map((w) => ({
197
+ name: w.name,
198
+ registry: w.registry,
199
+ ncomp: w.ncomp,
200
+ value: Array.isArray(w.value) ? [...w.value] : w.value,
201
+ })),
202
+ pointer: Array.from(pointer),
203
+ };
204
+ }
205
+
206
+ /** Step until the tick counter reaches `tick`. */
207
+ function stepTo(tick) {
208
+ while (state.tick < tick) step();
209
+ return state.tick;
210
+ }
211
+
212
+ /** Step the ticks strictly before session microsecond `micros`. */
213
+ function stepAt(micros) {
214
+ while (state.tick * MICROS_PER_TICK < micros) step();
215
+ return state.tick;
216
+ }
217
+
218
+ function setHeat(value) {
219
+ state.held = value;
220
+ pacer.setControl("heat", value);
221
+ }
222
+
223
+ function release() {
224
+ state.held = null;
225
+ pacer.clearControl("heat");
226
+ }
227
+
228
+ /** Back to tick zero, no held control, fresh lanes and no writes. */
229
+ function reset() {
230
+ state.tick = 0;
231
+ state.heat = null;
232
+ state.grain = null;
233
+ state.held = null;
234
+ state.draws = 0;
235
+ state.writes = [];
236
+ pointer.fill(0);
237
+ lastX = 0;
238
+ lastY = 0;
239
+ mods = 0;
240
+ pacer.clearControl("heat");
241
+ }
242
+
243
+ return {
244
+ state,
245
+ pointer,
246
+ apply,
247
+ step,
248
+ stepTo,
249
+ stepAt,
250
+ tick(n) {
251
+ for (let i = 0; i < n; i++) step();
252
+ return state.tick;
253
+ },
254
+ setHeat,
255
+ release,
256
+ reset,
257
+ frame,
258
+ };
259
+ }
package/lib/build.mjs ADDED
@@ -0,0 +1,126 @@
1
+ // Build orchestration, portable: the sequence and the honesty rules live
2
+ // here; every byte and process comes from an injected adapter. The order is
3
+ // fixed: verify the prepared runtime, clear stale generated output, validate
4
+ // the program, bake the executable payload. `preflightProject` is the
5
+ // legacy graphics-only spelling that adds the `/1` receipt on top of the
6
+ // shared bake — the score-validated full build (`lib/pipeline.mjs`) calls
7
+ // `bakeProject` directly and derives, packs, audits and receipts the score
8
+ // itself. There is deliberately no score compile, pacer invocation or
9
+ // target inference in the graphics bake; those semantics belong to the full
10
+ // pipeline and the browser's PieceHost/Pacer.load/READY chain.
11
+
12
+ import { DemoError, buildError } from './errors.mjs';
13
+ import { makeBuildReceipt, serializeReceipt } from './receipt.mjs';
14
+
15
+ function nativeFailure(error, phase) {
16
+ if (error instanceof DemoError || error?.code === 'native_pngine_failed') return error;
17
+ return buildError(
18
+ `native_${phase}`,
19
+ `pngine ${phase} failed: ${error?.message ?? String(error)}`,
20
+ 'Check the program with pngine validate; the demo does not infer or retry score targets.',
21
+ );
22
+ }
23
+
24
+ /**
25
+ * The shared graphics bake: verify the prepared runtime, ensure the output
26
+ * directory, clear the caller-named stale generated files, then validate
27
+ * and bake the program with pngine. Answers the payload bytes, their
28
+ * digest, the native tool identity and the verified runtime row — the
29
+ * inputs a receipt or an export drift check needs. Writes no receipt.
30
+ *
31
+ * @param {Object} options
32
+ * @param {{folder: string, files: Object}} options.project
33
+ * @param {{folder: string, outDir: string, payload: string}} options.paths
34
+ * @param {string[]} [options.staleFiles] generated files to clear first
35
+ * @param {Object} options.io
36
+ * @param {Object} options.native
37
+ * @param {Object} options.runtime
38
+ * @param {(bytes: Uint8Array) => string} options.hashBytes
39
+ */
40
+ export async function bakeProject({ project, paths, staleFiles = [], io, native, runtime, hashBytes }) {
41
+ const verified = await runtime.verify();
42
+
43
+ await io.ensureDir(paths.outDir);
44
+ for (const stale of staleFiles) {
45
+ await io.removeFile(stale);
46
+ }
47
+
48
+ const identity = await native.identity();
49
+ try {
50
+ await native.validate({ cwd: paths.folder, program: project.files.program.name });
51
+ } catch (error) {
52
+ throw nativeFailure(error, 'validate');
53
+ }
54
+
55
+ try {
56
+ await native.bake({ cwd: paths.folder, program: project.files.program.name, output: paths.payload });
57
+ } catch (error) {
58
+ throw nativeFailure(error, 'bake');
59
+ }
60
+
61
+ const stats = await io.statFile(paths.payload);
62
+ if (stats === null || !stats.isFile || stats.size === 0) {
63
+ throw buildError(
64
+ 'bake_missing_output',
65
+ `pngine did not leave a non-empty payload at ${paths.payloadRelative ?? paths.payload}`,
66
+ 'Run pngine render by hand from the project folder and inspect its stderr.',
67
+ );
68
+ }
69
+ const bytes = await io.readFile(paths.payload);
70
+ return {
71
+ payloadPath: paths.payload,
72
+ bytes,
73
+ sha256: hashBytes(bytes),
74
+ identity,
75
+ runtime: {
76
+ format: verified.format,
77
+ digest: verified.digest,
78
+ files: verified.files.length,
79
+ },
80
+ };
81
+ }
82
+
83
+ /**
84
+ * The legacy graphics-only preflight: the shared bake plus the
85
+ * `animader-demo-build/1` receipt. `node demo/show.mjs --preflight-only`
86
+ * keeps exactly this meaning; it never validates a score.
87
+ *
88
+ * @param {Object} options
89
+ * @param {{folder: string, files: Object}} options.project
90
+ * @param {number} options.seconds
91
+ * @param {Object} options.paths
92
+ * @param {Object} options.io
93
+ * @param {Object} options.native
94
+ * @param {Object} options.runtime
95
+ * @param {(bytes: Uint8Array) => string} options.hashBytes
96
+ */
97
+ export async function preflightProject({ project, seconds, paths, io, native, runtime, hashBytes }) {
98
+ const baked = await bakeProject({
99
+ project,
100
+ paths,
101
+ staleFiles: [paths.payload, paths.receipt],
102
+ io,
103
+ native,
104
+ runtime,
105
+ hashBytes,
106
+ });
107
+ const receipt = makeBuildReceipt({
108
+ project,
109
+ projectName: paths.projectName,
110
+ seconds,
111
+ output: {
112
+ name: paths.payloadRelative,
113
+ bytes: baked.bytes.byteLength,
114
+ sha256: baked.sha256,
115
+ },
116
+ runtime: baked.runtime,
117
+ tool: baked.identity,
118
+ });
119
+
120
+ await io.writeFile(paths.receipt, serializeReceipt(receipt));
121
+ return {
122
+ payloadPath: paths.payload,
123
+ receiptPath: paths.receipt,
124
+ receipt,
125
+ };
126
+ }
@@ -0,0 +1,254 @@
1
+ // The watch-mode build loop (plan section 5): the scheduling decisions only.
2
+ // Bursts of change notifications coalesce into one build (a trailing debounce
3
+ // while idle); changes that arrive while a build is running set one dirty
4
+ // flag, so however many landed, exactly one follow-up build is queued; a
5
+ // candidate built while any change was observed is discarded and rebuilt —
6
+ // it is never published — so only a candidate that observed a quiet window
7
+ // becomes the served generation. Builds are serialized by construction (one
8
+ // cycle at a time), which is what keeps two generations from interleaving.
9
+ //
10
+ // When the caller provides `isStale`, the discard decision stops depending
11
+ // on event delivery: after `build` resolves and before the discard/publish
12
+ // choice, the candidate's own inputs are checked once more, so a change
13
+ // whose event has not been delivered yet (a platform watcher with latency,
14
+ // a silent watcher) still cannot publish. `isStale` is the authority
15
+ // whenever it is provided — a `dirty` flag set by a prompt whose identity
16
+ // the candidate's own start snapshot already consumed is a late duplicate
17
+ // (the macOS FSEvents shape) and is dropped by that same check; callers
18
+ // that pass no `isStale` keep the dirty flag as the decision, exactly as
19
+ // before.
20
+ //
21
+ // When the caller provides `shouldBuild`, one more decision moves out of
22
+ // the loop's own state: a cycle entered from a prompt (the debounce timer,
23
+ // the initial kick, or the one follow-up owed by a dirty build) asks it
24
+ // whether the inputs a build would read have moved since the last
25
+ // attempt, and a `false` answer clears the owed work and schedules
26
+ // nothing — a prompt for unchanged inputs costs no build. The check runs
27
+ // once, before the cycle's first build only: a candidate discarded by
28
+ // `dirty` or `isStale` always owes its rebuild, so that path never asks.
29
+ // A check that cannot run cannot prove a skip either and builds, the same
30
+ // safe direction `isStale` takes.
31
+ //
32
+ // The loop builds nothing and owns no filesystem: `build` produces a
33
+ // candidate, `discard` throws one away, `publish` makes it current, and each
34
+ // is injected. A rejected `build` reaches `onError` and the loop keeps
35
+ // serving later changes — the caller reports it, the watcher keeps watching.
36
+ // `close` is final: a pending debounce is cancelled, an in-flight candidate
37
+ // is discarded when it lands, and later changes start nothing.
38
+ //
39
+ // This module is portable: no `node:` import, no process state, no DOM. The
40
+ // debounce uses the plain global timer pair, the same shape in a host and a
41
+ // test.
42
+
43
+ /**
44
+ * @param {Object} options
45
+ * @param {() => Promise<any>} options.build one build over a fresh staging
46
+ * area, resolving to its candidate
47
+ * @param {(candidate: any) => void|Promise<void>} options.discard throw a
48
+ * candidate away without ever publishing it
49
+ * @param {(candidate: any) => void|Promise<void>} options.publish make a
50
+ * candidate the current generation
51
+ * @param {(error: unknown) => void} [options.onError] a rejected build,
52
+ * reported instead of thrown
53
+ * @param {(candidate: any) => boolean|Promise<boolean>} [options.isStale]
54
+ * the authority on whether the candidate's own inputs moved while it was
55
+ * being built, consulted after `build` resolves and before the
56
+ * discard/publish decision; true sends the candidate down the same
57
+ * discard-and-rebuild path the dirty flag does. When provided it decides
58
+ * even when `dirty` is set — an arrived event is not a moved input, and
59
+ * the platform's late duplicate delivery for a write the candidate
60
+ * already consumed is not a change
61
+ * @param {() => boolean|Promise<boolean>} [options.shouldBuild] the
62
+ * authority on whether the inputs a scheduled build would read have moved
63
+ * since the last attempt, consulted when a cycle entered from a prompt is
64
+ * about to start its first build; false clears the owed work and builds
65
+ * nothing. Never consulted for a rebuild a discard owes.
66
+ * @param {number} [options.debounceMs] the idle burst-coalescing window
67
+ */
68
+ export function createBuildLoop({ build, discard, publish, onError = null, isStale = null, shouldBuild = null, debounceMs = 150 }) {
69
+ if (typeof build !== 'function' || typeof discard !== 'function' || typeof publish !== 'function') {
70
+ throw new TypeError('createBuildLoop needs build, discard and publish functions');
71
+ }
72
+
73
+ let closed = false;
74
+ let running = false;
75
+ let dirty = false;
76
+ let timer = null;
77
+ const idleWaiters = new Set();
78
+
79
+ function clearTimer() {
80
+ if (timer !== null) {
81
+ clearTimeout(timer);
82
+ timer = null;
83
+ }
84
+ }
85
+
86
+ /** Resolve the idle waiters once no cycle runs and none is owed. */
87
+ function settleIfIdle() {
88
+ if (running || dirty || timer !== null) return;
89
+ const waiters = [...idleWaiters];
90
+ idleWaiters.clear();
91
+ for (const waiter of waiters) waiter();
92
+ }
93
+
94
+ /**
95
+ * A discard or publish rejection is reported like a build failure instead
96
+ * of leaving an unhandled rejection in the host; the cycle then settles
97
+ * until the next change.
98
+ */
99
+ async function guard(step) {
100
+ try {
101
+ await step();
102
+ } catch (error) {
103
+ if (onError) onError(error);
104
+ }
105
+ }
106
+
107
+ /** One serialized cycle: build until a candidate saw a quiet window. */
108
+ async function cycle() {
109
+ running = true;
110
+ try {
111
+ // A scheduled build is about to start: the caller's own authority on
112
+ // whether the inputs it would read have moved since the last
113
+ // attempt. This runs for every cycle a prompt enters — the debounce
114
+ // timer, the initial kick, the one follow-up a dirty build owes —
115
+ // and never for the rebuild a discard owes (that rebuild is owed by
116
+ // the discard itself, whatever the inputs now equal). A prompt that
117
+ // arrives while the check runs sets `dirty` here, and a dirty cycle
118
+ // builds regardless of the answer, so no prompt is lost to the gap
119
+ // between the check's stat and its verdict.
120
+ if (shouldBuild !== null) {
121
+ let wanted;
122
+ try {
123
+ wanted = await shouldBuild();
124
+ } catch (error) {
125
+ // A check that cannot run cannot prove a skip either: report it
126
+ // like a build failure and build — the safe direction.
127
+ if (onError) onError(error);
128
+ wanted = true;
129
+ }
130
+ if (!wanted) {
131
+ if (!dirty) return; // nothing moved: no build, nothing scheduled
132
+ }
133
+ }
134
+ for (;;) {
135
+ dirty = false;
136
+ let candidate;
137
+ try {
138
+ candidate = await build();
139
+ } catch (error) {
140
+ if (onError) onError(error);
141
+ // The dirty flag set during the failed build still owes its one
142
+ // follow-up; otherwise the loop settles until the next change.
143
+ if (!dirty) return;
144
+ continue;
145
+ }
146
+ if (closed) {
147
+ await guard(() => discard(candidate));
148
+ return;
149
+ }
150
+ let moved = dirty;
151
+ if (isStale !== null) {
152
+ // The authority, even over a dirty flag a prompt set: the check
153
+ // re-stats the candidate's own start snapshot, so a prompt whose
154
+ // identity that snapshot already consumed (a late duplicate
155
+ // delivered while this candidate was in flight) answers fresh
156
+ // and is dropped — "an event arrived" is not "the inputs
157
+ // moved". Callers without isStale keep the dirty flag as
158
+ // before.
159
+ try {
160
+ moved = await isStale(candidate);
161
+ } catch (error) {
162
+ // A check that cannot run cannot prove the candidate fresh
163
+ // either: report it like a build failure and let the rebuild
164
+ // decide — the same safe direction an unanswerable stat has.
165
+ if (onError) onError(error);
166
+ moved = true;
167
+ }
168
+ }
169
+ if (moved) {
170
+ // A change was observed while this candidate was being taken or
171
+ // built — by an event (the fast path) or by the candidate's own
172
+ // inputs having moved on disk (the authority): discard it and
173
+ // rebuild before publishing anything.
174
+ await guard(() => discard(candidate));
175
+ continue;
176
+ }
177
+ await guard(() => publish(candidate));
178
+ return;
179
+ }
180
+ } finally {
181
+ running = false;
182
+ if (!closed && dirty && timer === null) {
183
+ // Exactly one follow-up for the changes that landed during the last
184
+ // build; no debounce is owed because the events already happened.
185
+ dirty = false;
186
+ cycle();
187
+ return;
188
+ }
189
+ settleIfIdle();
190
+ }
191
+ }
192
+
193
+ /**
194
+ * A promise that resolves once no build is running and none is queued —
195
+ * the initial build's settle, and the test/telemetry seam for the same.
196
+ */
197
+ function idle() {
198
+ if (!running && !dirty && timer === null) return Promise.resolve();
199
+ return new Promise((resolve) => {
200
+ idleWaiters.add(resolve);
201
+ });
202
+ }
203
+
204
+ return {
205
+ /** True while a build cycle is in flight. */
206
+ get running() { return running; },
207
+ /** True when a change arrived during the current build. */
208
+ get dirty() { return dirty; },
209
+ /** True when a debounce is scheduled or a follow-up is queued. */
210
+ get pending() { return timer !== null; },
211
+ /** True once close() has been called; the loop does nothing after it. */
212
+ get closed() { return closed; },
213
+ /** Resolves when no build runs and none is queued (or once closed). */
214
+ idle,
215
+
216
+ /**
217
+ * One filtered change arrived. Idle: schedule one coalesced cycle after
218
+ * the debounce window (the timer resets per event, so a burst is one
219
+ * build). Building: set the single dirty flag that queues one follow-up.
220
+ */
221
+ notifyChange() {
222
+ if (closed) return;
223
+ if (running) {
224
+ dirty = true;
225
+ return;
226
+ }
227
+ if (timer !== null) clearTimeout(timer);
228
+ timer = setTimeout(() => {
229
+ timer = null;
230
+ cycle();
231
+ }, debounceMs);
232
+ },
233
+
234
+ /** Start one cycle now (the initial build); a no-op while one runs. */
235
+ kick() {
236
+ if (closed || running) return;
237
+ clearTimer();
238
+ cycle();
239
+ },
240
+
241
+ /** Final: cancel the pending debounce, discard what is in flight. */
242
+ async close() {
243
+ if (closed) return;
244
+ closed = true;
245
+ clearTimer();
246
+ // An in-flight cycle observes `closed` when its build resolves and
247
+ // discards the candidate itself; a cycle between builds has nothing
248
+ // in flight. Wait for neither: close must not block on a build.
249
+ const waiters = [...idleWaiters];
250
+ idleWaiters.clear();
251
+ for (const waiter of waiters) waiter();
252
+ },
253
+ };
254
+ }