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
@@ -0,0 +1,619 @@
1
+ // The dev session operation (plan section 5): everything `animader dev`
2
+ // does between argv and the process signal wait. It owns one serving
3
+ // lifecycle:
4
+ //
5
+ // session root (outside the project) ─┬─ generation store (staging,
6
+ // │ publish, retention, sweep)
7
+ // dev server (player/error page, │
8
+ // pinned /gen/<id>/ mounts, channel) ─┘
9
+ // build loop ── build = inspect + resolve seconds + runSnapshotBuild
10
+ // in a work tree BESIDE the fresh staging root, moved into
11
+ // it once finished; publish = store.publish + reload
12
+ // broadcast; a dirty candidate is discarded and rebuilt,
13
+ // and so is a candidate whose own inputs moved on disk
14
+ // while it was built — isStale re-stats them at the
15
+ // publish decision, so no event delivery is waited on
16
+ // watcher ── filtered recursive fs.watch; every filtered event is a
17
+ // PROMPT, never a decision
18
+ // sweep ── the stat safety net over the last build's consumed-input
19
+ // rows (the receipt's inputs.files) and their directories,
20
+ // feeding the SAME loop entry point: a platform watcher
21
+ // that goes silent after an atomic save — or a network
22
+ // filesystem that answers nothing — still gets its one
23
+ // build per edit
24
+ // session ── the only decider of "did the inputs change?": a prompt
25
+ // from either source asks the loop to LOOK, and before a
26
+ // scheduled build starts the session compares the input
27
+ // set on disk against its own `attempted` snapshot —
28
+ // written when a build starts, pass or fail — and skips
29
+ // without building while they are equal, so a replayed
30
+ // pre-start write, a platform's late duplicate delivery
31
+ // and a failed build's own prompts cost nothing, while
32
+ // any real change on disk always answers exactly one
33
+ // build. The same comparison decides at the publish
34
+ // boundary: a prompt that arrived while the candidate
35
+ // was in flight is dropped when the candidate's own start
36
+ // snapshot covers its path and the disk still holds that
37
+ // identity (the write was consumed; the macOS FSEvents
38
+ // late delivery builds nothing), and keeps the discard
39
+ // whenever the snapshot cannot speak for the prompted
40
+ // path — the same fail-safe direction every unanswerable
41
+ // stat takes
42
+ //
43
+ // Each tree under the session root has exactly one owner: a build owns its
44
+ // work tree (input copy and pipeline scratch) and removes it, the store owns
45
+ // the staging roots (one finished generation tree each) and removes them.
46
+ // The server starts BEFORE the first build, so an initial build failure
47
+ // serves the error/recovery page while the watcher stays available; the
48
+ // last successful generation keeps serving byte-identically through failed
49
+ // rebuilds. stop() closes the watcher and the loop, waits for the loop's
50
+ // last cycle to settle (the loop's close must not block on a build, but
51
+ // nothing may remove the session tree under a live writer), then closes
52
+ // the channel connections, the server and the store, in that order.
53
+
54
+ import { promises as fsp } from 'node:fs';
55
+ import { tmpdir } from 'node:os';
56
+ import { basename, dirname, join, relative, resolve } from 'node:path';
57
+ import { realpath } from 'node:fs/promises';
58
+ import { createHash } from 'node:crypto';
59
+ import { fileURLToPath } from 'node:url';
60
+
61
+ import { runSnapshotBuild, createBuildProducers } from './build-node.mjs';
62
+ import { startDevServer } from './devserver.mjs';
63
+ import { launchBrowser } from './launch.mjs';
64
+ import { createGenerationStore } from './generations.mjs';
65
+ import { createProjectWatchFilter, watchProject, createInputSweep, statRow, statDir, DEFAULT_SWEEP_INTERVAL_MS } from './watch.mjs';
66
+ import { createNodeIo } from './node-fs.mjs';
67
+ import { createPngineNative } from './pngine.mjs';
68
+ import { createBuildLoop } from '../lib/buildloop.mjs';
69
+ import { errorLines, DemoError } from '../lib/errors.mjs';
70
+ import { inspectProject, PROJECT_FILES, SOUNDTRACK_NAMES } from '../lib/contract.mjs';
71
+ import { contributorToolchain, resolveToolchain } from '../src/toolchain.mjs';
72
+
73
+ const PKG = resolve(dirname(fileURLToPath(import.meta.url)), '..');
74
+
75
+ /**
76
+ * The fixed seat names the session always carries in its input snapshot,
77
+ * even before the first build has returned receipt rows to record: their
78
+ * identities (a missing seat is its own value — exactly one soundtrack
79
+ * candidate survives inspection) are what a first, failing build's
80
+ * attempted snapshot can still see, so the edit that fixes it answers a
81
+ * build rather than a skip.
82
+ */
83
+ const SEAT_INPUT_ROWS = Object.freeze([
84
+ PROJECT_FILES.program,
85
+ PROJECT_FILES.timeline,
86
+ ...SOUNDTRACK_NAMES,
87
+ ]);
88
+
89
+ /**
90
+ * @param {Object} options
91
+ * @param {string} options.folder the project folder (realpath'd here; a
92
+ * missing folder is a hard failure before any server work)
93
+ * @param {number} [options.port] 127.0.0.1 port; 0 chooses a free one
94
+ * @param {boolean} [options.open] attempt the browser launch once the
95
+ * first build settles
96
+ * @param {boolean} [options.watch] watch for edits and rebuild (the
97
+ * `--no-watch` spelling passes false)
98
+ * @param {() => Promise<{seconds: number}>} options.resolveSeconds the
99
+ * per-build host-length resolution (animader.json validated, then any
100
+ * explicit override)
101
+ * @param {(line: string) => void} [options.out] stdout line writer
102
+ * @param {(line: string) => void} [options.err] stderr line writer
103
+ * @param {{native?: object, toolchain?: object, vendorRoot?: string, producers?: object,
104
+ * launch?: Function, watchImpl?: Function}} [options.hosts] test
105
+ * seams; the defaults are this workspace's real pngine CLI, the
106
+ * prepared bundle, the real build producer ports, the platform-aware
107
+ * browser launcher and the host's own recursive fs.watch
108
+ * @param {number} [options.debounceMs] the burst-coalescing window
109
+ * @param {number} [options.sweepIntervalMs] the stat safety net's period
110
+ * (default ~1 s; 0 is not a period)
111
+ * @param {string} [options.sessionRoot] the session tree's own root
112
+ * directory, ensured to exist (default: a fresh OS temporary directory,
113
+ * outside the project)
114
+ * @returns {Promise<{served: object, store: object, stop: () => Promise<{published: boolean, lastError: Object|null}>}>}
115
+ */
116
+ export async function runDevSession({
117
+ folder,
118
+ port = 0,
119
+ open = true,
120
+ watch = true,
121
+ resolveSeconds,
122
+ out = (line) => process.stdout.write(`${line}\n`),
123
+ err = (line) => process.stderr.write(`${line}\n`),
124
+ hosts = {},
125
+ debounceMs,
126
+ sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS,
127
+ sessionRoot,
128
+ }) {
129
+ if (typeof resolveSeconds !== 'function') {
130
+ throw new TypeError('runDevSession needs a resolveSeconds function (the shared config resolution)');
131
+ }
132
+
133
+ let real;
134
+ try {
135
+ real = await realpath(folder);
136
+ } catch {
137
+ throw new DemoError('folder_missing', `the project folder does not exist: ${folder}`, {
138
+ exitCode: 3,
139
+ remedy: 'Pass the folder that holds program.sjon, timeline.piece.sjon and one of soundtrack.wav, soundtrack.ogg, soundtrack.opus.',
140
+ });
141
+ }
142
+
143
+ const io = createNodeIo();
144
+ const toolchain = hosts.toolchain ?? (hosts.vendorRoot !== undefined
145
+ ? contributorToolchain({ vendorRoot: hosts.vendorRoot })
146
+ : await resolveToolchain());
147
+ const runtime = { verify: () => toolchain.verifyRuntime() };
148
+ const native = hosts.native ?? createPngineNative({ binary: toolchain.pngine.cli });
149
+ const producers = hosts.producers ?? createBuildProducers({ toolchain });
150
+ const launch = hosts.launch ?? ((url) => launchBrowser(url));
151
+
152
+ const root = sessionRoot ?? await fsp.mkdtemp(join(tmpdir(), 'animader-dev-session-'));
153
+ await fsp.mkdir(root, { recursive: true });
154
+ const store = createGenerationStore({ root });
155
+ let lastError = null;
156
+ let everPublished = false;
157
+ let watcher = null;
158
+ let sweep = null;
159
+ // The publish-time freshness record: the receipt input rows of the last
160
+ // build that returned one, stat'd at the instant each build STARTS — the
161
+ // same "everything up to this instant is inside this build" boundary the
162
+ // sweep's accounted() draws. isStale re-stats those rows when the build
163
+ // resolves: any difference means an input moved under the candidate, and
164
+ // the candidate is discarded and rebuilt even if no watcher event has
165
+ // been delivered yet (the decision reads the filesystem; it does not
166
+ // wait for delivery).
167
+ let inputRows = [];
168
+
169
+ // The session's own record of the inputs the last attempted build
170
+ // covers — the snapshot the skip compares. Written when a build STARTS
171
+ // (pass or fail: a failed attempt still counts as attempted, which is
172
+ // what keeps its own prompts from retrying it) over the last returning
173
+ // build's receipt rows plus the fixed seat names, at the same boundary
174
+ // accounted() draws; re-adopted at each publish over the receipt's own
175
+ // rows, so the snapshot always covers what the served generation
176
+ // consumed. Files carry `statRow` identities, directories the same
177
+ // filtered listing keys the sweep compares; a missing path is its own
178
+ // value, so a deletion is a change and an unanswerable stat is a
179
+ // change. Null until the first build starts; the skip fails open then.
180
+ let attempted = null; // { files: Map<path, identity>, dirs: Map<dir, key> }
181
+
182
+ // Project-relative input rows ('/'-separated, the receipt's spelling)
183
+ // and the directories that hold them, as absolute paths.
184
+ const inputPath = (row) => join(real, ...row.split('/'));
185
+ const inputDirRow = (row) => {
186
+ const cut = row.lastIndexOf('/');
187
+ return cut === -1 ? '' : row.slice(0, cut);
188
+ };
189
+
190
+ // The one path policy everything shares: the watcher's filter, the
191
+ // sweep's directory listings and this session's own snapshots.
192
+ const watchFilter = createProjectWatchFilter({ projectRoot: real });
193
+
194
+ // ANIMADER_WATCH_TRACE=1: one line per prompt decision (the prompted
195
+ // path, its identity, the attempted identity for that path, and the
196
+ // verdict), one line per build (its reason: changed, or the rebuild a
197
+ // stale-at-publish discard owes), one line per publish (the generation
198
+ // and the published snapshot's digest). The prompt LABELS are recorded
199
+ // always — the discard decision's fail-safe needs to know which paths
200
+ // arrived during a build — but nothing is stat'd, hashed or printed for
201
+ // the trace unless the flag is set. The escape hatch exists for the day
202
+ // a platform disagrees, so the next step is a trace file rather than
203
+ // another hypothesis.
204
+ const traceEnabled = process.env.ANIMADER_WATCH_TRACE === '1';
205
+ const trace = (line) => { if (traceEnabled) err(`watch trace: ${line}`); };
206
+ const pendingPrompts = []; // project-relative labels (or '(no path)'), awaiting the next decision
207
+ let owedBuildReason = null; // 'stale-at-publish' when a discard owes the next build
208
+
209
+ const served = await startDevServer({
210
+ generations: store,
211
+ runtimeRoot: toolchain.runtime.kind === 'dir' ? toolchain.runtime.root : undefined,
212
+ runtimeFiles: toolchain.runtime.kind === 'map' ? await installedRuntimeFiles(toolchain, root) : undefined,
213
+ latestError: () => lastError,
214
+ // A request-path fault reaches the session's dev log (the err seam),
215
+ // never a silent bare 500.
216
+ onFault: (line) => err(line),
217
+ port,
218
+ });
219
+
220
+ out('animader dev — edit, build and reload (one generation per build)');
221
+ out(`project: ${real}`);
222
+ out(`serving: ${served.url}`);
223
+ out('page: the player at / (an error/recovery page until the first build succeeds)');
224
+ out('channel: /dev (development only: reload on successful builds, stale on failures)');
225
+
226
+ function reportBuildError(error) {
227
+ lastError = error;
228
+ for (const line of errorLines(error)) err(line);
229
+ if (!everPublished) {
230
+ out('no generation yet: / serves the build error page until a build succeeds');
231
+ }
232
+ served.channel.broadcast('stale', {
233
+ error: {
234
+ code: error?.code ?? null,
235
+ message: error?.message ?? String(error),
236
+ remedy: error?.remedy ?? null,
237
+ },
238
+ });
239
+ }
240
+
241
+ /**
242
+ * The installed runtime mount: the manifest's resolved files plus the
243
+ * rendered provenance document the page's boot fetches from
244
+ * /runtime/provenance.json. The rendered file lives in this session's
245
+ * own tree, so the store's cleanup owns it.
246
+ */
247
+ async function installedRuntimeFiles(toolchain, sessionRoot) {
248
+ const files = await toolchain.runtime.files();
249
+ const { provenance } = await toolchain.verifyRuntime();
250
+ const provenancePath = join(sessionRoot, 'runtime-provenance.json');
251
+ await fsp.writeFile(provenancePath, `${JSON.stringify(provenance, null, 2)}\n`);
252
+ files.push({ path: 'provenance.json', source: provenancePath });
253
+ return files;
254
+ }
255
+
256
+ async function build() {
257
+ // The sweep's baselines move to NOW before anything is read: every
258
+ // change up to this instant is inside the build that starts here, so
259
+ // the sweep cannot re-report it as a second change (a watcher event
260
+ // plus a sweep hit for one edit stays exactly one build). This runs
261
+ // for failing builds too: their inputs are accounted all the same,
262
+ // which is what keeps a failed rebuild from looping on the sweep.
263
+ sweep?.accounted();
264
+ // The attempted snapshot, drawn at the same boundary: every input row
265
+ // the last returning build recorded, plus the fixed seat names and
266
+ // the filtered listings of the directories that hold them. Before the
267
+ // first build returns there are no receipt rows — the seats and the
268
+ // root listing stand in for them. This is written before anything is
269
+ // read and survives a failing build: the inputs a failed build
270
+ // attempted are exactly these, so no prompt for them can retry it.
271
+ const startRows = [...new Set([...inputRows, ...SEAT_INPUT_ROWS])];
272
+ const startFiles = await Promise.all(startRows.map(async (row) => [inputPath(row), await statRow(inputPath(row))]));
273
+ const startDirs = await Promise.all(
274
+ [...new Set(startRows.map(inputDirRow))].map(async (dirRow) => [inputPath(dirRow), await statDir(inputPath(dirRow), watchFilter)]),
275
+ );
276
+ attempted = { files: new Map(startFiles), dirs: new Map(startDirs) };
277
+ const buildReason = owedBuildReason ?? 'changed';
278
+ owedBuildReason = null;
279
+ trace(`build: reason=${buildReason}`);
280
+ // The candidate's own freshness baseline, taken in the same pass: the
281
+ // receipt rows of the last returning build, at the identities they
282
+ // hold now. Anything that differs from this record by the time the
283
+ // build resolves moved under the candidate, whether or not any event
284
+ // said so.
285
+ const freshness = inputRows.map((row) => {
286
+ const path = inputPath(row);
287
+ return [path, attempted.files.get(path)];
288
+ });
289
+ const staging = await store.stagingRoot();
290
+ let work = null;
291
+ try {
292
+ // A fresh inspection per build: the soundtrack selection and the
293
+ // authored bytes are whatever the project holds now, and the
294
+ // pipeline's own drift check refuses a project that changed under
295
+ // the snapshot.
296
+ const project = await inspectProject({ folder: real, io });
297
+ const { seconds } = await resolveSeconds();
298
+ // The snapshot's input copy and the pipeline's scratch live beside
299
+ // the staging root (this build owns and removes that work tree), so
300
+ // the staging root holds exactly the finished generation tree and
301
+ // only the store ever removes it — one owner per tree.
302
+ const { built, work: workTree, workProject } = await runSnapshotBuild({
303
+ project,
304
+ seconds,
305
+ projectName: basename(real),
306
+ native,
307
+ runtime,
308
+ producers,
309
+ io,
310
+ workRoot: root,
311
+ });
312
+ // The sweep's next set is THIS build's own consumed-input record —
313
+ // the receipt's rows, the one input list that cannot drift from
314
+ // what the build actually reads (program, timeline, soundtrack and
315
+ // every authored resource beside them, kernels included). The
316
+ // publish-time freshness record adopts the same rows: the next
317
+ // build's baseline stats them at its own start.
318
+ const rows = built.receipt.inputs.files.map((row) => row.path);
319
+ sweep?.inputs(rows);
320
+ inputRows = rows;
321
+ work = workTree;
322
+ const dir = join(staging, 'project');
323
+ await fsp.rename(workProject, dir);
324
+ await fsp.rm(work, { recursive: true, force: true });
325
+ work = null;
326
+ return {
327
+ staging,
328
+ dir,
329
+ seconds,
330
+ soundtrack: project.files.soundtrack.name,
331
+ freshness,
332
+ // The same start snapshot's directory keys: the discard decision
333
+ // re-stats them beside the file rows, so a file created inside a
334
+ // covered directory while this build ran still discards the
335
+ // candidate even when no prompt for it arrives.
336
+ startDirs: [...startDirs],
337
+ };
338
+ } catch (cause) {
339
+ if (work !== null) {
340
+ await fsp.rm(work, { recursive: true, force: true }).catch(() => {});
341
+ }
342
+ await fsp.rm(staging, { recursive: true, force: true });
343
+ throw cause;
344
+ }
345
+ }
346
+
347
+ async function discard(candidate) {
348
+ await fsp.rm(candidate.staging, { recursive: true, force: true });
349
+ out('build discarded: the project changed during the build; rebuilding before publishing');
350
+ }
351
+
352
+ /**
353
+ * The publish-time authority on whether the candidate's inputs moved:
354
+ * re-stat every row baselined when its build started and answer true on
355
+ * any difference — a changed identity, a path now missing, a replaced
356
+ * file — or on a stat that cannot answer at all. Unanswerable is a
357
+ * change here, the safe direction for a publish decision (unlike a
358
+ * 404): a candidate whose freshness cannot be proven is never served.
359
+ * The same pass covers the start snapshot's directory keys, and every
360
+ * prompt that arrived while the candidate was in flight is judged
361
+ * against that snapshot: a prompt whose path it covers has just been
362
+ * proven equal — the build consumed that write, and the prompt is a
363
+ * late duplicate (the macOS FSEvents shape) that must be dropped —
364
+ * while a prompt the snapshot cannot speak for keeps the discard, the
365
+ * same safe direction `needsBuild` takes. Before the first build
366
+ * returns there are no rows, so the initial candidate is judged by the
367
+ * loop's dirty flag alone.
368
+ */
369
+ async function isStale(candidate) {
370
+ for (const [path, was] of candidate.freshness) {
371
+ const now = await statRow(path);
372
+ if (now !== was || (typeof now === 'string' && now.startsWith('unreadable:'))) {
373
+ trace('discard: the candidate\'s inputs moved while it was built (stale at publish)');
374
+ owedBuildReason = 'stale-at-publish';
375
+ return true;
376
+ }
377
+ }
378
+ for (const [dir, wasKey] of candidate.startDirs ?? []) {
379
+ if (wasKey !== await statDir(dir, watchFilter)) {
380
+ trace('discard: the candidate\'s input directories moved while it was built (stale at publish)');
381
+ owedBuildReason = 'stale-at-publish';
382
+ return true;
383
+ }
384
+ }
385
+ // The prompts that arrived while this candidate was in flight (both
386
+ // sources feed the same list). The passes above proved every covered
387
+ // path equal, so a covered prompt is a duplicate the build already
388
+ // consumed; an uncovered prompt — a new path the start snapshot
389
+ // cannot speak for, or an event that named no path — keeps the
390
+ // discard: dropping what cannot be proven would be the missed-edit
391
+ // direction.
392
+ const batch = pendingPrompts.splice(0);
393
+ let kept = false;
394
+ for (const label of batch) {
395
+ if (!coveredByStartSnapshot(candidate, label)) {
396
+ kept = true;
397
+ break;
398
+ }
399
+ }
400
+ await tracePromptDecisions(batch, kept ? 'keep' : 'drop');
401
+ if (kept) {
402
+ trace('discard: a prompt arrived during the build for a path its start snapshot does not cover');
403
+ owedBuildReason = 'stale-at-publish';
404
+ return true;
405
+ }
406
+ return false;
407
+ }
408
+
409
+ /** Can the candidate's own start snapshot speak for a prompted path?
410
+ * Only a covered path can be proven already-consumed; everything else
411
+ * keeps the discard. */
412
+ function coveredByStartSnapshot(candidate, label) {
413
+ if (label === '(no path)') return false;
414
+ const isDir = label.endsWith('/');
415
+ const path = inputPath(label.replace(/\/+$/, ''));
416
+ return isDir
417
+ ? candidate.startDirs?.some(([dir]) => dir === path) === true
418
+ : candidate.freshness.some(([file]) => file === path);
419
+ }
420
+
421
+ async function publish(candidate) {
422
+ const generation = await store.publish({
423
+ staging: candidate.staging,
424
+ dir: candidate.dir,
425
+ seconds: candidate.seconds,
426
+ soundtrack: candidate.soundtrack,
427
+ });
428
+ everPublished = true;
429
+ lastError = null;
430
+ // The attempted snapshot moves to what this publish made current: the
431
+ // receipt's own rows. A row the candidate baselined keeps that
432
+ // baseline's identity — isStale just proved the disk still holds it,
433
+ // so it is what the build provably consumed; a row the baseline never
434
+ // covered (an input created since the last receipt) is stat'd now,
435
+ // the same adoption the sweep's own inputs() makes. Directory keys
436
+ // already held stay held for the same reason the sweep keeps them (a
437
+ // listing that moved during the build must still look moved), and
438
+ // every row, held or new, is re-read whole when the next build
439
+ // starts. A prompt that arrives now finds the inputs equal to the
440
+ // published attempt and builds nothing.
441
+ const consumed = new Map(candidate.freshness);
442
+ const held = attempted ?? { files: new Map(), dirs: new Map() };
443
+ const rows = [...new Set(inputRows)];
444
+ attempted = {
445
+ files: new Map(await Promise.all(rows.map(async (row) => {
446
+ const path = inputPath(row);
447
+ return [path, consumed.has(path) ? consumed.get(path) : await statRow(path)];
448
+ }))),
449
+ dirs: new Map(await Promise.all([...new Set(rows.map(inputDirRow))].map(async (dirRow) => {
450
+ const dir = inputPath(dirRow);
451
+ return [dir, held.dirs.has(dir) ? held.dirs.get(dir) : await statDir(dir, watchFilter)];
452
+ }))),
453
+ };
454
+ if (traceEnabled) {
455
+ const digest = createHash('sha256')
456
+ .update([...attempted.files].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([path, identity]) => `${relative(real, path)}\0${identity}\n`).join(''))
457
+ .update([...attempted.dirs].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([dir, key]) => `d:${relative(real, dir)}\0${key}\n`).join(''))
458
+ .digest('hex')
459
+ .slice(0, 12);
460
+ trace(`publish: generation=${generation.id} snapshot=${digest}`);
461
+ }
462
+ out(`build ${generation.id}: ok — ${candidate.seconds}s, soundtrack ${candidate.soundtrack}`);
463
+ out(`generation ${generation.id}: ${store.pageUrl(generation.id)} (immutable while retained)`);
464
+ served.channel.broadcast('reload', {
465
+ generation: generation.id,
466
+ url: store.pageUrl(generation.id),
467
+ });
468
+ }
469
+
470
+ const loop = createBuildLoop({ build, discard, publish, onError: reportBuildError, isStale, shouldBuild: needsBuild, debounceMs });
471
+
472
+ /**
473
+ * The session's answer to "does a prompt owe a build?", asked by the
474
+ * loop whenever a scheduled build is about to start: one stat pass over
475
+ * the attempted snapshot's recorded rows and directory listings,
476
+ * compared by identity. Nothing attempted yet (before the first build
477
+ * starts) fails open and builds; after that, equal means skip — the
478
+ * last attempt already covers exactly what is on disk, whether it
479
+ * published or failed. An unanswerable stat is its own identity
480
+ * difference, the safe direction: the skip can never hide an edit it
481
+ * could not read.
482
+ */
483
+ async function needsBuild() {
484
+ if (attempted === null) return true;
485
+ const files = await Promise.all(
486
+ [...attempted.files].map(async ([path, was]) => was !== await statRow(path)),
487
+ );
488
+ const moved = files.some(Boolean)
489
+ || (await Promise.all([...attempted.dirs].map(async ([dir, was]) => was !== await statDir(dir, watchFilter)))).some(Boolean);
490
+ await tracePromptDecisions(pendingPrompts.splice(0), moved ? 'build' : 'skip');
491
+ return moved;
492
+ }
493
+
494
+ /**
495
+ * One trace line per prompt awaiting this decision: the prompted path
496
+ * (project-relative, or '(no path)'), its identity on disk now, the
497
+ * attempted identity for that path, and the verdict. The batch arrives
498
+ * already taken — both decision points (the cycle-entry skip and the
499
+ * discard decision's drop/keep) consume the list regardless of the
500
+ * flag, so a prompt is never judged twice. Records nothing unless
501
+ * ANIMADER_WATCH_TRACE is set.
502
+ */
503
+ async function tracePromptDecisions(batch, verdict) {
504
+ if (!traceEnabled) return;
505
+ for (const label of batch) {
506
+ await traceOnePrompt(label, verdict);
507
+ }
508
+ }
509
+
510
+ async function traceOnePrompt(label, verdict) {
511
+ if (label === '(no path)') {
512
+ trace('prompt (no path) → ' + verdict);
513
+ return;
514
+ }
515
+ const isDir = label.endsWith('/');
516
+ const path = inputPath(label.replace(/\/+$/, ''));
517
+ const identity = isDir ? await statDir(path, watchFilter) : await statRow(path);
518
+ const was = isDir ? attempted.dirs.get(path) : attempted.files.get(path);
519
+ trace(`prompt ${label} identity=${identity} attempted=${was ?? '(none)'} → ${verdict}`);
520
+ }
521
+
522
+ // Both prompt sources feed the SAME loop entry point, and neither
523
+ // decides anything: the watcher's filtered event is one prompt, and the
524
+ // sweep's own difference report is another. Every label is recorded
525
+ // (the discard decision's fail-safe judges them against the build's
526
+ // start snapshot) and consumed by whichever decision runs next — the
527
+ // cycle-entry skip or the discard decision. Whether a prompt means a
528
+ // change is answered here alone, from the filesystem, against the
529
+ // session's own snapshot — never from the event's metadata, a wall
530
+ // clock or a comparison against a baseline of events.
531
+ const promptWatcherChange = (source) => {
532
+ pendingPrompts.push(source === null ? '(no path)' : relative(real, source).split('\\').join('/'));
533
+ loop.notifyChange();
534
+ };
535
+ const promptSweepChange = (reasons) => {
536
+ pendingPrompts.push(...reasons);
537
+ loop.notifyChange();
538
+ };
539
+
540
+ // The watcher starts BEFORE the initial build: an edit landing during
541
+ // that first build is exactly the dirty-candidate case the loop discards
542
+ // and rebuilds, so no change window exists between watching and building.
543
+ // The stat sweep stands beside it from the same instant, over the same
544
+ // exclusion filter, feeding the same loop entry point: whatever the
545
+ // platform watcher does or does not report, an authored edit still gets
546
+ // its one build.
547
+ if (watch) {
548
+ sweep = createInputSweep({
549
+ folder: real,
550
+ filter: watchFilter,
551
+ intervalMs: sweepIntervalMs,
552
+ onChange: promptSweepChange,
553
+ });
554
+ try {
555
+ watcher = watchProject({
556
+ folder: real,
557
+ filter: watchFilter,
558
+ onChange: promptWatcherChange,
559
+ onError: (error) => {
560
+ for (const line of errorLines(error)) err(line);
561
+ // Watching stops loudly; serving, the last generation and the
562
+ // stat sweep stay, so edits still rebuild.
563
+ watcher?.close();
564
+ watcher = null;
565
+ },
566
+ watchImpl: hosts.watchImpl,
567
+ });
568
+ out(`watching: ${real} (recursive; out/, dist/ and build debris excluded)`);
569
+ } catch (cause) {
570
+ for (const line of errorLines(cause)) err(line);
571
+ out('serving without a platform watcher: the stat sweep still rebuilds on edits; fix the named problem and restart animader dev for immediate watcher events');
572
+ }
573
+ out(`sweeping: the last build's input set and its directories every ${sweepIntervalMs}ms (stat safety net beside the watcher)`);
574
+ } else {
575
+ out('watching disabled (--no-watch): this build is served without rebuilds');
576
+ }
577
+
578
+ // The initial build runs through the same loop as every later one. The
579
+ // session returns live: `settled` resolves once the first build (and any
580
+ // change-during-build follow-up) is done.
581
+ loop.kick();
582
+ const settled = loop.idle();
583
+ const opened = settled.then(async () => {
584
+ if (!open) return;
585
+ const launched = await launch(served.url);
586
+ if (launched.ok) {
587
+ out(`opened ${served.url} with ${launched.command}`);
588
+ } else {
589
+ err(`browser launch failed: ${launched.reason}`);
590
+ err(`open ${served.url} manually; the server is still running and no launch is claimed.`);
591
+ }
592
+ });
593
+ void opened.catch(() => {});
594
+
595
+ let stopped = false;
596
+ async function stop() {
597
+ if (stopped) return { published: everPublished, lastError };
598
+ stopped = true;
599
+ watcher?.close();
600
+ sweep?.close();
601
+ await loop.close();
602
+ // The loop's close() is final but deliberately does not wait for an
603
+ // in-flight build (it must not block on one), and that cycle still
604
+ // writes into the session root — including one further build a dirty
605
+ // cycle had queued inside itself. stop() is where the writers can be
606
+ // ordered against the removal, so it waits for the last cycle to
607
+ // settle before anything deletes the trees: `running` goes false only
608
+ // after the cycle's final discard or publish step, and once it is
609
+ // false no step can start again (close() saw to that).
610
+ while (loop.running) {
611
+ await new Promise((resolve) => setTimeout(resolve, 5));
612
+ }
613
+ await served.close();
614
+ await store.dispose();
615
+ return { published: everPublished, lastError };
616
+ }
617
+
618
+ return { served, store, stop, settled };
619
+ }