@bongos/core 1.21.96 → 1.21.98

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 (44) hide show
  1. package/.bongos-core.json +62 -47
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0152-metic-task-abandonment.md +2 -0
  8. package/docs/api/openapi.json +52 -5
  9. package/docs/api-reference.md +3 -2
  10. package/docs/architecture.md +3 -0
  11. package/docs/copy-inventory.md +395 -393
  12. package/docs/copy-registry.json +486 -468
  13. package/docs/design/project-startup-direction.md +13 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +260 -260
  16. package/migrations/core_274_parked_backlog_stays_parked.sql +140 -0
  17. package/modules/hall-ui/public/studio-lib.js +22 -0
  18. package/modules/hall-ui/public/studio.css +138 -7
  19. package/modules/hall-ui/public/studio.html +10 -4
  20. package/modules/hall-ui/public/studio.js +39 -1
  21. package/modules/hall-ui/records/studio.md +16 -0
  22. package/modules/lifecycle/db-ship.js +7 -2
  23. package/modules/lifecycle/db-tasks.js +25 -0
  24. package/modules/lifecycle/routes/tasks.js +5 -5
  25. package/modules/provisioning/github-setup.js +73 -4
  26. package/modules/provisioning/routes/github-setup.js +43 -1
  27. package/modules/provisioning/routes/provisioning.js +8 -4
  28. package/modules/public-landing/public/projects.html +185 -30
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/release-notes.json +16 -0
  32. package/scripts/gds/provision-github-setup.js +29 -9
  33. package/scripts/gds/provision.js +20 -9
  34. package/src/module-api.js +1 -1
  35. package/tests/abandon_task.mjs +1 -1
  36. package/tests/github_one_trip.mjs +4 -4
  37. package/tests/github_org_account.mjs +3 -2
  38. package/tests/github_pick_up.mjs +366 -0
  39. package/tests/hall_studio_home.mjs +140 -5
  40. package/tests/parked_backlog_stays_parked.mjs +182 -0
  41. package/tests/projects_hub_pre_uat.mjs +3 -2
  42. package/tests/promotion_triggers_skip_cascade.mjs +5 -3
  43. package/tests/provisioning_github_app_routes.mjs +73 -0
  44. package/tests/wizard_github_feedback.mjs +95 -29
@@ -7,7 +7,10 @@
7
7
  // Then the REAL page: studio.html's own body in a mini-DOM, with the real
8
8
  // dom-utils.js, tweak-editor-lib.js, studio-lib.js and studio.js in a vm over a
9
9
  // stub client, driven the way an artist would: the tally, Resume, the three
10
- // panels, "take it", and the studio's own words typed over in place.
10
+ // panels, "take it", and the studio's own words typed over in place. And the
11
+ // room's sky (task 1004654): the day room from 07:00 to 18:59 on the viewer's
12
+ // clock, re-checked each minute, and a greeting for the time of day that an
13
+ // artist's own words always beat.
11
14
 
12
15
  import { strict as assert } from 'node:assert';
13
16
  import { createRequire } from 'node:module';
@@ -112,9 +115,23 @@ function augment(el) {
112
115
  return el;
113
116
  }
114
117
 
115
- function bootStudio({ answers = {}, signedIn = true } = {}) {
118
+ // The viewer's clock, in LOCAL time (the rule reads getHours), held still so
119
+ // a test can name the hour. new Date() answers the held time; new Date(x) is
120
+ // the real constructor, so the composers' ISO dates still parse.
121
+ function heldClock(h, m) {
122
+ const Real = Date;
123
+ let t = new Real(2026, 9, 9, h, m, 0).getTime();
124
+ class Held extends Real {
125
+ constructor(...a) { if (a.length) super(...a); else super(t); }
126
+ static now() { return t; }
127
+ }
128
+ Held.set = (hh, mm) => { t = new Real(2026, 9, 9, hh, mm, 0).getTime(); };
129
+ return Held;
130
+ }
131
+
132
+ function bootStudio({ answers = {}, signedIn = true, at = null, body = BODY } = {}) {
116
133
  const root = augment(makeEl('body', {}));
117
- root.innerHTML = BODY;
134
+ root.innerHTML = body;
118
135
  const byId = (id) => walk(root, (c) => (c.id === id ? c : null));
119
136
  const docListeners = {};
120
137
  const requests = [];
@@ -154,12 +171,20 @@ function bootStudio({ answers = {}, signedIn = true } = {}) {
154
171
  addEventListener() {},
155
172
  scrollTo() {},
156
173
  };
157
- const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, encodeURIComponent };
174
+ // setInterval is held, never real (a real one would keep the run alive): the
175
+ // test fires each registered tick by hand, as a minute passing.
176
+ const intervals = [];
177
+ const fakeSetInterval = (fn, ms) => { intervals.push({ fn, ms }); return intervals.length; };
178
+ const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, setInterval: fakeSetInterval, encodeURIComponent };
179
+ const clock = at ? heldClock(at[0], at[1]) : null;
180
+ if (clock) sandbox.Date = clock;
158
181
  sandbox.globalThis = sandbox;
159
182
  vm.createContext(sandbox);
160
183
  for (const [name, src] of SOURCES) vm.runInContext(src, sandbox, { filename: name });
161
184
  const out = {
162
- requests, navigated: null, focused: null, el: byId, html: htmlEl,
185
+ requests, navigated: null, focused: null, el: byId, html: htmlEl, body: root, intervals,
186
+ // Move the held clock and let a minute pass: every registered tick fires.
187
+ tickTo: (h, m) => { clock.set(h, m); for (const i of intervals) i.fn(); },
163
188
  writes: () => requests.filter((r) => r.method !== 'GET'),
164
189
  click: async (id) => { const el = byId(id); assert.ok(el, `no #${id}`); el.fire('click', { target: el }); await out.settle(); },
165
190
  clickIn: async (overlayId, sel) => {
@@ -404,6 +429,116 @@ try {
404
429
  assert.match(s.el('st-offer-err').textContent, /Only an artist can take a page/);
405
430
  });
406
431
 
432
+ // ── the room's sky and the greeting (task 1004654) ────────────────────────
433
+ const at = (h, m) => new Date(2026, 9, 9, h, m, 0);
434
+
435
+ await test('the sky: day from 07:00 to 18:59 on the viewer\'s clock, night either side of it', () => {
436
+ assert.equal(lib.skyAt(at(6, 59)), 'night');
437
+ assert.equal(lib.skyAt(at(7, 0)), 'day');
438
+ assert.equal(lib.skyAt(at(12, 0)), 'day');
439
+ assert.equal(lib.skyAt(at(18, 59)), 'day');
440
+ assert.equal(lib.skyAt(at(19, 0)), 'night');
441
+ assert.equal(lib.skyAt(at(0, 0)), 'night');
442
+ });
443
+
444
+ await test('the greeting\'s time of day: morning before noon by day, afternoon after, evening at night', () => {
445
+ assert.equal(lib.dayPart(at(6, 59)), 'evening', 'before seven the room is still at night');
446
+ assert.equal(lib.dayPart(at(7, 0)), 'morning');
447
+ assert.equal(lib.dayPart(at(11, 59)), 'morning');
448
+ assert.equal(lib.dayPart(at(12, 0)), 'afternoon');
449
+ assert.equal(lib.dayPart(at(18, 59)), 'afternoon');
450
+ assert.equal(lib.dayPart(at(19, 0)), 'evening');
451
+ assert.equal(lib.clockGreets('Good evening.'), true);
452
+ assert.equal(lib.clockGreets(' Good evening. '), true, 'the served markup\'s whitespace is not a tweak');
453
+ assert.equal(lib.clockGreets('Hello, you.'), false, 'an artist\'s words are not the stock greeting');
454
+ assert.match(HTML, new RegExp('id="studio-greet"[^>]*>' + lib.STOCK_GREETING.replace('.', '\\.') + '<'), 'the page is served with the stock greeting');
455
+ });
456
+
457
+ await test('the page: the body wears the sky and the greeting follows the clock at each boundary hour', async () => {
458
+ for (const [h, m, sky, words] of [
459
+ [6, 59, 'night', 'Good evening.'],
460
+ [7, 0, 'day', 'Good morning.'],
461
+ [11, 59, 'day', 'Good morning.'],
462
+ [12, 0, 'day', 'Good afternoon.'],
463
+ [18, 59, 'day', 'Good afternoon.'],
464
+ [19, 0, 'night', 'Good evening.'],
465
+ ]) {
466
+ const s = bootStudio({ answers: HOME, at: [h, m] });
467
+ await s.settle();
468
+ const hhmm = `${h}:${String(m).padStart(2, '0')}`;
469
+ assert.equal(s.body.getAttribute('data-sky'), sky, `the sky at ${hhmm}`);
470
+ assert.equal(s.el('studio-greet').textContent, words, `the greeting at ${hhmm}`);
471
+ }
472
+ });
473
+
474
+ await test('re-checked each minute: the room turns to day at 07:00 and back to night at 19:00', async () => {
475
+ const s = bootStudio({ answers: HOME, at: [6, 59] });
476
+ await s.settle();
477
+ assert.deepEqual(s.intervals.map((i) => i.ms), [60000], 'one tick, once a minute');
478
+ assert.equal(s.body.getAttribute('data-sky'), 'night');
479
+ s.tickTo(7, 0);
480
+ assert.equal(s.body.getAttribute('data-sky'), 'day');
481
+ assert.equal(s.el('studio-greet').textContent, 'Good morning.');
482
+ s.tickTo(12, 0);
483
+ assert.equal(s.el('studio-greet').textContent, 'Good afternoon.');
484
+ s.tickTo(19, 0);
485
+ assert.equal(s.body.getAttribute('data-sky'), 'night');
486
+ assert.equal(s.el('studio-greet').textContent, 'Good evening.', 'at night the page\'s own greeting is back');
487
+ });
488
+
489
+ await test('an artist\'s rewritten greeting wins at every hour; the sky still follows the clock', async () => {
490
+ const tweaked = BODY.replace(/(id="studio-greet"[^>]*>)Good evening\.</, '$1Back at the desk.<');
491
+ assert.notEqual(tweaked, BODY, 'the fixture rewrote the greeting');
492
+ const s = bootStudio({ answers: HOME, body: tweaked, at: [8, 0] });
493
+ await s.settle();
494
+ assert.equal(s.body.getAttribute('data-sky'), 'day');
495
+ assert.equal(s.el('studio-greet').textContent, 'Back at the desk.');
496
+ for (const [h, m] of [[12, 0], [19, 0], [7, 0]]) {
497
+ s.tickTo(h, m);
498
+ assert.equal(s.el('studio-greet').textContent, 'Back at the desk.', `their words at ${h}:${m}`);
499
+ }
500
+ });
501
+
502
+ await test('typing over the greeting in the morning: it opens as the page\'s own line, and the clock never types over it', async () => {
503
+ const s = bootStudio({ answers: { ...HOME, 'GET /copy-desk/pages/builders:studio/draft': ok(studioRead({ held: true })) }, at: [9, 0] });
504
+ await s.settle();
505
+ const greet = s.el('studio-greet');
506
+ assert.equal(greet.textContent, 'Good morning.');
507
+ await s.click('studio-greet');
508
+ assert.equal(greet.getAttribute('contenteditable'), 'true', 'matched to the greeting line, not left unread');
509
+ assert.equal(greet.getAttribute('data-unread'), null);
510
+ greet.textContent = 'Morning, you.';
511
+ s.tickTo(13, 0);
512
+ assert.equal(greet.textContent, 'Morning, you.', 'a minute passing never overwrites the words being typed');
513
+ });
514
+
515
+ await test('the sheet: the day tokens sit beside the night ones, the day pieces, and motion only without a reduced-motion preference', () => {
516
+ const CSS = read('studio.css');
517
+ const dayAt = CSS.indexOf('body[data-page="studio"][data-sky="day"] {');
518
+ const nightAt = CSS.indexOf('body[data-page="studio"] {');
519
+ assert.ok(nightAt >= 0 && dayAt > nightAt, 'a day block after the night tokens');
520
+ assert.ok(CSS.slice(nightAt, dayAt).split('\n}').length === 2, 'directly after them: nothing between the two blocks but the day block\'s note');
521
+ const dayBlock = CSS.slice(dayAt, CSS.indexOf('}', dayAt));
522
+ // the mockup's [data-sky="day"] values (docs/design/mocks/day-night-rooms/index.html)
523
+ for (const [tok, v] of [['--st-room', '#efe7dc'], ['--st-frame', '#d6c9b7'], ['--st-night', '#bfdaf0'], ['--st-moon', '#ffd46e'],
524
+ ['--st-tower-1', '#9db2c7'], ['--st-tower-2', '#8ea4bb'], ['--st-tower-3', '#a8bccf'], ['--st-pane', '#e9f1f8'],
525
+ ['--st-floor', '#e2d5c3'], ['--st-ink', '#1b1a1d'], ['--st-glass', 'rgba(255, 255, 255, .52)']]) {
526
+ assert.ok(dayBlock.includes(`${tok}: ${v};`), `day ${tok} is the mockup's ${v}`);
527
+ }
528
+ assert.match(CSS, /\.st-light \{[^}]*background: var\(--st-pane\)/, 'the panes are lit by night and unlit by day through one token');
529
+ assert.match(CSS, /body\[data-sky="day"\] \.st-cloud \{ opacity: \.9; \}/);
530
+ assert.match(CSS, /body\[data-sky="day"\] \.st-sunpatch \{ opacity: \.7; \}/);
531
+ assert.match(CSS, /body\[data-sky="day"\] \.st-star,\s*body\[data-sky="day"\] \.st-glow \{ opacity: 0; \}/, 'no stars, and the lamp is off');
532
+ assert.match(HTML, /class="st-cloud st-cloud--1"/);
533
+ assert.match(HTML, /class="st-sunpatch"/);
534
+ const anims = [...CSS.matchAll(/animation:\s*[^;]+;|transition:\s*[^;]+;/g)].map((m) => m.index);
535
+ const calm = CSS.indexOf('@media (prefers-reduced-motion: no-preference) {');
536
+ const calmEnd = CSS.indexOf('\n}', calm);
537
+ assert.ok(anims.length > 0 && anims.every((i) => i > calm && i < calmEnd), 'every animation and transition sits inside the no-preference block');
538
+ assert.match(CSS, /body\[data-page="studio"\]\[data-sky="day"\] \.st-overlay \{\s*--st-scrim:/, 'the panels follow the sky');
539
+ assert.match(CSS, /body\[data-page="studio"\]\[data-sky="day"\] \.aq-overlay \{/, 'the Approval queue follows the sky');
540
+ });
541
+
407
542
  await test('the older reviews stay one quiet door away while any still wait', async () => {
408
543
  const REVIEW = { id: '1900', title: 'Artist review: the copy and visuals that shipped in task 1899', status: 'backlog', source: 'cascade', source_ref: 'cascade-artist-review-on-ship-1899', created_at: day(20) };
409
544
  const s = bootStudio({ answers: { ...HOME, 'GET /tasks?discipline=artist&status=backlog&limit=50': ok({ tasks: [REVIEW] }) } });
@@ -0,0 +1,182 @@
1
+ // tests/parked_backlog_stays_parked.mjs
2
+ //
3
+ // A task a PERSON parked in backlog stays there until a person promotes it
4
+ // (task 1004592, migration core_274).
5
+ //
6
+ // What happened: restoring abandoned task 1003397 put it in backlog as ADR 0152
7
+ // says ("comes back un-workable and needs a second, explicit promote"), and the
8
+ // promotion sweep promoted it on the next reconciler tick. Demoting it again
9
+ // was undone one tick later the same way. The sweep could not tell "waiting on
10
+ // a dependency" from "parked on purpose", because nothing in the row said so.
11
+ //
12
+ // HOW THIS PROVES IT. The DB-free unit gate has no Postgres, so the fake below
13
+ // holds task rows in memory and answers each statement the way the database
14
+ // would GIVEN WHAT THE STATEMENT ACTUALLY SAYS: a restore or demote parks a row
15
+ // only if its UPDATE writes parked_at, and the sweep withholds a parked row
16
+ // only if its SQL carries `parked_at IS NULL`. Drop either half and the
17
+ // "still backlog" cases go red. The real restoreTask, parkTask and
18
+ // promoteUnblockedBacklogTasks run against it; nothing is re-typed here.
19
+ //
20
+ // The trigger half (the three promotion functions and the un-park trigger) is
21
+ // checked on the migration's SQL, comments stripped, as
22
+ // tests/promotion_triggers_skip_cascade.mjs does for core_250. That it applied
23
+ // is not proved here; it needs Postgres.
24
+ //
25
+ // Run: node tests/parked_backlog_stays_parked.mjs
26
+
27
+ import { strict as assert } from 'node:assert';
28
+ import { createRequire } from 'node:module';
29
+ import { readFileSync } from 'node:fs';
30
+ import { makeRunner } from './helpers.mjs';
31
+
32
+ process.env.NODE_ENV = 'test';
33
+ const require = createRequire(import.meta.url);
34
+ const dbShip = require('../modules/lifecycle/db-ship.js');
35
+ const dbTasks = require('../modules/lifecycle/db-tasks.js');
36
+ const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8').replace(/\r\n/g, '\n');
37
+
38
+ const { test, summary } = makeRunner();
39
+
40
+ const PERSON = 42;
41
+
42
+ // One in-memory tasks table. Every row has no dependencies and no blocker, so
43
+ // the sweep's other guards pass it: the parked clause is the only thing that
44
+ // can hold a row back.
45
+ function fakeDb(rows) {
46
+ const tasks = new Map(rows.map((r) => [r.id, { parked_at: null, parked_by: null, ...r }]));
47
+ const flat = (sql) => String(sql).replace(/\s+/g, ' ');
48
+ // core_274's BEFORE UPDATE trigger: leaving backlog un-parks.
49
+ const setStatus = (row, status) => {
50
+ if (status !== 'backlog') { row.parked_at = null; row.parked_by = null; }
51
+ row.status = status;
52
+ };
53
+ const sweepAdmits = (sql, row) => row.status === 'backlog'
54
+ && !(/parked_at IS NULL/.test(sql) && row.parked_at);
55
+ async function query(sqlIn, params = []) {
56
+ const sql = flat(sqlIn);
57
+ if (/^(BEGIN|COMMIT|ROLLBACK)/.test(sql)) return { rows: [] };
58
+ if (/^SELECT id, title, status FROM tasks WHERE id = \$1 FOR UPDATE/.test(sql)) {
59
+ const row = tasks.get(params[0]);
60
+ return { rows: row ? [{ ...row }] : [] };
61
+ }
62
+ if (/^SELECT t\.id, t\.title, t\.goal_id FROM tasks t/.test(sql)) {
63
+ return { rows: [...tasks.values()].filter((r) => sweepAdmits(sql, r)).map(({ id, title, goal_id }) => ({ id, title, goal_id })) };
64
+ }
65
+ if (/^UPDATE tasks t SET status = 'ready'/.test(sql)) {
66
+ const ids = params[0];
67
+ const hit = [...tasks.values()].filter((r) => ids.includes(r.id) && sweepAdmits(sql, r));
68
+ hit.forEach((r) => setStatus(r, 'ready'));
69
+ return { rows: hit.map(({ id, title, goal_id }) => ({ id, title, goal_id })) };
70
+ }
71
+ if (/^UPDATE tasks SET status = 'backlog'/.test(sql)) {
72
+ const row = tasks.get(params[0]);
73
+ const guard = /AND status = 'ready'/.test(sql) ? row && row.status === 'ready' : !!row;
74
+ if (!guard) return { rows: [], rowCount: 0 };
75
+ setStatus(row, 'backlog');
76
+ if (/parked_at = now\(\)/.test(sql)) { row.parked_at = new Date(); row.parked_by = params[1] ?? null; }
77
+ return { rows: [{ id: row.id, title: row.title, status: row.status }], rowCount: 1 };
78
+ }
79
+ throw new Error(`unexpected SQL in fake: ${sql.slice(0, 80)}`);
80
+ }
81
+ const client = { query, release() {} };
82
+ return { tasks, query, connect: async () => client };
83
+ }
84
+
85
+ const sweep = (pool) => dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
86
+
87
+ await test('control: an ordinary backlog row with nothing pending IS promoted', async () => {
88
+ // Without this the "still backlog" cases below could pass on a sweep that
89
+ // promotes nothing at all.
90
+ const pool = fakeDb([{ id: 1, title: 'waiting on a trigger', status: 'backlog', goal_id: 7 }]);
91
+ const out = await sweep(pool);
92
+ assert.deepEqual(out.promoted.map((r) => r.id), [1]);
93
+ assert.equal(pool.tasks.get(1).status, 'ready');
94
+ });
95
+
96
+ await test('restore, then the armed sweep: the task is still backlog', async () => {
97
+ const pool = fakeDb([{ id: 2, title: 'retired by mistake', status: 'abandoned', goal_id: 7 }]);
98
+ await dbShip.restoreTask({ taskId: 2, parkedBy: PERSON }, { pool });
99
+ assert.equal(pool.tasks.get(2).status, 'backlog');
100
+ assert.ok(pool.tasks.get(2).parked_at, 'restore must park the task');
101
+ assert.equal(pool.tasks.get(2).parked_by, PERSON, 'and record who parked it');
102
+ const out = await sweep(pool);
103
+ assert.deepEqual(out.candidates, [], 'a restored task is not a promotion candidate');
104
+ assert.equal(pool.tasks.get(2).status, 'backlog',
105
+ 'the sweep promoted a restored task: ADR 0152\'s second, explicit promote was skipped');
106
+ });
107
+
108
+ await test('demote, then the armed sweep: the task is still backlog', async () => {
109
+ const pool = fakeDb([{ id: 3, title: 'taken back off the board', status: 'ready', goal_id: 7 }]);
110
+ assert.equal(await dbTasks.parkTask(3, PERSON, pool), true, 'a ready task demotes');
111
+ assert.equal(pool.tasks.get(3).status, 'backlog');
112
+ assert.equal(pool.tasks.get(3).parked_by, PERSON);
113
+ await sweep(pool);
114
+ assert.equal(pool.tasks.get(3).status, 'backlog',
115
+ 'the sweep re-promoted a demoted task one tick later (task 1003397, 2026-10-06)');
116
+ });
117
+
118
+ await test('demote refuses a task that is no longer ready, and writes nothing', async () => {
119
+ const pool = fakeDb([{ id: 4, title: 'claimed meanwhile', status: 'active', goal_id: 7 }]);
120
+ assert.equal(await dbTasks.parkTask(4, PERSON, pool), false);
121
+ assert.equal(pool.tasks.get(4).status, 'active');
122
+ assert.equal(pool.tasks.get(4).parked_at, null);
123
+ });
124
+
125
+ await test('the sweep carries the parked clause in BOTH the read and the write', async () => {
126
+ const pool = fakeDb([{ id: 6, title: 'x', status: 'backlog', goal_id: 7 }]);
127
+ const seen = [];
128
+ const spy = { query: async (sql, p) => { seen.push(String(sql)); return pool.query(sql, p); } };
129
+ await sweep(spy);
130
+ assert.match(seen.find((s) => /SELECT/.test(s)), /t\.parked_at IS NULL/);
131
+ assert.match(seen.find((s) => /^\s*UPDATE/.test(s)), /t\.parked_at IS NULL/,
132
+ 'the UPDATE re-checks the guard rather than trusting the ids it read');
133
+ });
134
+
135
+ // ---------- the routes hand the person in ----------
136
+
137
+ const ROUTES = read('modules/lifecycle/routes/tasks.js');
138
+ const slice = (from, to) => ROUTES.slice(ROUTES.indexOf(from), ROUTES.indexOf(to, ROUTES.indexOf(from)));
139
+
140
+ await test('the restore branch of /promote parks with the caller', () => {
141
+ const promote = slice("router.post('/tasks/:id/promote'", "router.post('/tasks/:id/demote'");
142
+ assert.match(promote, /restoreTask\(\{ taskId: id, parkedBy: req\.builder\.id \}\)/);
143
+ });
144
+
145
+ await test('/demote parks with the caller instead of a bare status flip', () => {
146
+ const demote = slice("router.post('/tasks/:id/demote'", '// Phase 5 lifecycle transitions');
147
+ assert.match(demote, /db\.parkTask\(id, req\.builder\.id\)/);
148
+ assert.doesNotMatch(demote, /updateTaskStatus\(id, 'backlog'\)/,
149
+ 'a bare flip leaves no marker and the sweep undoes it');
150
+ });
151
+
152
+ // ---------- core_274: the triggers ----------
153
+
154
+ const MIG = read('migrations/core_274_parked_backlog_stays_parked.sql')
155
+ .replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
156
+ const fnBody = (name) => {
157
+ const start = MIG.indexOf(`CREATE OR REPLACE FUNCTION ${name}()`);
158
+ assert.notEqual(start, -1, `core_274 does not define ${name}`);
159
+ const rest = MIG.slice(start + 1);
160
+ const next = rest.indexOf('CREATE OR REPLACE FUNCTION');
161
+ return next === -1 ? rest : rest.slice(0, next);
162
+ };
163
+
164
+ await test('core_274 adds the two nullable marker columns, forward-safe', () => {
165
+ assert.match(MIG, /ADD COLUMN IF NOT EXISTS parked_at timestamptz;/);
166
+ assert.match(MIG, /ADD COLUMN IF NOT EXISTS parked_by bigint REFERENCES builders\(id\) ON DELETE SET NULL;/);
167
+ assert.doesNotMatch(MIG, /NOT NULL|DEFAULT/i, 'no default: an older release must never park anything');
168
+ });
169
+
170
+ await test('each promotion trigger refuses a parked row (a dependency shipping cannot free it)', () => {
171
+ for (const name of ['promote_dependents_on_ship', 'promote_dependents_on_criterion_satisfy', 'promote_dependents_on_goal_achieve']) {
172
+ assert.match(fnBody(name), /AND t\.parked_at IS NULL/, `${name} promotes a parked task`);
173
+ }
174
+ });
175
+
176
+ await test('leaving backlog un-parks, and only a real status change does', () => {
177
+ const fn = fnBody('clear_parked_on_leave_backlog');
178
+ assert.match(fn, /IF NEW\.status IS DISTINCT FROM 'backlog' THEN NEW\.parked_at := NULL; NEW\.parked_by := NULL;/);
179
+ assert.match(MIG, /BEFORE UPDATE OF status ON tasks FOR EACH ROW WHEN \(OLD\.status IS DISTINCT FROM NEW\.status\) EXECUTE FUNCTION clear_parked_on_leave_backlog\(\)/);
180
+ });
181
+
182
+ summary();
@@ -213,8 +213,9 @@ test('the manifest step acknowledges code_received, offers a restart after a sta
213
213
  // 'converting' is the same stage — it used to fall through to "waiting for approval" (task 1004572)
214
214
  assert.match(SCRIPT, /if \(live\.manifest === 'code_received' \|\| live\.manifest === 'converting'\)/, 'GitHub’s half is acknowledged');
215
215
  assert.match(SCRIPT, /Approved on GitHub\. The platform is placing the app’s keys on your project/);
216
- assert.match(SCRIPT, /manifestStale \? '<button type="button" class="linklike" data-action="manifest-start">/, 'a stalled wait gets a restart door');
217
- assert.match(SCRIPT, /\(Date\.now\(\) - manifestStartedAt\) > 90000/, 'after ~90 seconds');
216
+ // the way back is the SAME attempt, on show from the start — never a new link after 90s (task 1004652)
217
+ assert.match(SCRIPT, /data-action="reopen-github">No GitHub tab\? Reopen GitHub/, 'a waiting step can reopen GitHub');
218
+ assert.doesNotMatch(SCRIPT, /manifestStale|> 90000/, 'no 90-second restart door left');
218
219
  assert.match(SCRIPT, /function rememberManifest\(stateToken, status, appSlug\)/);
219
220
  assert.match(SCRIPT, /rememberManifest\(d\.state, 'pending'\)/, 'the token is remembered when GitHub is opened');
220
221
  assert.match(SCRIPT, /if \(created\.manifest\) \{ live\.manifest = created\.manifest; live\.appSlug = created\.app_slug \|\| null; \}/, 'enter() re-seeds the step');
@@ -33,7 +33,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
33
33
  const repoRoot = join(__dirname, '..');
34
34
  const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
35
35
 
36
- const MIG = 'core_250_cascade_requests_do_not_auto_promote.sql';
36
+ // core_274 (task 1004592) restated all three bodies to add the parked clause,
37
+ // so it is now the latest definition and must carry core_250's guard forward.
38
+ const MIG = 'core_274_parked_backlog_stays_parked.sql';
37
39
 
38
40
  // The three promotion triggers, by the state transition each reacts to.
39
41
  const TRIGGERS = [
@@ -59,7 +61,7 @@ function fnBody(name) {
59
61
 
60
62
  const { test, summary } = makeRunner();
61
63
 
62
- await test('core_250 redefines all three promotion triggers', () => {
64
+ await test('the latest migration redefines all three promotion triggers', () => {
63
65
  for (const name of TRIGGERS) {
64
66
  assert.match(body, new RegExp(`CREATE OR REPLACE FUNCTION ${name}\\(\\)`), `${name} is redefined`);
65
67
  }
@@ -99,7 +101,7 @@ await test('nothing else was weakened while adding the guard', () => {
99
101
  }
100
102
  });
101
103
 
102
- await test('core_250 is the LAST definition of each trigger in migrations/', () => {
104
+ await test('the latest migration is the LAST definition of each trigger in migrations/', () => {
103
105
  // The drift tripwire, and the assertion that would have caught migration 177
104
106
  // on the day it landed. A later whole-body redefinition inherits only what it
105
107
  // restates, so this fails the moment someone redefines one of these functions
@@ -29,6 +29,9 @@ provisioning.getOAuthManifestByState = async (_db, state) => (state === 'GOODSTA
29
29
  ? { id: 99, instance_id: 42, requested_by: 7, status: 'pending', expires_at: new Date(Date.now() + 600000).toISOString() }
30
30
  : null);
31
31
  provisioning.attachManifestCode = async (_db, id, code) => ({ id, status: 'code_received', github_code: code });
32
+ // the latest attempt the mint guard and the pick-up route read (task 1004652); none by default
33
+ let latest = null;
34
+ provisioning.getLatestOAuthManifestForInstance = async () => latest;
32
35
 
33
36
  // A branding with a known publicOrigin so mothershipOrigin() resolves deterministically.
34
37
  const branding = require(`${ROOT}/src/branding.js`);
@@ -281,6 +284,76 @@ console.log('\nGET /provisioning/github-app/status (the page poll → auto-open
281
284
  t('missing state → 400', res.status === 400, `status=${res.status}`);
282
285
  }
283
286
 
287
+ console.log('\nNever a second app (task 1004652): the mint guard and "Pick up where it stopped":');
288
+ {
289
+ const mint = async () => fetch(`${base}/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
290
+ const soon = new Date(Date.now() + 600000).toISOString();
291
+ // a link still waiting on GitHub is handed back — the SAME state, no new attempt row
292
+ stored = null;
293
+ latest = { id: 70, instance_id: 42, state: 'WAITINGSTATE', status: 'pending', attempts: 0, expires_at: soon, created_at: new Date().toISOString() };
294
+ let res = await mint();
295
+ let j = await res.json();
296
+ t('a waiting link is reopened, not re-minted: same state, same expiry, no new row', res.status === 200 && j.state === 'WAITINGSTATE' && j.expires_at === soon && stored === null && j.post_url === 'https://github.com/settings/apps/new?state=WAITINGSTATE', JSON.stringify(j));
297
+ // GitHub already made the app (keys placed, sign-in unconfirmed): refused, nothing minted
298
+ for (const row of [
299
+ { id: 71, instance_id: 42, state: 'S71', status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', expires_at: soon },
300
+ { id: 72, instance_id: 42, state: 'S72', status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', expires_at: soon },
301
+ { id: 73, instance_id: 42, state: 'S73', status: 'error', attempts: 2, app_id: null, app_slug: null, expires_at: soon },
302
+ ]) {
303
+ stored = null; latest = row;
304
+ res = await mint(); j = await res.json();
305
+ t(`an attempt GitHub approved (${row.status}, attempts ${row.attempts}) refuses a new link: 409 app_exists, nothing minted`, res.status === 409 && j.error.code === 'app_exists' && stored === null, JSON.stringify(j));
306
+ }
307
+ stored = null; latest = { id: 74, instance_id: 42, state: 'S74', status: 'converting', attempts: 1, expires_at: soon };
308
+ res = await mint(); j = await res.json();
309
+ t('an attempt the runner is converting refuses a new link: 409 github_in_progress', res.status === 409 && j.error.code === 'github_in_progress' && stored === null, JSON.stringify(j));
310
+ // nothing was made on GitHub: a fresh link is minted
311
+ for (const row of [
312
+ { id: 75, instance_id: 42, state: 'S75', status: 'pending', attempts: 0, expires_at: new Date(Date.now() - 1000).toISOString() },
313
+ { id: 76, instance_id: 42, state: 'S76', status: 'error', attempts: 0, expires_at: soon },
314
+ ]) {
315
+ stored = null; latest = row;
316
+ res = await mint(); j = await res.json();
317
+ t(`nothing made on GitHub (${row.status}, attempts ${row.attempts}) → a fresh state is minted`, res.status === 200 && stored && stored.state === j.state && j.state !== row.state, JSON.stringify(j));
318
+ }
319
+
320
+ // the pick-up route: 'convert' re-queues the SAME attempt with the code it holds
321
+ const queries = [];
322
+ const pg = require(`${ROOT}/src/module-api.js`).pool;
323
+ const prevQuery = pg.query;
324
+ pg.query = async (sql, params) => { queries.push({ sql, params }); return { rows: /UPDATE provisioning_oauth_manifests/.test(sql) ? [{ id: params[0] }] : [] }; };
325
+ const pickUp = async () => fetch(`${base}/provisioning/instances/42/github-app/pick-up`, { method: 'POST' });
326
+ latest = { id: 80, instance_id: 42, state: 'S80', status: 'error', attempts: 1, github_code: 'CODE', app_id: null, app_slug: null, created_at: new Date(Date.now() - 10 * 60000).toISOString(), expires_at: soon };
327
+ const before = reported.length;
328
+ stored = null;
329
+ res = await pickUp(); j = await res.json();
330
+ const requeue = queries.find((q) => /UPDATE provisioning_oauth_manifests/.test(q.sql));
331
+ t("pick-up 'convert' re-queues attempt 80 itself (status code_received, attempts reset), mints nothing", res.status === 200 && j.action === 'convert' && requeue && requeue.params[0] === 80 && /status = 'code_received', attempts = 0/.test(requeue.sql) && /github_code IS NOT NULL/.test(requeue.sql) && stored === null, JSON.stringify(j));
332
+ // 'check': keys placed, the runner's window closed → a check request, no new attempt
333
+ queries.length = 0;
334
+ latest = { id: 81, instance_id: 42, state: 'S81', status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: new Date(Date.now() - 5 * 3600000).toISOString(), resolved_at: new Date(Date.now() - 4 * 3600000).toISOString(), expires_at: soon };
335
+ res = await pickUp(); j = await res.json();
336
+ const asked = reported.slice(before).filter((e) => e.event === 'github-app-check-requested');
337
+ t("pick-up 'check' records one check request for the project and writes no attempt", res.status === 200 && j.action === 'check' && asked.length === 1 && asked[0].instanceId === 42 && !queries.some((q) => /UPDATE|INSERT/.test(q.sql)) && stored === null, JSON.stringify(j));
338
+ // nothing the route itself can pick up: 409 naming the page's own action
339
+ for (const [row, action] of [
340
+ [{ id: 82, instance_id: 42, state: 'S82', status: 'pending', attempts: 0, expires_at: soon }, 'reopen'],
341
+ [{ id: 83, instance_id: 42, state: 'S83', status: 'error', attempts: 2, github_code: null, app_id: null, created_at: new Date().toISOString(), expires_at: soon }, 'operator'],
342
+ ]) {
343
+ latest = row;
344
+ res = await pickUp(); j = await res.json();
345
+ t(`pick-up on '${action}' is the page's own: 409 nothing_to_pick_up, action named`, res.status === 409 && j.error.code === 'nothing_to_pick_up' && j.error.action === action, JSON.stringify(j));
346
+ }
347
+ // another builder's project is a 404, like a missing one
348
+ const prevInst = provisioning.getInstanceById;
349
+ provisioning.getInstanceById = async () => ({ id: 42, owner_builder_id: 8, slug: 'mercury', domain: 'mercury.example.com', status: 'active' });
350
+ res = await pickUp();
351
+ t('pick-up on someone else’s project → 404', res.status === 404, `status=${res.status}`);
352
+ provisioning.getInstanceById = prevInst;
353
+ pg.query = prevQuery;
354
+ latest = null;
355
+ }
356
+
284
357
  console.log('\nparseId contract (the (req,res) signature — a bad id is a clean 400, not a 500):');
285
358
  {
286
359
  const res = await fetch(`${base}/provisioning/instances/not-a-number/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });