demobites 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/launcher/index.mjs +72 -7
- package/package.json +1 -1
- package/scripts/calibrate.mjs +17 -3
- package/scripts/record.mjs +144 -15
- package/skill/SKILL.md +94 -5
- package/skill/scripts/batch.mjs +328 -0
- package/skill/scripts/briefs.mjs +163 -9
- package/skill/scripts/cleanup.mjs +2 -1
- package/skill/scripts/manifest.mjs +13 -1
- package/skill/scripts/retake.mjs +72 -14
- package/skill/scripts/stage-wait.mjs +119 -0
- package/skill/scripts/status.mjs +12 -2
- package/skill/scripts/upload.mjs +11 -3
- package/skill/scripts/vocab.mjs +2 -1
package/skill/SKILL.md
CHANGED
|
@@ -164,6 +164,19 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
|
|
|
164
164
|
|
|
165
165
|
**LAW: page transitions are cut and faded, never watched.** When the story moves to another page, the viewer sees page one, a short fade, page two — never the loading blank. record.mjs stamps every mid-take `goto` and manifest.mjs cuts that window out with a fade (`cuts` in the wire manifest); the ingestion lays it on the bite as a timeline cut. No zoom and no narration live inside a cut (the studio forbids both), so put the line about the new page on the beat AFTER it has landed, and say goodbye to the old page BEFORE the goto.
|
|
166
166
|
|
|
167
|
+
### FRAMING: the camera obeys the script
|
|
168
|
+
|
|
169
|
+
Framing is our job, never the customer's (founder, 2026-09-27). Decide it from the narration you wrote. The customer never hears about zooms, framing or the cursor.
|
|
170
|
+
|
|
171
|
+
- Every step gets `frame`: `"close"` or `"wide"`.
|
|
172
|
+
- A field, a button, a menu item, a toggle, a badge, a row = `"close"`.
|
|
173
|
+
- Landing on a page, a report, a chart, a table, a dashboard, a list of results = `"wide"`. So is every line that says "here is", "you see", "you land on", "the whole".
|
|
174
|
+
- A `type` step with `enter` is close for the typing. What Enter reveals is wide by itself; the recorder sees the new page. Do not add a beat for it.
|
|
175
|
+
- A bare `settle` right after a navigation is wide by itself.
|
|
176
|
+
- Two consecutive wides on the same page are one shot. Write both; the server joins them.
|
|
177
|
+
- Never more than 4 seconds of close on a static screen. When the line runs longer, the beat is wide.
|
|
178
|
+
- No `frame` = the recorder's own choice, close on the subject. `reveals: false` keeps the camera where it is after a click or an Enter.
|
|
179
|
+
|
|
167
180
|
Storyboard schema (`rulesVersion` and per-step `rules` come from Phase 1b):
|
|
168
181
|
|
|
169
182
|
```json
|
|
@@ -183,7 +196,7 @@ Storyboard schema (`rulesVersion` and per-step `rules` come from Phase 1b):
|
|
|
183
196
|
}
|
|
184
197
|
```
|
|
185
198
|
|
|
186
|
-
Step fields: `action` is one of `goto | settle | scroll | click | hover | type | expect`. `rules` (optional, any step) lists the numbers of the workspace rules that shaped the beat; the storyboard's top-level `rulesVersion` names the rule set (Phase 1b). **Durations (`dwell`, `after`, settle `ms`, scroll `ms`) are milliseconds; a value under 60 is read as seconds** (write `"dwell": 3400` or `"dwell": 3.4`, never `"dwell": 3` meaning 3 ms). `goto` needs `url`. `settle` takes `ms` and an optional `focus` selector. `scroll` needs `dy` and takes `ms`. `click`/`hover` need `selector` and take `minY` (minimum Y for the visible instance pick), `dwell`, `after`, `waitLoad`. Every step takes `label` and `
|
|
199
|
+
Step fields: `action` is one of `goto | settle | scroll | click | hover | type | expect`. `rules` (optional, any step) lists the numbers of the workspace rules that shaped the beat; the storyboard's top-level `rulesVersion` names the rule set (Phase 1b). **Durations (`dwell`, `after`, settle `ms`, scroll `ms`) are milliseconds; a value under 60 is read as seconds** (write `"dwell": 3400` or `"dwell": 3.4`, never `"dwell": 3` meaning 3 ms). `goto` needs `url`. `settle` takes `ms` and an optional `focus` selector. `scroll` needs `dy` and takes `ms`. `click`/`hover` need `selector` and take `minY` (minimum Y for the visible instance pick), `dwell`, `after`, `waitLoad`. `type` needs `selector` and `text` and takes `enter` (press Enter after the text), `clear`, `after`, `reveals`. Every step takes `label`, `narration` and `frame` (`"close"` or `"wide"`, the FRAMING law above; any other value stops the take).
|
|
187
200
|
|
|
188
201
|
Beyond `steps`, a storyboard may carry the off-camera blocks (Phase 3a/law above); `cleanup.mjs` runs them on the same profile without video:
|
|
189
202
|
|
|
@@ -220,7 +233,7 @@ Two fields carry the whole advantage of this lane, so fill them in:
|
|
|
220
233
|
|
|
221
234
|
Use `hideCss` for chat widgets and cookie banners that would pollute the picture. The first `goto` opens the video, so the first narration goes on the settle right after it.
|
|
222
235
|
|
|
223
|
-
**Show the storyboard inline and get approval before filming.** Present it as a numbered shot list, not raw JSON. Say the target length out loud so the human can push back on pacing before you burn a take. Iterate until they say go.
|
|
236
|
+
**Show the storyboard inline and get approval before filming.** Present it as a numbered shot list, not raw JSON. The list is the story: what the viewer sees and hears, beat by beat. Never zooms, framing, selectors or the cursor. Say the target length out loud so the human can push back on pacing before you burn a take. Iterate until they say go.
|
|
224
237
|
|
|
225
238
|
The presentation has three blocks, always: the shot list (irreversible beats marked "pointed at, not pressed"), **"Before the camera"** (what `prep[]` creates) and **"After the cut"** (the `cleanup_plan[]` sentences). A storyboard whose flow needs data and has no prep, or creates anything and has no cleanup plan, is not ready to show.
|
|
226
239
|
|
|
@@ -291,6 +304,7 @@ Filming laws baked into `record.mjs`, do not reimplement or weaken them:
|
|
|
291
304
|
- Mouse coordinate clicks: the real mouse tracks the drawn cursor, hover states fire naturally.
|
|
292
305
|
- **The camera follows the subject, measured off the live page.** Every hover records the hovered element's rectangle. Every click records TWO shots: the control on approach, and then whatever the click opened. A click that opens a menu or a dialog moves the subject somewhere else on screen, so a camera left on the button shows a dimmed backdrop while the thing you just opened sits off frame.
|
|
293
306
|
- **Shots overlap on purpose.** The manifest's camera path is chained by the backend so the runtime travels from one subject to the next at zoom. Never "fix" this into a non overlapping sequence, that is the pull out to 1.0 between every shot.
|
|
307
|
+
- **A landing is wide.** A `type` with `enter` ends its close shot the moment before Enter; what Enter revealed (a new URL, a page that changed) is a full-frame `wide: true` shot, and so is a bare settle after a navigation and any step framed `wide`. The server ends the previous zoom at the wide's start and never holds a close shot over it (founder, 2026-09-27).
|
|
294
308
|
|
|
295
309
|
If the take fails mid flow, the partial video and manifest are still saved. Diagnose, fix the storyboard, film again.
|
|
296
310
|
|
|
@@ -336,7 +350,7 @@ node scripts/retake.mjs <biteId> [--note "what changed"] # or: npx demobite re
|
|
|
336
350
|
Laws for a re-take:
|
|
337
351
|
- **Read the note first.** "We moved Export to the header" tells you which step will break before you film.
|
|
338
352
|
- **A step that no longer resolves stops the take at that step.** Look at the live page. If the control moved,
|
|
339
|
-
fix the selector in the take's `storyboard.json` and run again with `--take <dir
|
|
353
|
+
fix the selector in the take's `storyboard.json` and run again with `--take <dir>` (your edited storyboard is kept; `--fresh` replaces it with the server's recipe). If the feature is truly
|
|
340
354
|
gone, DROP that beat AND its narration line, and tell the human plainly: "This capability no longer exists,
|
|
341
355
|
we removed it from the video." Nothing stages until every step resolves.
|
|
342
356
|
- **The narration in the recipe is the ORIGINAL intent.** Do not rewrite it to taste: the server replaces it
|
|
@@ -352,6 +366,7 @@ The human pastes a bundle of approved briefs into the chat: a header (batchId, w
|
|
|
352
366
|
|
|
353
367
|
```bash
|
|
354
368
|
node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approved briefs; warns when the paste drifted
|
|
369
|
+
node scripts/briefs.mjs list --slug <slug> # Update Radar: the batch behind a record code, filmed in four phases (see the section below)
|
|
355
370
|
node scripts/briefs.mjs claim <batchId> <briefId> # mints an attempt, creates take-<briefId>-r<revision>/brief.json
|
|
356
371
|
node scripts/briefs.mjs event <takeDir> planning|awaiting_storyboard_approval|recording|uploading|failed|cancelled [--note "..."]
|
|
357
372
|
node scripts/briefs.mjs release <takeDir> # give the brief back (cancelled)
|
|
@@ -372,6 +387,70 @@ The procedure, in order:
|
|
|
372
387
|
|
|
373
388
|
Resume after an interruption from what is on disk and on the server: a `take-*` directory with `brief.json` is claimed; with `raw.webm` it was filmed; with `clean.mp4` and `manifest.demobites.json` it is ready to stage; with `staged.json` it is delivered, waiting for minutes, or staged (check it with `status.mjs --no-wait`). `list` shows the server's view of every attempt. Never re-claim a brief that already has your own live attempt; never re-stage one that `staged.json` says is delivered, waiting or staged unless the human asked for a new take (`upload.mjs --supersede`).
|
|
374
389
|
|
|
390
|
+
## Record a batch by slug (Update Radar)
|
|
391
|
+
|
|
392
|
+
An Update Radar workflow (a scan of merged pull requests → topics → briefs) ends its Briefs stage with a short record code and the command `npx demobite record <slug>` on its page ("Go to your terminal or your coding agent where you installed it and run this command"). The human either ran the command themselves and pasted its output to you, or asked you to run it. Either way the batch is a batch of briefs as above, reached by its code instead of its id. Phase 0 (the auth gate, with the human's word) and Phase 1b (the workspace rules) come first, as for every run. Plan limits are not your concern. Delivery is one door.
|
|
393
|
+
|
|
394
|
+
**We will be judged by the outcome: the fewest edits before Export and Go live** (founder ruling, 2026-09-26). The cloud draft is a starting point, never final. The run has four steps (List → Refactor + questions → One approval → Film in the background), in this order, and no other question is asked once filming starts. The Phase numbers named inside them are the skill's phases above.
|
|
395
|
+
|
|
396
|
+
```bash
|
|
397
|
+
npx demobite record <slug> # what the human runs: lists the batch, writes .recorder/radar/<slug>/
|
|
398
|
+
node scripts/briefs.mjs list --slug <slug> # the same call from the skill: GET <base>/api/recorder/briefs?slug=<slug>
|
|
399
|
+
node scripts/briefs.mjs refine <slug> <briefId> [--note "..."] # phase 2: post the brief you refined (.recorder/radar/<slug>/refined/<briefId>.json)
|
|
400
|
+
node scripts/batch.mjs plan <slug> # phase 3: the pool for this machine and which briefs have a storyboard
|
|
401
|
+
node scripts/batch.mjs run <slug> [--concurrency N] # phase 4: film every approved storyboard in the background, deliver each as it lands
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The answer to `list --slug` is the batch payload plus `radar: { slug, name, workflowUrl }`. The command prints the workflow's name, the batch id, the briefs in their order (position, title, estimated seconds), every open question the drafts carry, then writes `.recorder/radar/<slug>/bundle.json` and one draft per brief in `.recorder/radar/<slug>/briefs/<briefId>.json`. Its answers when something is off, and what you do:
|
|
405
|
+
|
|
406
|
+
- "Not connected to DemoBites" → Phase 0, with the human's word; never open the browser by yourself.
|
|
407
|
+
- "No batch with that code" → the code is wrong; ask the human to check the command on the Update Radar page.
|
|
408
|
+
- "The briefs are not approved yet" → the human approves them on the Update Radar page; wait for their word, then list again.
|
|
409
|
+
- "The recorder key was refused" → the key is stale or belongs to another workspace; Phase 0 again, with their word.
|
|
410
|
+
|
|
411
|
+
### 1. List
|
|
412
|
+
|
|
413
|
+
`list --slug <slug>` (or read the bundle the human's run wrote). Work from the server's briefs. The batch id on the first lines is the `<batchId>` every other command takes. Run `rules.mjs` now (Phase 1b). Nothing is claimed yet, nothing films.
|
|
414
|
+
|
|
415
|
+
### 2. Refactor every brief, then ask every question once
|
|
416
|
+
|
|
417
|
+
**The refactor pass, before any filming.** Walk EVERY brief in the batch against the repository (Phase 3b: routes, navigation, controls, the handlers behind each action, the gates) and the running app (Phase 3a: `vocab.mjs` over every screen the brief names). For each brief:
|
|
418
|
+
|
|
419
|
+
- Confirm the flow exists at the target address. A flow that is not there is said so, not filmed.
|
|
420
|
+
- Replace guessed screens and labels with the real ones, the app's current words (vocab.json is the dictionary).
|
|
421
|
+
- Drop the steps that are not there. Add nothing the brief did not ask for.
|
|
422
|
+
- Tighten `flowIntent` and the narration intent to what is on screen; the outcome stays the last beat; the exclusions stay things the camera never shows; the take stays under `maxSeconds` (90).
|
|
423
|
+
|
|
424
|
+
Write the refined brief to `.recorder/radar/<slug>/refined/<briefId>.json` (copy the draft from `briefs/<briefId>.json` and edit it; same fields — the command keeps only the seven content fields, so the draft's other keys may stay) and post it back: `node scripts/briefs.mjs refine <slug> <briefId> --note "<what you changed and why>"`. The server mints a new revision and the Radar page shows that brief as "Refined on your machine", so the human sees what will be filmed before it is filmed. The command rewrites the bundle with the new revisions; the claim in step 4 takes them. The server's limits, checked before the post and named on failure: title ≤ 80 characters, audience ≤ 60, outcome ≤ 200, flowIntent 2–8 lines of ≤ 120, prerequisites and exclusions up to 6 lines of ≤ 160, estimatedDurationSec a whole number 15–90. Write within them; a refine over a limit is refused, never truncated. A brief that needs no change is not posted. An older DemoBites without the refine route answers so; then film from your refined file and say that the page still shows the cloud draft.
|
|
425
|
+
|
|
426
|
+
**The brief stays a story** (founder ruling, 2026-09-27). What the human approves is narrative only: "this is the story I'd like to tell", the beats in plain words, the outcome. Never a word about zooms, framing, the cursor, verification, selectors or how you direct. Not in the refined brief, not in the refine note, not in the approval text. Directing is your job and it stays behind the scenes.
|
|
427
|
+
|
|
428
|
+
**Double-checked behind the scenes.** Before anything is shown, check every brief against the running app and the repository: go there, open the screens, click the path, see the report land. Correct the storyboard silently. Only a change at the level of the story reaches the refine note, in one line ("the app has no Export here; the story ends on Schedule").
|
|
429
|
+
|
|
430
|
+
**Questions once, for the whole batch.** Only what the app and the code cannot answer. While you walk the briefs, collect every open question: the drafts' `questions[]` (printed by `list`), the account or login the flows need, test data that must exist, feature flags, the URL and environment to film on, and anything else you cannot decide from the code and the app. Ask them in ONE message, numbered, brief by brief, and wait for the answers. Never a question mid-filming: a take that would need one is not ready to film, and it is said so in this message. When there is nothing to ask, say that in one line and go on.
|
|
431
|
+
|
|
432
|
+
Then write every storyboard (Phase 3, the refined brief as the spec, the workspace rules below the laws, vocab.json as the only dictionary) into `.recorder/radar/<slug>/storyboards/<briefId>.json`, and run the headless dry run (Phase 4) for each of them on your own, before the human sees anything. A brief you could not refine into a filmable storyboard is reported with the reason and left without a storyboard; `batch.mjs` skips it and says so.
|
|
433
|
+
|
|
434
|
+
### 3. One approval for the batch
|
|
435
|
+
|
|
436
|
+
Show the refined storyboards for ALL briefs together, each as a numbered shot list with its three blocks (the shot list with "pointed at, not pressed" beats, "Before the camera", "After the cut"), its estimated length, the rules applied, and what changed against the cloud draft in one line per brief (the story, never the directing). Then ask for one yes for the batch: "Film these 3?". **One word for the batch replaces one approval per brief** (founder ruling, 2026-09-26). The human may strike a brief from the batch in their answer ("film 1 and 3"); that brief gets no storyboard in `storyboards/` (or `--only <briefId,...>` on the run). A no on the batch means back to step 2, not filming a subset. `node scripts/batch.mjs plan <slug>` shows what the run will do: the pool size for this machine and which briefs have a storyboard.
|
|
437
|
+
|
|
438
|
+
### 4. Film in the background, in parallel
|
|
439
|
+
|
|
440
|
+
On the yes: `node scripts/batch.mjs run <slug>` (run it in the background so you keep answering). It launches the takes together in a small pool sized to the machine and delivers each one as it finishes:
|
|
441
|
+
|
|
442
|
+
- **The pool rule:** 2 takes at a time on a machine with 8 CPU cores or fewer, or 16 GB of memory or less; 3 above that; `--concurrency N` overrides; never more than 4. The rest queue.
|
|
443
|
+
- **One browser profile per take.** Each take films on its own profile directory seeded from `.recorder/profile` (the signed-in session rides along, the profile lock does not), passed to `record.mjs` and `cleanup.mjs` as `RECORDER_PROFILE`, and removed after the take. Never share one Chrome profile between two takes.
|
|
444
|
+
- **Per take, in order, as child processes of the same scripts a single take uses:** `briefs.mjs claim` → `event recording` → `cleanup.mjs --prep` (when declared) → `record.mjs` → `cleanup.mjs` (revert, checks.after, when declared) → `trim.mjs` → `calibrate.mjs` → `manifest.mjs` → `upload.mjs --stage-only --no-open`, the one delivery door: the take becomes a bite by itself, or is kept waiting for recording minutes.
|
|
445
|
+
- **A failed take never stops the others.** The attempt gets `event failed --note "<step>: <why>"`, the take directory stays for diagnosis, the pool goes on. Logs live in `.recorder/radar/<slug>/takes/<briefId>/<step>.log` (and `take.log`, every step in order); `takes/summary.json` is the batch's outcome.
|
|
446
|
+
- **Progress lines.** The script prints one line per event (claimed, filming, filmed, cleaned, delivered with the bite id, kept, failed with the step and the log). Relay them to the human as they land, in the same words. At the end it prints the summary per brief, the Demos grid, and the Radar `workflowUrl`.
|
|
447
|
+
|
|
448
|
+
When the run ends, report per brief what happened, name any take kept waiting for recording minutes (it waits on the Demos grid), name any failed take with its step and what you will change before filming it again (a failed take is filmed again alone with `--only <briefId>`, after the fix, with the human's word), and end with the Update Radar link, `radar.workflowUrl` from the bundle: the workflow page shows each take arriving and the demos it becomes. The human publishes from there; you never publish. `status.mjs --all` shows where each delivered take stands; `status.mjs <takeDir>` waits for one to finish and prints what landed (the Phase 6 receipt law holds: no studio link before the bite is completed).
|
|
449
|
+
|
|
450
|
+
**One demo, not a batch.** Someone who asks for a single demo from a Radar brief, or a single re-film, gets the single-take path: `claim`, storyboard, the yes on it, `record.mjs` on the profile, cleanup, trim, calibrate, manifest, `upload.mjs`. `batch.mjs` is for the batch.
|
|
451
|
+
|
|
452
|
+
Resume like a batch: what is on disk (`take-*` directories, `takes/<briefId>/result.json`) and what `list --slug` reports is the truth, never a second claim on your own live attempt. `batch.mjs run` again films only the briefs whose storyboards are present; pass `--only` for the ones to film again.
|
|
453
|
+
|
|
375
454
|
## The wire manifest (fixed contract, version 2)
|
|
376
455
|
|
|
377
456
|
`manifest.mjs` produces exactly this shape. All times are relative to the UPLOADED file (record_from already subtracted, clamped at 0). `duration` is the duration of the uploaded clean.mp4.
|
|
@@ -390,11 +469,13 @@ Resume after an interruption from what is on disk and on the server: a `take-*`
|
|
|
390
469
|
click?: { x, y, t }, // frame px + seconds
|
|
391
470
|
narration?: { text, t, estimated_duration }
|
|
392
471
|
}],
|
|
393
|
-
camera: [{ t_start, t_end, x, y, w, h, label }], // focus rectangles, frame px
|
|
472
|
+
camera: [{ t_start, t_end, x, y, w, h, label, n?, revealed?, wide?, glide? }], // focus rectangles, frame px
|
|
394
473
|
cuts?: [{ t_start, t_end, transition: 'fade'|'abrupt', n }] // navigation loads, cut out of the bite
|
|
395
474
|
}
|
|
396
475
|
```
|
|
397
476
|
|
|
477
|
+
`wide: true` on a camera shot is the camera at 1.0 for the span: the server ends the previous zoom at its start, joins consecutive wides, and writes no zoom for it.
|
|
478
|
+
|
|
398
479
|
`estimated_duration` is only ever an estimate and nothing downstream treats it as final.
|
|
399
480
|
|
|
400
481
|
### What the two v2 fields buy
|
|
@@ -419,6 +500,14 @@ DELETE <base>/api/recorder/key (Authorization: Bearer <api_key>)
|
|
|
419
500
|
GET <base>/api/recorder/rules (Authorization: Bearer <api_key>) // WORKSPACE RULES (1.4): never cached
|
|
420
501
|
-> { workspaceId, rules: string | null, version, updatedAt } (the claim's api.rules is the same url; workspaceRules on the claim is the snapshot)
|
|
421
502
|
|
|
503
|
+
GET <base>/api/recorder/briefs?batch=<batchId> (Authorization: Bearer <api_key>) // BATCH OF BRIEFS: the approved briefs, their attempts, the delivery door
|
|
504
|
+
GET <base>/api/recorder/briefs?slug=<slug> (Authorization: Bearer <api_key>) // UPDATE RADAR (1.6): same payload + radar { slug, name, workflowUrl }
|
|
505
|
+
-> { batch, api, workspaceRules, briefs, radar? } (404 unknown_slug; 409 not_approved; 403 workspace_mismatch)
|
|
506
|
+
|
|
507
|
+
PUT <base>/api/recorder/briefs/<briefId>/refine (Authorization: Bearer <api_key>) // REFACTOR PASS (1.7): the brief refined on this machine
|
|
508
|
+
{ revision, contentHash, content, note? } // content = the brief's fields (title, audience, outcome, flowIntent, exclusions, ...)
|
|
509
|
+
-> { revision, contentHash } // the new revision; the Radar page shows "Refined on your machine" (409 hash_mismatch; 410 superseded)
|
|
510
|
+
|
|
422
511
|
GET <base>/api/recorder/recipe?biteId=<id> (Authorization: Bearer <api_key>) // RE-TAKE: the bite's recipe
|
|
423
512
|
-> { storyboard, config:{app,url,frame}, manifest, engine } (404 no recipe; 402/403 plan gate)
|
|
424
513
|
|
|
@@ -450,5 +539,5 @@ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`.
|
|
|
450
539
|
|
|
451
540
|
- Anything the human sees (storyboard presentation, questions, reports) uses commas and periods only, no dashes, and real action words. Never orphan a single word on its own line in a heading.
|
|
452
541
|
- Never touch credentials. Never print the api_key. Config and key files are chmod 600.
|
|
453
|
-
- Never film without the human's explicit word on the storyboard. That yes, in the chat, is the word for the take: delivery ingests by itself, and the human watches the take on the Demos grid, never on a preview page. For a batch of briefs the word is given on
|
|
542
|
+
- Never film without the human's explicit word on the storyboard. That yes, in the chat, is the word for the take: delivery ingests by itself, and the human watches the take on the Demos grid, never on a preview page. For a pasted batch of briefs the word is given on each storyboard; for an Update Radar batch reached by its record code the word is ONE yes on the refined storyboards shown together (founder ruling 2026-09-26), after every question was asked once. Never publish, never share, never send invitations.
|
|
454
543
|
- One take directory per take, keep failed takes for diagnosis, name them `take-<slug>`, `take-<slug>2`, and so on. A take claimed from a brief is `take-<briefId>-r<revision>`.
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// FILM IN THE BACKGROUND, IN PARALLEL (1.7.0, founder ruling 2026-09-26):
|
|
3
|
+
// after the ONE approval for the batch, the takes of an Update Radar batch
|
|
4
|
+
// are filmed together in a small pool, each in its OWN browser profile
|
|
5
|
+
// directory, and each is delivered as it finishes. A failed take never stops
|
|
6
|
+
// the others. "We will be judged by the outcome: the fewest edits before
|
|
7
|
+
// Export and Go live."
|
|
8
|
+
//
|
|
9
|
+
// node batch.mjs plan <slug> the pool size for this machine and which briefs have a storyboard (films nothing)
|
|
10
|
+
// node batch.mjs run <slug> [--concurrency N] film every brief with a storyboard, N at a time (1..4), deliver each as it lands
|
|
11
|
+
// [--only <briefId,...>] [--keep-profiles]
|
|
12
|
+
//
|
|
13
|
+
// Inputs, all under .recorder/radar/<slug>/ (written by `briefs.mjs list --slug`
|
|
14
|
+
// and by the agent's refactor pass):
|
|
15
|
+
// bundle.json the batch (refreshed from the server first; the disk copy is the fallback)
|
|
16
|
+
// storyboards/<briefId>.json one approved storyboard per brief (Phase 3 schema); a brief without one is skipped, and said
|
|
17
|
+
// Outputs:
|
|
18
|
+
// takes/<briefId>/<step>.log one log per step (claim, prep, record, cleanup, trim, calibrate, manifest, upload)
|
|
19
|
+
// takes/<briefId>/take.log every step in order
|
|
20
|
+
// takes/<briefId>/result.json what happened to the take
|
|
21
|
+
// takes/summary.json the batch's outcome
|
|
22
|
+
// take-<briefId>-r<revision>/ the take itself, in the project directory as for every claimed brief
|
|
23
|
+
//
|
|
24
|
+
// Per take, in order, as child processes of the existing scripts (nothing is
|
|
25
|
+
// reimplemented here): briefs claim → event recording → cleanup --prep (when
|
|
26
|
+
// declared) → record → cleanup (when declared) → trim → calibrate → manifest →
|
|
27
|
+
// upload --stage-only --no-open (the delivery door). On a failure: event
|
|
28
|
+
// failed --note, keep the take directory, go on with the next brief.
|
|
29
|
+
//
|
|
30
|
+
// THE POOL RULE: 2 concurrent takes on a machine with 8 CPU cores or fewer,
|
|
31
|
+
// or 16 GB of memory or less; 3 above that; --concurrency overrides; never
|
|
32
|
+
// more than 4. Each take films on its own profile directory seeded from
|
|
33
|
+
// .recorder/profile (the signed-in session rides along, the profile lock
|
|
34
|
+
// does not), passed to the children as RECORDER_PROFILE.
|
|
35
|
+
//
|
|
36
|
+
// Never prints the api_key (the children never do either).
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import os from "node:os";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
|
|
43
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
const [, , cmd, slugArg, ...rest] = process.argv;
|
|
45
|
+
const flag = (name) => rest.includes(name);
|
|
46
|
+
const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? String(rest[i + 1] ?? "") : null; };
|
|
47
|
+
|
|
48
|
+
function usage(code = 2) {
|
|
49
|
+
console.error(`Usage:
|
|
50
|
+
node batch.mjs plan <slug>
|
|
51
|
+
node batch.mjs run <slug> [--concurrency N] [--only <briefId,...>] [--keep-profiles]`);
|
|
52
|
+
process.exit(code);
|
|
53
|
+
}
|
|
54
|
+
if (!cmd || !["plan", "run"].includes(cmd)) usage();
|
|
55
|
+
const slug = String(slugArg ?? "").trim().toLowerCase();
|
|
56
|
+
if (!/^[a-z0-9-]{1,32}$/.test(slug)) { console.error("The record code is the short code on the Update Radar page, like k3fx9q."); process.exit(2); }
|
|
57
|
+
|
|
58
|
+
// ── where the scripts are ──────────────────────────────────────────────────
|
|
59
|
+
// Installed, every script sits beside this file. In the package repository
|
|
60
|
+
// record/trim/calibrate live in ../../scripts; both layouts resolve.
|
|
61
|
+
function scriptPath(name) {
|
|
62
|
+
for (const dir of [here, path.resolve(here, "..", "..", "scripts")]) {
|
|
63
|
+
const p = path.join(dir, name);
|
|
64
|
+
if (fs.existsSync(p)) return p;
|
|
65
|
+
}
|
|
66
|
+
console.error(`${name} not found beside ${here}. Run \`npx demobite\` to refresh the skill.`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const SCRIPTS = {
|
|
70
|
+
briefs: scriptPath("briefs.mjs"), cleanup: scriptPath("cleanup.mjs"), record: scriptPath("record.mjs"),
|
|
71
|
+
trim: scriptPath("trim.mjs"), calibrate: scriptPath("calibrate.mjs"), manifest: scriptPath("manifest.mjs"), upload: scriptPath("upload.mjs"),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── the pool rule ──────────────────────────────────────────────────────────
|
|
75
|
+
const MAX_CONCURRENCY = 4;
|
|
76
|
+
function poolSize({ cpus = os.cpus().length, memGiB = os.totalmem() / 2 ** 30, override = null } = {}) {
|
|
77
|
+
if (override !== null && override !== undefined && override !== "") {
|
|
78
|
+
const n = Number(override);
|
|
79
|
+
if (!Number.isInteger(n) || n < 1) { console.error(`--concurrency must be a whole number from 1 to ${MAX_CONCURRENCY}.`); process.exit(2); }
|
|
80
|
+
return { size: Math.min(n, MAX_CONCURRENCY), why: n > MAX_CONCURRENCY ? `--concurrency ${n}, capped at ${MAX_CONCURRENCY}` : `--concurrency ${n}` };
|
|
81
|
+
}
|
|
82
|
+
const small = cpus <= 8 || memGiB <= 16;
|
|
83
|
+
return { size: small ? 2 : 3, why: `${cpus} CPU cores, ${Math.round(memGiB)} GB memory` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── the batch on disk and on the server ────────────────────────────────────
|
|
87
|
+
const cfgPath = path.resolve(".recorder", "config.json");
|
|
88
|
+
let cfg = {};
|
|
89
|
+
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
90
|
+
if (!cfg.api_key || !cfg.base) { console.error(`Not connected to DemoBites. Run: ${process.env.DEMOBITE_CLI ? "npx demobite login" : "node scripts/login.mjs"}`); process.exit(1); }
|
|
91
|
+
const base = cfg.base.replace(/\/+$/, "");
|
|
92
|
+
|
|
93
|
+
const radarDir = path.resolve(".recorder", "radar", slug);
|
|
94
|
+
const bundlePath = path.join(radarDir, "bundle.json");
|
|
95
|
+
const storyboardsDir = path.join(radarDir, "storyboards");
|
|
96
|
+
const takesDir = path.join(radarDir, "takes");
|
|
97
|
+
const sharedProfile = path.resolve(".recorder", "profile");
|
|
98
|
+
|
|
99
|
+
const stamp = () => new Date().toTimeString().slice(0, 8);
|
|
100
|
+
const say = (line) => console.log(`${stamp()} ${line}`);
|
|
101
|
+
|
|
102
|
+
function loadBundle() {
|
|
103
|
+
// The server's view first (revisions move after a refine); the disk copy when it cannot be reached.
|
|
104
|
+
fs.mkdirSync(takesDir, { recursive: true });
|
|
105
|
+
const r = spawnSync("node", [SCRIPTS.briefs, "list", "--slug", slug], { cwd: process.cwd(), env: process.env, encoding: "utf8" });
|
|
106
|
+
fs.writeFileSync(path.join(takesDir, "list.log"), `${r.stdout ?? ""}${r.stderr ?? ""}`);
|
|
107
|
+
if (r.status !== 0) {
|
|
108
|
+
const first = (r.stderr || r.stdout || "").trim().split("\n")[0];
|
|
109
|
+
if (!fs.existsSync(bundlePath)) { console.error(first || "Could not read the batch."); process.exit(1); }
|
|
110
|
+
console.error(`Could not refresh the batch from DemoBites (${first}); using the bundle on disk.`);
|
|
111
|
+
}
|
|
112
|
+
let bundle;
|
|
113
|
+
try { bundle = JSON.parse(fs.readFileSync(bundlePath, "utf8")); } catch (e) { console.error(`${bundlePath} unreadable: ${e.message}. Run: node scripts/briefs.mjs list --slug ${slug}`); process.exit(1); }
|
|
114
|
+
if (!bundle.batch?.id || !Array.isArray(bundle.briefs)) { console.error(`${bundlePath} has no batch. Run: node scripts/briefs.mjs list --slug ${slug}`); process.exit(1); }
|
|
115
|
+
return bundle;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const safeId = (id) => String(id).replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
119
|
+
const storyboardFor = (b) => path.join(storyboardsDir, `${safeId(b.briefId)}.json`);
|
|
120
|
+
|
|
121
|
+
function planBatch(bundle) {
|
|
122
|
+
const order = [...bundle.briefs].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
123
|
+
const only = opt("--only") ? new Set(opt("--only").split(",").map((s) => s.trim()).filter(Boolean)) : null;
|
|
124
|
+
return order.map((b, i) => {
|
|
125
|
+
const sb = storyboardFor(b);
|
|
126
|
+
let storyboard = null; let problem = null;
|
|
127
|
+
if (only && !only.has(String(b.briefId))) problem = "not in --only";
|
|
128
|
+
else if (!fs.existsSync(sb)) problem = "no storyboard";
|
|
129
|
+
else {
|
|
130
|
+
try { storyboard = JSON.parse(fs.readFileSync(sb, "utf8")); } catch (e) { problem = `storyboard unreadable: ${e.message}`; }
|
|
131
|
+
if (storyboard && (!Array.isArray(storyboard.steps) || storyboard.steps.length === 0)) problem = "storyboard has no steps";
|
|
132
|
+
}
|
|
133
|
+
const live = b.attempt && ["planning", "awaiting_storyboard_approval", "recording", "uploading"].includes(b.attempt.state);
|
|
134
|
+
if (!problem && b.status && !["approved", "refined"].includes(b.status) && !live) problem = `brief is ${b.status}`;
|
|
135
|
+
return { n: i + 1, brief: b, storyboardPath: sb, storyboard, problem, liveAttempt: live ? b.attempt : null };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── one step = one child process, logged ───────────────────────────────────
|
|
140
|
+
function runStep(take, step, script, args, extraEnv = {}) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const logPath = path.join(take.logDir, `${step}.log`);
|
|
143
|
+
const out = fs.createWriteStream(logPath);
|
|
144
|
+
const all = fs.createWriteStream(path.join(take.logDir, "take.log"), { flags: "a" });
|
|
145
|
+
all.write(`\n── ${step} ${new Date().toISOString()}\n node ${path.basename(script)} ${args.join(" ")}\n`);
|
|
146
|
+
let stdout = ""; let stderr = "";
|
|
147
|
+
const child = spawn("node", [script, ...args], { cwd: process.cwd(), env: { ...process.env, ...extraEnv }, stdio: ["ignore", "pipe", "pipe"] });
|
|
148
|
+
take.child = child;
|
|
149
|
+
child.stdout.on("data", (d) => { stdout += d; out.write(d); all.write(d); });
|
|
150
|
+
child.stderr.on("data", (d) => { stderr += d; out.write(d); all.write(d); });
|
|
151
|
+
child.on("close", (code, signal) => {
|
|
152
|
+
take.child = null;
|
|
153
|
+
out.end(); all.end();
|
|
154
|
+
resolve({ code: signal ? 1 : code, stdout, stderr, logPath, signal });
|
|
155
|
+
});
|
|
156
|
+
child.on("error", (e) => { take.child = null; stderr += e.message; out.end(); all.end(); resolve({ code: 1, stdout, stderr, logPath }); });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const firstLine = (r) => (r.stderr.trim() || r.stdout.trim()).split("\n").filter(Boolean).pop() ?? `exit ${r.code}`;
|
|
160
|
+
const rel = (p) => path.relative(process.cwd(), p) || p;
|
|
161
|
+
|
|
162
|
+
// ── one profile per take, seeded from the shared one ───────────────────────
|
|
163
|
+
// Chrome refuses a second instance on the same profile (SingletonLock); a
|
|
164
|
+
// COPY carries the signed-in session and nothing that locks. Caches stay
|
|
165
|
+
// behind: they are large and Chrome rebuilds them.
|
|
166
|
+
const SKIP_IN_PROFILE = /^(Singleton|lockfile$|.*Cache$|Crashpad$|BrowserMetrics|Safe Browsing|component_crx_cache|extensions_crx_cache)/i;
|
|
167
|
+
function seedProfile(take) {
|
|
168
|
+
const dir = path.join(take.logDir, "profile");
|
|
169
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
170
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
171
|
+
if (fs.existsSync(sharedProfile)) {
|
|
172
|
+
fs.cpSync(sharedProfile, dir, { recursive: true, force: true, filter: (src) => !SKIP_IN_PROFILE.test(path.basename(src)) });
|
|
173
|
+
}
|
|
174
|
+
return dir;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── one take, start to delivery ────────────────────────────────────────────
|
|
178
|
+
const takesInFlight = new Set();
|
|
179
|
+
async function runTake(item, bundle, results) {
|
|
180
|
+
const b = item.brief;
|
|
181
|
+
const label = `brief ${item.n}/${bundle.briefs.length} "${b.title}"`;
|
|
182
|
+
const take = { briefId: b.briefId, title: b.title, n: item.n, logDir: path.join(takesDir, safeId(b.briefId)), child: null };
|
|
183
|
+
takesInFlight.add(take);
|
|
184
|
+
try { await filmTake(item, bundle, results, take, label); } finally { takesInFlight.delete(take); }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function filmTake(item, bundle, results, take, label) {
|
|
188
|
+
const b = item.brief;
|
|
189
|
+
fs.mkdirSync(take.logDir, { recursive: true });
|
|
190
|
+
fs.rmSync(path.join(take.logDir, "take.log"), { force: true });
|
|
191
|
+
const result = { briefId: b.briefId, title: b.title, n: item.n, startedAt: new Date().toISOString(), takeDir: null, attemptRef: null, outcome: null, step: null, error: null, biteId: null, logDir: rel(take.logDir) };
|
|
192
|
+
results.push(result);
|
|
193
|
+
const finish = (outcome, extra = {}) => { Object.assign(result, { outcome, endedAt: new Date().toISOString(), ...extra }); fs.writeFileSync(path.join(take.logDir, "result.json"), JSON.stringify(result, null, 2) + "\n"); };
|
|
194
|
+
const fail = async (step, r) => {
|
|
195
|
+
const why = firstLine(r);
|
|
196
|
+
result.step = step; result.error = why;
|
|
197
|
+
say(`${label}: failed at ${step} (${why}). Log: ${rel(r.logPath)}`);
|
|
198
|
+
if (result.attemptRef && result.takeDir) await runStep(take, "event-failed", SCRIPTS.briefs, ["event", result.takeDir, "failed", "--note", `${step}: ${why}`.slice(0, 600)]);
|
|
199
|
+
finish("failed");
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// 1. claim → take-<briefId>-r<revision>/brief.json
|
|
203
|
+
const claim = await runStep(take, "claim", SCRIPTS.briefs, ["claim", String(bundle.batch.id), String(b.briefId)]);
|
|
204
|
+
if (claim.code !== 0) { result.step = "claim"; result.error = firstLine(claim); say(`${label}: claim refused (${result.error}). Log: ${rel(claim.logPath)}`); finish("claim_refused"); return; }
|
|
205
|
+
const m = /→ (\S+)\/brief\.json \(attempt ([^,)]+)/.exec(claim.stdout);
|
|
206
|
+
result.takeDir = m?.[1] ?? `take-${safeId(b.briefId)}-r${b.revision}`;
|
|
207
|
+
result.attemptRef = m?.[2] ?? null;
|
|
208
|
+
if (!fs.existsSync(path.join(result.takeDir, "brief.json"))) { result.step = "claim"; result.error = `no brief.json in ${result.takeDir}`; say(`${label}: claim did not write ${result.takeDir}/brief.json`); finish("failed"); return; }
|
|
209
|
+
say(`${label}: claimed → ${result.takeDir} (attempt ${result.attemptRef ?? "?"})`);
|
|
210
|
+
|
|
211
|
+
// 2. the approved storyboard travels with the take (cleanup --prep reads it before record does)
|
|
212
|
+
fs.copyFileSync(item.storyboardPath, path.join(result.takeDir, "storyboard.json"));
|
|
213
|
+
const sb = item.storyboard;
|
|
214
|
+
const hasPrep = (sb.prep?.length ?? 0) > 0 || (sb.checks?.before?.length ?? 0) > 0;
|
|
215
|
+
const hasCleanup = (sb.cleanup?.length ?? 0) > 0 || (sb.checks?.after?.length ?? 0) > 0;
|
|
216
|
+
|
|
217
|
+
// 3. the attempt moves to recording; the take gets its own profile
|
|
218
|
+
const ev = await runStep(take, "event-recording", SCRIPTS.briefs, ["event", result.takeDir, "recording"]);
|
|
219
|
+
if (ev.code !== 0) { await fail("event recording", ev); return; }
|
|
220
|
+
let profile;
|
|
221
|
+
try { profile = seedProfile(take); } catch (e) { await fail("profile", { stdout: "", stderr: e.message, logPath: path.join(take.logDir, "take.log"), code: 1 }); return; }
|
|
222
|
+
const env = { RECORDER_PROFILE: profile };
|
|
223
|
+
say(`${label}: filming on its own profile (${rel(profile)})`);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
if (hasPrep) { const r = await runStep(take, "prep", SCRIPTS.cleanup, [result.takeDir, "--prep"], env); if (r.code !== 0) { await fail("prep", r); return; } say(`${label}: prep done (checks before passed)`); }
|
|
227
|
+
const rec = await runStep(take, "record", SCRIPTS.record, [result.takeDir, item.storyboardPath], env);
|
|
228
|
+
if (rec.code !== 0) { await fail("record", rec); return; }
|
|
229
|
+
say(`${label}: filmed`);
|
|
230
|
+
if (hasCleanup) { const r = await runStep(take, "cleanup", SCRIPTS.cleanup, [result.takeDir], env); if (r.code !== 0) { await fail("cleanup", r); return; } say(`${label}: workspace returned to its initial state (checks after passed)`); }
|
|
231
|
+
} finally {
|
|
232
|
+
if (!flag("--keep-profiles")) fs.rmSync(profile, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 4. finish the file, then the ONE delivery door
|
|
236
|
+
for (const [step, script] of [["trim", SCRIPTS.trim], ["calibrate", SCRIPTS.calibrate], ["manifest", SCRIPTS.manifest]]) {
|
|
237
|
+
const r = await runStep(take, step, script, [result.takeDir]);
|
|
238
|
+
if (r.code !== 0) { await fail(step, r); return; }
|
|
239
|
+
}
|
|
240
|
+
const up = await runStep(take, "upload", SCRIPTS.upload, [result.takeDir, "--stage-only", "--no-open"]);
|
|
241
|
+
let staged = null;
|
|
242
|
+
try { staged = JSON.parse(fs.readFileSync(path.join(result.takeDir, "staged.json"), "utf8")); } catch {}
|
|
243
|
+
if (staged?.delivered && staged.biteId) {
|
|
244
|
+
result.biteId = staged.biteId;
|
|
245
|
+
say(`${label}: delivered → bite ${staged.biteId}, becoming a demo by itself`);
|
|
246
|
+
finish("delivered", { stagingId: staged.stagingId, dashboardUrl: staged.dashboardUrl });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (staged?.waiting) {
|
|
250
|
+
const kept = up.stdout.split("\n").find((l) => l.startsWith("Kept.")) ?? "Kept. The take is waiting for recording minutes.";
|
|
251
|
+
say(`${label}: ${kept}`);
|
|
252
|
+
finish("waiting", { stagingId: staged.stagingId, resetsAt: staged.resetsAt, dashboardUrl: staged.dashboardUrl });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (up.code !== 0 || !staged) { await fail("upload", up); return; }
|
|
256
|
+
result.step = "upload"; result.error = staged.deliveryError ?? "staged, not delivered";
|
|
257
|
+
say(`${label}: staged but not delivered (${result.error}). Try again later: node scripts/status.mjs ${result.takeDir}`);
|
|
258
|
+
finish("staged", { stagingId: staged.stagingId });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── the pool ───────────────────────────────────────────────────────────────
|
|
262
|
+
async function runPool(items, size, worker) {
|
|
263
|
+
const queue = [...items];
|
|
264
|
+
const active = new Set();
|
|
265
|
+
const next = async () => {
|
|
266
|
+
const item = queue.shift();
|
|
267
|
+
if (!item) return;
|
|
268
|
+
const p = worker(item).catch((e) => { say(`brief ${item.n} "${item.brief.title}": ${e.message}`); }).finally(() => active.delete(p));
|
|
269
|
+
active.add(p);
|
|
270
|
+
await p;
|
|
271
|
+
await next();
|
|
272
|
+
};
|
|
273
|
+
await Promise.all(Array.from({ length: Math.min(size, queue.length) }, () => next()));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── main ───────────────────────────────────────────────────────────────────
|
|
277
|
+
const bundle = loadBundle();
|
|
278
|
+
const plan = planBatch(bundle);
|
|
279
|
+
const pool = poolSize({ override: opt("--concurrency") });
|
|
280
|
+
const ready = plan.filter((p) => !p.problem);
|
|
281
|
+
const radarName = bundle.radar?.name ?? slug;
|
|
282
|
+
|
|
283
|
+
console.log(`Update Radar: ${radarName} batch ${bundle.batch.id}`);
|
|
284
|
+
console.log(`Pool: ${pool.size} take${pool.size === 1 ? "" : "s"} at a time (${pool.why}), each on its own profile directory; the rest queue.`);
|
|
285
|
+
for (const p of plan) {
|
|
286
|
+
const tag = p.problem ? `skip, ${p.problem}` : `ready${p.liveAttempt ? `, live attempt ${p.liveAttempt.ref} will be superseded by the claim` : ""}`;
|
|
287
|
+
console.log(` ${String(p.n).padStart(2, " ")}. ${p.brief.title} brief ${p.brief.briefId} r${p.brief.revision} [${tag}]`);
|
|
288
|
+
}
|
|
289
|
+
if (cmd === "plan") {
|
|
290
|
+
console.log(`\nStoryboards live in ${rel(storyboardsDir)}/<briefId>.json. Films nothing; run: node scripts/batch.mjs run ${slug}`);
|
|
291
|
+
process.exit(ready.length ? 0 : 1);
|
|
292
|
+
}
|
|
293
|
+
if (!ready.length) { console.error(`\nNothing to film: no brief has a storyboard in ${rel(storyboardsDir)}/.`); process.exit(1); }
|
|
294
|
+
console.log(`\nFilming ${ready.length} of ${plan.length} in the background. Logs: ${rel(takesDir)}/<briefId>/\n`);
|
|
295
|
+
|
|
296
|
+
const results = [];
|
|
297
|
+
let interrupted = false;
|
|
298
|
+
process.on("SIGINT", () => {
|
|
299
|
+
if (interrupted) process.exit(130);
|
|
300
|
+
interrupted = true;
|
|
301
|
+
say("interrupted: stopping the takes in flight; claimed briefs are marked failed so they can be filmed again");
|
|
302
|
+
for (const t of takesInFlight) t.child?.kill("SIGTERM");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await runPool(ready, pool.size, async (item) => {
|
|
306
|
+
if (interrupted) return;
|
|
307
|
+
await runTake(item, bundle, results);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ── the summary ────────────────────────────────────────────────────────────
|
|
311
|
+
const count = (o) => results.filter((r) => r.outcome === o).length;
|
|
312
|
+
const skipped = plan.filter((p) => p.problem);
|
|
313
|
+
console.log(`\nBatch ${bundle.batch.id}: ${count("delivered")} delivered, ${count("waiting")} kept waiting for recording minutes, ${count("failed") + count("claim_refused") + count("staged")} not delivered, ${skipped.length} skipped.`);
|
|
314
|
+
for (const r of results.sort((a, b) => a.n - b.n)) {
|
|
315
|
+
const line = r.outcome === "delivered" ? `delivered, bite ${r.biteId}`
|
|
316
|
+
: r.outcome === "waiting" ? "kept, waiting for recording minutes (it waits on the Demos grid)"
|
|
317
|
+
: r.outcome === "staged" ? `staged, not delivered (${r.error}); node scripts/status.mjs ${r.takeDir}`
|
|
318
|
+
: r.outcome === "claim_refused" ? `claim refused: ${r.error}`
|
|
319
|
+
: `failed at ${r.step}: ${r.error} (${r.logDir}/${r.step === "event recording" ? "event-recording" : r.step}.log)`;
|
|
320
|
+
console.log(` ${String(r.n).padStart(2, " ")}. ${r.title}: ${line}`);
|
|
321
|
+
}
|
|
322
|
+
for (const p of skipped) console.log(` ${String(p.n).padStart(2, " ")}. ${p.brief.title}: skipped, ${p.problem}`);
|
|
323
|
+
const summary = { slug, batchId: bundle.batch.id, radar: bundle.radar ?? null, pool, startedAt: results[0]?.startedAt ?? null, endedAt: new Date().toISOString(), results, skipped: skipped.map((p) => ({ briefId: p.brief.briefId, title: p.brief.title, why: p.problem })) };
|
|
324
|
+
fs.writeFileSync(path.join(takesDir, "summary.json"), JSON.stringify(summary, null, 2) + "\n");
|
|
325
|
+
const grid = `${base}/demos`;
|
|
326
|
+
console.log(`\nDemos grid: ${grid}`);
|
|
327
|
+
if (bundle.radar?.workflowUrl) console.log(`Workflow page: ${bundle.radar.workflowUrl}`);
|
|
328
|
+
process.exit(results.every((r) => r.outcome === "delivered" || r.outcome === "waiting") && !interrupted ? 0 : 1);
|