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.
- package/LICENSE +121 -0
- package/adapters/build-node.mjs +265 -0
- package/adapters/devserver.mjs +481 -0
- package/adapters/devsession.mjs +619 -0
- package/adapters/export-node.mjs +603 -0
- package/adapters/export-run.mjs +126 -0
- package/adapters/generations.mjs +183 -0
- package/adapters/http.mjs +294 -0
- package/adapters/launch.mjs +83 -0
- package/adapters/node-fs.mjs +80 -0
- package/adapters/pngine.mjs +87 -0
- package/adapters/snapshot.mjs +143 -0
- package/adapters/target.mjs +104 -0
- package/adapters/watch.mjs +355 -0
- package/adapters/webaudio.mjs +102 -0
- package/bin/animader.mjs +31 -0
- package/hello/join.js +46 -0
- package/hello/run.js +259 -0
- package/lib/build.mjs +126 -0
- package/lib/buildloop.mjs +254 -0
- package/lib/contract.mjs +371 -0
- package/lib/errors.mjs +51 -0
- package/lib/export.mjs +445 -0
- package/lib/new.mjs +304 -0
- package/lib/pipeline.mjs +247 -0
- package/lib/receipt.mjs +331 -0
- package/lib/rmtree.mjs +49 -0
- package/lib/routes.mjs +170 -0
- package/lib/score.mjs +434 -0
- package/lib/show.mjs +458 -0
- package/lib/soundtrack.mjs +67 -0
- package/lib/vendor.mjs +99 -0
- package/lib/wav.mjs +197 -0
- package/package.json +47 -4
- package/player/boot.mjs +792 -0
- package/player/controller.mjs +366 -0
- package/player/devchannel.mjs +306 -0
- package/player/index.html +35 -0
- package/player/page.mjs +134 -0
- package/player/player.css +393 -0
- package/player/release.mjs +76 -0
- package/src/commands/build.mjs +201 -0
- package/src/commands/dev.mjs +214 -0
- package/src/commands/doctor.mjs +348 -0
- package/src/commands/export.mjs +158 -0
- package/src/commands/new.mjs +164 -0
- package/src/commands/project.mjs +46 -0
- package/src/config.mjs +110 -0
- package/src/router.mjs +149 -0
- package/src/toolchain.mjs +691 -0
- package/starters/circle/README.md +50 -0
- package/starters/circle/animader.json +4 -0
- package/starters/circle/gitignore +9 -0
- package/starters/circle/kernels/circle.wgsl +32 -0
- package/starters/circle/program.sjon +34 -0
- package/starters/circle/timeline.piece.sjon +26 -0
- package/starters/ember/bake/spans.bin +0 -0
- package/starters/ember/kernels/curl.wgsl +10 -0
- package/starters/ember/kernels/ember.wgsl +10 -0
- package/starters/ember/kernels/hash.wgsl +12 -0
- package/starters/ember/kernels/simplex.wgsl +27 -0
- package/starters/ember/program.sjon +328 -0
- package/starters/ember/timeline.piece.sjon +34 -0
- package/toolchain/licenses/animader.txt +121 -0
- package/toolchain/licenses/esbuild.txt +21 -0
- package/toolchain/licenses/pacer.txt +121 -0
- package/toolchain/licenses/pngine.txt +121 -0
- package/toolchain/manifest.json +212 -0
- package/toolchain/prepare.mjs +446 -0
- package/toolchain/uniform-type-name.js +184 -0
- 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
|
+
}
|