@bongos/core 1.19.1063 → 1.19.1064

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 (37) hide show
  1. package/.bongos-core.json +72 -37
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +6 -0
  6. package/clients/bongos-client/index.cjs +6 -0
  7. package/clients/bongos-client/index.d.ts +10 -0
  8. package/clients/bongos-client/index.mjs +6 -0
  9. package/docs/api/openapi.json +258 -3
  10. package/docs/api-reference.md +5 -2
  11. package/docs/file-map.md +1 -0
  12. package/docs/module-api-changelog.md +2 -0
  13. package/modules/lifecycle/db-grade.js +16 -0
  14. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  15. package/modules/lifecycle/page-tweak-hold.js +74 -0
  16. package/modules/lifecycle/page-tweak-reads.js +33 -1
  17. package/modules/lifecycle/routes/tasks.js +4 -2
  18. package/modules/lifecycle/routes/visuals.js +82 -0
  19. package/modules/lifecycle/task-visual-db.js +40 -1
  20. package/modules/lifecycle/task-visuals.js +24 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +6 -0
  24. package/scripts/gds/copy-apply.js +277 -1
  25. package/scripts/gds/page-reader.js +2 -0
  26. package/scripts/gds/ship-finish.js +27 -2
  27. package/scripts/gds/ship-flow.js +17 -1
  28. package/scripts/gds/ship-land.js +30 -7
  29. package/scripts/gds/ship-merge.js +43 -15
  30. package/scripts/gds/strand-watch.js +41 -1
  31. package/scripts/gds/task.js +23 -5
  32. package/scripts/gds/tweak-renders.js +200 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/page_tweak_hold.mjs +265 -0
  35. package/tests/publish_status_branch.mjs +23 -0
  36. package/tests/task_visual_slots.mjs +161 -0
  37. package/tests/tweak_batch_apply.mjs +265 -0
@@ -46,6 +46,12 @@ const { readCheckRollup, UNIT_REPORT_CONTEXT } = require('../../modules/lifecycl
46
46
  function landed(o = {}) { return { ok: true, pending: false, reason: null, nextStep: null, noArtifact: o.noArtifact === true, noArtifactReason: o.noArtifactReason || null }; }
47
47
  function landPending() { return { ok: false, pending: true, reason: null, nextStep: null }; }
48
48
  function landBailed(reason, nextStep = null, bailId = null) { return { ok: false, pending: false, reason, nextStep, bailId }; }
49
+ // task 1004322 (ADR 0341 D7): the FOURTH outcome, and only a page tweak reaches it.
50
+ // Its grade passed and its branch and PR are published, but nothing is landing and
51
+ // nothing should: the artist approves the applied page first. Not a bail (nothing
52
+ // failed, and the card must not say "Land it") and not pending (no auto-merge is
53
+ // armed, so it does not land on its own).
54
+ function landHeld({ prUrl = null } = {}) { return { ok: false, pending: false, held: true, reason: null, nextStep: null, prUrl }; }
49
55
 
50
56
  // ---------- which bails a PR can still land (task 1002572, part 2) ----------
51
57
  //
@@ -79,12 +85,17 @@ function strandCommand(taskId, via) {
79
85
 
80
86
  // ciLand — dispatch the 'ci' confirmed->shipped land to the local or the
81
87
  // server-mediated path. Async because the server path awaits GDS API calls.
82
- async function ciLand(taskId, branch, valueSummary) {
83
- console.log(' deploy mode: ci — landing via PR auto-merge (no droplet key needed)');
88
+ // opts.hold (task 1004322): PUBLISH ONLY — push the branch and open the PR, never
89
+ // arm auto-merge and never poll for a land. A page tweak held for its artist takes
90
+ // it; every other caller passes nothing and gets the unchanged path.
91
+ async function ciLand(taskId, branch, valueSummary, opts = {}) {
92
+ console.log(opts.hold
93
+ ? ' held for the artist — publishing the branch and its PR only (no auto-merge, no land)'
94
+ : ' deploy mode: ci — landing via PR auto-merge (no droplet key needed)');
84
95
  if (pushVia() === 'server') {
85
- return ciLandServer(taskId, branch, valueSummary);
96
+ return ciLandServer(taskId, branch, valueSummary, opts);
86
97
  }
87
- return await ciLandLocal(taskId, branch, valueSummary);
98
+ return await ciLandLocal(taskId, branch, valueSummary, opts);
88
99
  }
89
100
 
90
101
  // task 1001916: render a FAILED POST /tasks/:id/publish-branch for the builder.
@@ -165,7 +176,7 @@ function formatPublishFailure(data, status, branch, opts = {}) {
165
176
  // hand the branch to the GDS, which pushes it + opens the PR + auto-merges with
166
177
  // the server-side credential. Requires a RE-AUTHED (non-box) cli session — a
167
178
  // box-scoped session is 403'd by the endpoint (it must `/builder-reauth` first).
168
- async function ciLandServer(taskId, branch, valueSummary) {
179
+ async function ciLandServer(taskId, branch, valueSummary, { hold = false } = {}) {
169
180
  console.log(' ci-land: server-mediated publish (this machine has no GitHub push credential)');
170
181
  const headSha = gitOk('git rev-parse HEAD');
171
182
  // Make sure origin/main is current so the THIN bundle's `origin/main..HEAD`
@@ -195,9 +206,14 @@ async function ciLandServer(taskId, branch, valueSummary) {
195
206
  console.log(` ci-land: server publish failed — ${e}. Task stays at confirmed. ${strandNextStep(taskId, 'server')}`);
196
207
  return landBailed(`server publish failed — ${e}`, strandCommand(taskId, 'server'));
197
208
  }
198
- console.log(` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge ${r.data.auto_merge ? 'enabled' : 'NOT enabled — merge it on GitHub'}).`);
209
+ // task 1004322: the server does not arm a held page tweak (it reads the hold
210
+ // itself), so do not tell the builder to merge it on GitHub.
211
+ console.log(hold
212
+ ? ` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge NOT armed: waiting for the artist).`
213
+ : ` ci-land: server pushed ${branch} + PR ${r.data.pr_url} (auto-merge ${r.data.auto_merge ? 'enabled' : 'NOT enabled — merge it on GitHub'}).`);
199
214
  const assigneeLine = prAssigneeLine(r.data.pr_assignee);
200
215
  if (assigneeLine) console.log(assigneeLine);
216
+ if (hold) return landHeld({ prUrl: r.data.pr_url || null });
201
217
  return pollServerPublish(taskId, branch);
202
218
  } finally {
203
219
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
@@ -858,7 +874,7 @@ function pushCapturing(branch, deps = {}) {
858
874
  // ciLandLocal — today's local path: this machine pushes the branch + opens the PR
859
875
  // + enables auto-merge via git + gh. async since task 1169 — it asks the server to
860
876
  // post the gate-author-trust attestation between the push and the PR open.
861
- async function ciLandLocal(taskId, branch, valueSummary) {
877
+ async function ciLandLocal(taskId, branch, valueSummary, { hold = false } = {}) {
862
878
  const headSha = gitOk('git rev-parse HEAD');
863
879
 
864
880
  console.log(` pushing ${branch} to origin...`);
@@ -924,6 +940,12 @@ async function ciLandLocal(taskId, branch, valueSummary) {
924
940
  console.log(` ci-land: reusing open PR ${existing.url}`);
925
941
  }
926
942
 
943
+ // task 1004322: a held page tweak stops here — published, never armed.
944
+ if (hold) {
945
+ const pr = ghJson(['pr', 'view', branch, '--json', 'number,url,state']);
946
+ console.log(` ci-land: PR ${(pr && pr.url) || 'opened'} — auto-merge NOT armed: waiting for the artist.`);
947
+ return landHeld({ prUrl: (pr && pr.url) || null });
948
+ }
927
949
  console.log(' ci-land: enabling auto-merge (lands when required checks pass)...');
928
950
  // task 1718: an enable-failure is NOT a dead-end — the PR is open and the server
929
951
  // still lands green PRs. Don't return false (which stranded the builder at
@@ -1075,6 +1097,7 @@ module.exports = {
1075
1097
  LAND_BAIL_NO_TASK_BRANCH,
1076
1098
  landBailRecoverableByPr,
1077
1099
  landBailed,
1100
+ landHeld,
1078
1101
  landPending,
1079
1102
  landPendingLines,
1080
1103
  landPollAfterAutoMerge,
@@ -86,6 +86,46 @@ async function autoMerge(taskId, valueSummary, opts = {}, deps = {}) {
86
86
  return await land(taskId, branch, valueSummary);
87
87
  }
88
88
 
89
+ // regenerateShipArtifacts — every generated artifact a branch must carry fresh,
90
+ // in the order the land has always refreshed them. Moved out of mergeLocally
91
+ // unchanged (task 1004322) so the held publish below carries the same set.
92
+ function regenerateShipArtifacts() {
93
+ regenerateDiagrams();
94
+ // Refresh the generated code symbol skeleton (Path B / task 1224) on the same beat.
95
+ regenerateRepoMap();
96
+ // Refresh the generated session-log index + the bounded CLAUDE.md §13 snippet (ADR 0062 §8 / task 1226).
97
+ regenerateSessionIndex();
98
+ // Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
99
+ regenerateFileMap();
100
+ // Refresh the copy registry + inventory on the same beat (task 1003556). Its rows
101
+ // carry line numbers, so ANY edit to a scanned UI file staled it — and nothing in
102
+ // the ship path regenerated it, so the PR was born stale and CI said so.
103
+ regenerateCopyInventory();
104
+ // Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
105
+ regenerateApiDocs();
106
+ // ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
107
+ regenerateApiClient();
108
+ }
109
+
110
+ // publishHeld — the held pass's whole "land" (task 1004322, ADR 0341 D7). A page
111
+ // tweak whose grade passed waits at completed for its artist, and the Approval
112
+ // queue shows the APPLIED BRANCH, so the branch and its PR must exist now: a
113
+ // grade-parked ship otherwise never publishes, and the later approve would strand
114
+ // at no_branch_no_tip. This publishes, in every deploy mode, through the PR path
115
+ // (never a local merge to main) and stops: no auto-merge, no poll, no land.
116
+ // `deps` is a test seam only.
117
+ async function publishHeld(taskId, valueSummary, deps = {}) {
118
+ const branch = (deps.currentBranch || currentBranch)();
119
+ if (!branch || branch === 'main' || branch === 'HEAD') {
120
+ return landBailed(
121
+ `nothing to publish — this checkout is on ${branch}, not the task branch`,
122
+ onMainNextStep(taskId, (deps.localTaskBranchExists || localTaskBranchExists)(taskId)),
123
+ ); // untagged on purpose: only autoMerge routes by bail id
124
+ }
125
+ (deps.regenerate || regenerateShipArtifacts)();
126
+ return await (deps.ciLand || ciLand)(taskId, branch, valueSummary, { hold: true });
127
+ }
128
+
89
129
  async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
90
130
  const branch = currentBranch();
91
131
  if (!branch || branch === 'main' || branch === 'HEAD') {
@@ -116,21 +156,7 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
116
156
 
117
157
  // Refresh the data-driven diagrams before computing commits-ahead, so a
118
158
  // diagram-only refresh still counts as something to deploy.
119
- regenerateDiagrams();
120
- // Refresh the generated code symbol skeleton (Path B / task 1224) on the same beat.
121
- regenerateRepoMap();
122
- // Refresh the generated session-log index + the bounded CLAUDE.md §13 snippet (ADR 0062 §8 / task 1226).
123
- regenerateSessionIndex();
124
- // Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
125
- regenerateFileMap();
126
- // Refresh the copy registry + inventory on the same beat (task 1003556). Its rows
127
- // carry line numbers, so ANY edit to a scanned UI file staled it — and nothing in
128
- // the ship path regenerated it, so the PR was born stale and CI said so.
129
- regenerateCopyInventory();
130
- // Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
131
- regenerateApiDocs();
132
- // ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
133
- regenerateApiClient();
159
+ regenerateShipArtifacts();
134
160
 
135
161
  // Resolve the deploy mode ONCE (task 1093 / C9) — it reads config/deploy.json;
136
162
  // reused at the fetch-bail check + the ci-land branch below.
@@ -422,6 +448,8 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
422
448
 
423
449
  module.exports = {
424
450
  autoMerge,
451
+ // task 1004322: the held page tweak's publish-only path.
452
+ publishHeld,
425
453
  // task 1002572 (PART 2): the pure recovery command for a ship run from main —
426
454
  // exported so the "don't point back at the dead end" wording is pinned.
427
455
  onMainNextStep,
@@ -38,6 +38,7 @@
38
38
  // Exit codes: 0 always on --line (fail-open). Otherwise 0 clean, 1 strands found, 2 undetermined.
39
39
 
40
40
  const { apiCall, arg, hasFlag } = require('./cli-lib');
41
+ const tweakHold = require('../../modules/lifecycle/page-tweak-hold.js');
41
42
 
42
43
  // ---- thresholds -------------------------------------------------------------
43
44
  // Reasoned, not magic. Written down because a future reader will want to retune them.
@@ -132,6 +133,31 @@ function rankStrands(strands) {
132
133
  });
133
134
  }
134
135
 
136
+ // ---- the held pass (task 1004322, ADR 0341 D7) --------------------------------
137
+ //
138
+ // A page tweak whose grade PASSED waits at `completed` for its artist on purpose:
139
+ // the artist approves the applied page before it lands. It is not a strand and not
140
+ // a failed grade, and calling it one would send someone to /grade-recover a task
141
+ // nobody should touch. The task list carries no grade, so each completed page
142
+ // tweak's grade is read on its own (there are a handful at most; the read is
143
+ // bounded anyway). A read that FAILS leaves the task to the ordinary
144
+ // classification: an unknown must stay loud, never be explained away as a hold.
145
+ const HOLD_READ_LIMIT = 25;
146
+
147
+ async function heldPageTweaks(tasks, { call, withTimeout }) {
148
+ const candidates = tasks.filter((t) => t && t.status === 'completed' && tweakHold.isPageTweak(t) && safeId(t.id)).slice(0, HOLD_READ_LIMIT);
149
+ const held = new Set();
150
+ await Promise.all(candidates.map(async (t) => {
151
+ try {
152
+ const res = await withTimeout(call('GET', `/api/bongos/tasks/${safeId(t.id)}?include=grade`), 'page-tweak grade');
153
+ const body = res && (res.data || res);
154
+ const grade = body && body.grade;
155
+ if (tweakHold.isHeldForArtist(t, grade ? grade.passed : null)) held.add(String(t.id));
156
+ } catch { /* unknown: classified as usual */ }
157
+ }));
158
+ return held;
159
+ }
160
+
135
161
  // ---- fetch ------------------------------------------------------------------
136
162
 
137
163
  async function check({ nowMs = Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS, confirmedStaleMs, completedStaleMs, call = apiCall } = {}) {
@@ -157,14 +183,23 @@ async function check({ nowMs = Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS, confi
157
183
 
158
184
  const strands = [];
159
185
  const truncated = [];
186
+ const waiting = [];
187
+ const completed = (pages.find((p) => p.status === 'completed') || { tasks: [] }).tasks;
188
+ const held = await heldPageTweaks(completed, { call, withTimeout });
160
189
  for (const { status, tasks } of pages) {
161
190
  if (tasks.length >= PAGE_LIMIT) truncated.push(status);
162
191
  for (const t of tasks) {
192
+ if (status === 'completed' && held.has(String(t.id))) {
193
+ const updated = Date.parse(t.updated_at || '');
194
+ const ageMs = Number.isFinite(updated) ? nowMs - updated : NaN;
195
+ waiting.push({ id: safeId(t.id), status, ageMs, age: humanAge(ageMs), shape: tweakHold.HOLD_STATE });
196
+ continue;
197
+ }
163
198
  const s = classifyStrand(t, { nowMs, confirmedStaleMs, completedStaleMs });
164
199
  if (s) strands.push(s);
165
200
  }
166
201
  }
167
- return { undetermined: false, ok: strands.length === 0 && !truncated.length, strands: rankStrands(strands), truncated };
202
+ return { undetermined: false, ok: strands.length === 0 && !truncated.length, strands: rankStrands(strands), truncated, waiting };
168
203
  }
169
204
 
170
205
  // ---- rendering --------------------------------------------------------------
@@ -218,6 +253,11 @@ function report(r) {
218
253
  console.log(` ! coverage capped: the ${r.truncated.join(' and ')} queue(s) returned a full ${PAGE_LIMIT}-row page,`);
219
254
  console.log(' so strands older than the cutoff are NOT listed below.');
220
255
  }
256
+ // task 1004322: named, and kept apart from the strands — nobody should recover these.
257
+ const waiting = Array.isArray(r.waiting) ? r.waiting : [];
258
+ for (const w of waiting) {
259
+ console.log(` task ${w.id} waiting for the artist (${w.age}) — a page tweak whose grade passed; not a strand, leave it`);
260
+ }
221
261
  if (r.ok) {
222
262
  console.log(' ✓ none — nothing parked at completed or confirmed past its threshold.');
223
263
  console.log('');
@@ -13,6 +13,8 @@
13
13
  // bongos task show <id> [--json]
14
14
  // bongos task visual <id> <image> [--alt "caption"] attach/replace the ship-time visual
15
15
  // bongos task visual <id> --remove take it back off
16
+ // bongos task visual <id> <image> --slot <name> attach/replace a NAMED visual (task 1004322)
17
+ // bongos task visual <id> --slot <name> --remove take a named one back off
16
18
  //
17
19
  // Task CREATION is Archon-gated SERVER-SIDE (creating work shapes scope, ADR 0016). This
18
20
  // CLI adds no authority — a lower rank gets a clear 403 message pointing at idea capture.
@@ -84,6 +86,7 @@ function usage() {
84
86
  console.error(' bongos task show <id> [--json]');
85
87
  console.error(' bongos task visual <id> <image> [--alt "caption"] attach or replace the ship-time visual');
86
88
  console.error(' bongos task visual <id> --remove remove it again');
89
+ console.error(' bongos task visual <id> <image> --slot <name> attach or replace a named visual (e.g. after-phone-dark)');
87
90
  console.error(`\n kinds: ${VALID_KINDS.join(' | ')}`);
88
91
  console.error(` disciplines: ${VALID_DISCIPLINES.join(' | ')}`);
89
92
  }
@@ -174,6 +177,21 @@ async function cmdVisual(args) {
174
177
  }
175
178
  const api = await cliClient();
176
179
 
180
+ // task 1004322: --slot names the visual (lifecycle_013). Validated here with the
181
+ // server's own rule, so a bad name costs a retyped command, not an upload.
182
+ const slot = arg('--slot', args) || null;
183
+ if (slot !== null && !taskVisuals.isSafeSlot(slot)) {
184
+ console.error(`--slot "${slot}" is not a slot name: lowercase words joined by dashes, such as ${taskVisuals.TWEAK_RENDER_SLOTS[0]}.`);
185
+ process.exit(2);
186
+ }
187
+ if (slot && hasFlag('--remove', args)) {
188
+ const r = await api.tasks.deleteTasksIdVisualsSlot({ id, slot });
189
+ if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
190
+ if (!r.ok) { console.error(`visual remove failed (${r.status}):`, r.data); process.exit(1); }
191
+ console.log(`✓ Task #${id} no longer carries the "${slot}" visual.`);
192
+ return;
193
+ }
194
+
177
195
  if (hasFlag('--remove', args)) {
178
196
  const r = await api.tasks.deleteTasksIdVisual({ id });
179
197
  if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
@@ -199,14 +217,14 @@ async function cmdVisual(args) {
199
217
  }
200
218
 
201
219
  const alt = arg('--alt', args) || '';
202
- const r = await api.tasks.postTasksIdVisual({
203
- id,
204
- body: { image_b64: visual.buf.toString('base64'), content_type: visual.contentType, ...(alt ? { alt } : {}) },
205
- });
220
+ const body = { image_b64: visual.buf.toString('base64'), content_type: visual.contentType, ...(alt ? { alt } : {}) };
221
+ const r = slot
222
+ ? await api.tasks.postTasksIdVisualsSlot({ id, slot, body })
223
+ : await api.tasks.postTasksIdVisual({ id, body });
206
224
  if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
207
225
  if (r.status === 403) { console.error(`Only task #${id}'s claim holder (or an Archon) may attach its visual.`); process.exit(1); }
208
226
  if (!r.ok) { console.error(`visual upload failed (${r.status}):`, r.data); process.exit(1); }
209
- console.log(`✓ Task #${id} now carries a visual (${Math.round(visual.buf.length / 1024)} KB).`);
227
+ console.log(`✓ Task #${id} now carries ${slot ? `the "${slot}" visual` : 'a visual'} (${Math.round(visual.buf.length / 1024)} KB).`);
210
228
  console.log(' It renders wherever this task\'s value summary does.');
211
229
  }
212
230
 
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/tweak-renders.js — the eight renders of a page tweak, shot and
5
+ // attached (task 1004322 / BV2.TW11; ADR 0341 D7 and its builder pick 9).
6
+ //
7
+ // WHY. The artist approves the APPLIED PAGE, not a diff and not a page built from
8
+ // the batch: the Approval queue (TW12) shows images of the applied branch, which
9
+ // is what keeps ADR 0233's "nothing renders a task description as product copy"
10
+ // true. So /tweak renders the page before it applies the batch and again after,
11
+ // on a desktop (1440) and a phone (390), in light and in dark, and attaches the
12
+ // eight pictures to the round's task under their slot names
13
+ // (task-visuals.TWEAK_RENDER_SLOTS: before-desktop-light … after-phone-dark).
14
+ //
15
+ // HOW IT RENDERS. Exactly the way the page reader reads (scripts/gds/page-reader.js):
16
+ // the hall through the hall-preview harness with the pinned fixture account, the
17
+ // landing and status surfaces through the ui-design kit's stub, one browser from
18
+ // the kit's launchBrowser (system Edge or Chrome; nothing is downloaded), the
19
+ // clock pinned, the page's first state from its .states.json. A page that pins
20
+ // one colour mode is shot in that mode for both of its "light" and "dark" slots,
21
+ // and the run says so, rather than inventing a mode the page does not have.
22
+ // Full-page JPEG, so a long page still fits under the store's 4 MB cap.
23
+ //
24
+ // Run: node scripts/gds/tweak-renders.js shoot --page <id> --phase before|after --out <dir>
25
+ // node scripts/gds/tweak-renders.js attach <task-id> --dir <dir> (every slot file in <dir>)
26
+ // node scripts/gds/tweak-renders.js plan --page <id> (what would be shot; no browser)
27
+ //
28
+ // Exit: 0 done · 1 a render or an upload failed · 2 usage · 3 no browser.
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+
33
+ const taskVisuals = require('../../modules/lifecycle/task-visuals');
34
+ const { arg, cliClient, cliExit } = require('./cli-lib');
35
+
36
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
37
+ const EXT = 'jpg';
38
+ const JPEG_QUALITY = 82;
39
+
40
+ // ---- pure ------------------------------------------------------------------
41
+
42
+ function slotName(phase, device, mode) {
43
+ return `${phase}-${device}-${mode}`;
44
+ }
45
+
46
+ // The shots for one phase of one page: one per device x mode, each carrying the
47
+ // slot it fills and the mode the page will actually render in. `plan` is the
48
+ // page's first render plan from page-reader.planRenders.
49
+ function shotsFor(phase, plan) {
50
+ if (!taskVisuals.TWEAK_RENDER_PHASES.includes(phase)) throw new Error(`phase must be one of ${taskVisuals.TWEAK_RENDER_PHASES.join(', ')}`);
51
+ const pinned = plan && plan.pinnedMode ? plan.pinnedMode : null;
52
+ const out = [];
53
+ for (const [device, width] of Object.entries(taskVisuals.TWEAK_RENDER_DEVICES)) {
54
+ for (const mode of taskVisuals.TWEAK_RENDER_MODES) {
55
+ out.push({ slot: slotName(phase, device, mode), device, width, mode, renderMode: pinned || mode, file: `${slotName(phase, device, mode)}.${EXT}` });
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // The page's first render plan, and whether its states file pins a mode. Reads
62
+ // the inventory and the states file; no browser.
63
+ function planForPage(pageId, { inventory, planRenders, root = REPO_ROOT }) {
64
+ const page = (inventory.pages || []).find((p) => p.id === pageId);
65
+ if (!page) return { ok: false, code: 'unknown_page', detail: { page: pageId } };
66
+ const plans = planRenders(page, { root });
67
+ const plan = plans[0];
68
+ let modes = null;
69
+ if (page.states_files && page.states_files.length) {
70
+ try { modes = JSON.parse(fs.readFileSync(path.join(root, page.states_files[0]), 'utf8')).modes || null; } catch { modes = null; }
71
+ }
72
+ const pinnedMode = Array.isArray(modes) && modes.length === 1 ? modes[0] : null;
73
+ return { ok: true, page, plan: { ...plan, pinnedMode } };
74
+ }
75
+
76
+ // Which slot files a directory holds, in slot order, and which are missing.
77
+ function slotFilesIn(dir, { exists = (p) => fs.existsSync(p) } = {}) {
78
+ const found = [];
79
+ const missing = [];
80
+ for (const slot of taskVisuals.TWEAK_RENDER_SLOTS) {
81
+ const hit = ['jpg', 'png', 'webp'].map((e) => path.join(dir, `${slot}.${e}`)).find(exists);
82
+ if (hit) found.push({ slot, file: hit });
83
+ else missing.push(slot);
84
+ }
85
+ return { found, missing };
86
+ }
87
+
88
+ // ---- the browser half --------------------------------------------------------
89
+
90
+ async function shoot({ pageId, phase, out }) {
91
+ const reader = require('./page-reader.js');
92
+ const kit = require('../../modules/ui-design/kit/lib');
93
+ const inventory = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, reader.INVENTORY_REL), 'utf8'));
94
+ const planned = planForPage(pageId, { inventory, planRenders: reader.planRenders });
95
+ if (!planned.ok) { console.error(`tweak-renders: ${planned.code} (${pageId})`); return 2; }
96
+ const { page, plan } = planned;
97
+ const shots = shotsFor(phase, plan);
98
+ if (plan.pinnedMode) console.log(`tweak-renders: ${pageId} pins the ${plan.pinnedMode} mode, so both of its mode slots are shot in ${plan.pinnedMode}.`);
99
+ fs.mkdirSync(out, { recursive: true });
100
+
101
+ let browser;
102
+ try { browser = await kit.launchBrowser(); } catch (e) {
103
+ console.error(`tweak-renders: ${e.message}`);
104
+ return e instanceof kit.NoBrowserError ? 3 : 1;
105
+ }
106
+ const stoppers = [];
107
+ let failed = 0;
108
+ try {
109
+ let base;
110
+ if (page.surface === 'builders') {
111
+ const h = await reader.startHarness(kit);
112
+ stoppers.push(h.stop);
113
+ base = h.base;
114
+ } else {
115
+ const s = await kit.startStub({ surface: reader.SURFACE_MODULE[page.surface] || page.surface, auth: plan.auth, prefix: plan.stub.prefix, child: plan.stub.child, fixtures: plan.stub.fixtures });
116
+ stoppers.push(s.stop);
117
+ base = s.base;
118
+ }
119
+ for (const shot of shots) {
120
+ const [W, H] = kit.SIZES[shot.width];
121
+ const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 1, colorScheme: shot.renderMode });
122
+ try {
123
+ if (ctx.clock && typeof ctx.clock.setFixedTime === 'function') await ctx.clock.setFixedTime(new Date(reader.PINNED_CLOCK));
124
+ const p = await ctx.newPage();
125
+ p.setDefaultTimeout(6000);
126
+ p.on('dialog', (d) => { d.dismiss().catch(() => {}); });
127
+ await p.goto(base + kit.withMode(plan.url, shot.renderMode, plan.modeQuery), { waitUntil: 'load', timeout: 45000 });
128
+ await p.evaluate(() => document.fonts.ready).catch(() => {});
129
+ await p.waitForTimeout(kit.SETTLE_MS);
130
+ try { await kit.runActions(p, plan.actions); } catch (e) { console.log(` ${shot.slot}: an action failed (${String(e.message).split('\n')[0].slice(0, 100)}); shot as it stood`); }
131
+ await p.screenshot({ path: path.join(out, shot.file), fullPage: true, type: 'jpeg', quality: JPEG_QUALITY });
132
+ const kb = Math.round(fs.statSync(path.join(out, shot.file)).size / 1024);
133
+ console.log(` ${shot.slot}: ${W}px ${shot.renderMode} -> ${shot.file} (${kb} KB)`);
134
+ } catch (e) {
135
+ failed += 1;
136
+ console.error(` ${shot.slot}: NOT rendered (${String(e.message).split('\n')[0].slice(0, 160)})`);
137
+ } finally {
138
+ await ctx.close().catch(() => {});
139
+ }
140
+ }
141
+ } finally {
142
+ await browser.close().catch(() => {});
143
+ for (const stop of stoppers) stop();
144
+ }
145
+ console.log(`tweak-renders: ${shots.length - failed} of ${shots.length} ${phase} render(s) of ${pageId} in ${out}`);
146
+ return failed ? 1 : 0;
147
+ }
148
+
149
+ // ---- the upload half ---------------------------------------------------------
150
+
151
+ async function attach({ taskId, dir }, deps = {}) {
152
+ const { found, missing } = slotFilesIn(dir, deps);
153
+ if (!found.length) { console.error(`tweak-renders: no slot files in ${dir} (expected ${taskVisuals.TWEAK_RENDER_SLOTS[0]}.${EXT} and the rest).`); return 1; }
154
+ const client = deps.client || await cliClient();
155
+ let failed = 0;
156
+ for (const { slot, file } of found) {
157
+ const img = taskVisuals.readImageFile(file);
158
+ if (!img.ok) { failed += 1; console.error(` ${slot}: ${img.message}`); continue; }
159
+ const r = await client.tasks.postTasksIdVisualsSlot({ id: taskId, slot, body: { image_b64: img.buf.toString('base64'), content_type: img.contentType, alt: `${slot.replace(/-/g, ' ')} render of the page` } });
160
+ if (r.ok) console.log(` ${slot}: attached (${Math.round(img.buf.length / 1024)} KB)`);
161
+ else { failed += 1; console.error(` ${slot}: upload failed (${r.status})`); }
162
+ }
163
+ if (missing.length) console.error(`tweak-renders: MISSING ${missing.length} slot(s): ${missing.join(', ')}. The Approval queue shows what is attached; shoot the missing phase and attach again.`);
164
+ console.log(`tweak-renders: ${found.length - failed} of ${taskVisuals.TWEAK_RENDER_SLOTS.length} render(s) attached to task ${taskId}.`);
165
+ return failed || missing.length ? 1 : 0;
166
+ }
167
+
168
+ async function main(args) {
169
+ const cmd = args[0];
170
+ if (cmd === 'shoot' || cmd === 'plan') {
171
+ const pageId = arg('--page', args);
172
+ if (!pageId) { console.error('usage: node scripts/gds/tweak-renders.js shoot --page <id> --phase before|after --out <dir>'); return 2; }
173
+ if (cmd === 'plan') {
174
+ const reader = require('./page-reader.js');
175
+ const inventory = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, reader.INVENTORY_REL), 'utf8'));
176
+ const planned = planForPage(pageId, { inventory, planRenders: reader.planRenders });
177
+ if (!planned.ok) { console.error(`tweak-renders: ${planned.code} (${pageId})`); return 2; }
178
+ for (const phase of taskVisuals.TWEAK_RENDER_PHASES) for (const s of shotsFor(phase, planned.plan)) console.log(`${s.slot}\t${s.width}px\t${s.renderMode}\t${planned.plan.url}`);
179
+ return 0;
180
+ }
181
+ const phase = arg('--phase', args);
182
+ const out = arg('--out', args);
183
+ if (!taskVisuals.TWEAK_RENDER_PHASES.includes(phase) || !out) { console.error('usage: node scripts/gds/tweak-renders.js shoot --page <id> --phase before|after --out <dir>'); return 2; }
184
+ return shoot({ pageId, phase, out: path.resolve(out) });
185
+ }
186
+ if (cmd === 'attach') {
187
+ const taskId = Number(args[1]);
188
+ const dir = arg('--dir', args);
189
+ if (!Number.isInteger(taskId) || taskId <= 0 || !dir) { console.error('usage: node scripts/gds/tweak-renders.js attach <task-id> --dir <dir>'); return 2; }
190
+ return attach({ taskId, dir: path.resolve(dir) });
191
+ }
192
+ console.error('usage: node scripts/gds/tweak-renders.js shoot|attach|plan … (see the file header)');
193
+ return 2;
194
+ }
195
+
196
+ if (require.main === module) {
197
+ main(process.argv.slice(2)).then((code) => cliExit(code), (e) => { console.error(`tweak-renders: ${e.stack || e.message}`); cliExit(1); });
198
+ }
199
+
200
+ module.exports = { shotsFor, planForPage, attach };
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1063'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1064'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');