@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.
- package/.bongos-core.json +62 -47
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0152-metic-task-abandonment.md +2 -0
- package/docs/api/openapi.json +52 -5
- package/docs/api-reference.md +3 -2
- package/docs/architecture.md +3 -0
- package/docs/copy-inventory.md +395 -393
- package/docs/copy-registry.json +486 -468
- package/docs/design/project-startup-direction.md +13 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +260 -260
- package/migrations/core_274_parked_backlog_stays_parked.sql +140 -0
- package/modules/hall-ui/public/studio-lib.js +22 -0
- package/modules/hall-ui/public/studio.css +138 -7
- package/modules/hall-ui/public/studio.html +10 -4
- package/modules/hall-ui/public/studio.js +39 -1
- package/modules/hall-ui/records/studio.md +16 -0
- package/modules/lifecycle/db-ship.js +7 -2
- package/modules/lifecycle/db-tasks.js +25 -0
- package/modules/lifecycle/routes/tasks.js +5 -5
- package/modules/provisioning/github-setup.js +73 -4
- package/modules/provisioning/routes/github-setup.js +43 -1
- package/modules/provisioning/routes/provisioning.js +8 -4
- package/modules/public-landing/public/projects.html +185 -30
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/provision-github-setup.js +29 -9
- package/scripts/gds/provision.js +20 -9
- package/src/module-api.js +1 -1
- package/tests/abandon_task.mjs +1 -1
- package/tests/github_one_trip.mjs +4 -4
- package/tests/github_org_account.mjs +3 -2
- package/tests/github_pick_up.mjs +366 -0
- package/tests/hall_studio_home.mjs +140 -5
- package/tests/parked_backlog_stays_parked.mjs +182 -0
- package/tests/projects_hub_pre_uat.mjs +3 -2
- package/tests/promotion_triggers_skip_cascade.mjs +5 -3
- package/tests/provisioning_github_app_routes.mjs +73 -0
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
assert.match(SCRIPT,
|
|
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
|
-
|
|
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('
|
|
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('
|
|
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: '{}' });
|