@bongos/core 1.19.1042 → 1.19.1044
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 +64 -39
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +129 -3
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +38 -38
- package/docs/copy-registry.json +41 -41
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +4 -1
- package/migrations/core_256_blocker_unlink_releases_task.sql +84 -0
- package/modules/hall-ui/public/style.css +4 -0
- package/modules/hall-ui/public/task.css +6 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/task.js +17 -0
- package/modules/hall-ui/records/record-family.md +2 -0
- package/modules/ideas/blockers.js +60 -0
- package/modules/ideas/routes/blockers.js +41 -0
- package/modules/npm-release/module.json +8 -2
- package/modules/npm-release/public/task-widget.js +79 -0
- package/modules/npm-release/routes/task-where.js +48 -0
- package/modules/npm-release/work.js +70 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/exec-path-guard.js +1 -1
- package/scripts/gds/seam-wiring-guard.js +4 -2
- package/scripts/hall-preview/server.js +14 -1
- package/src/bongos/serve-internal.js +18 -4
- package/src/module-api.js +1 -1
- package/src/module-loader/loader.js +13 -3
- package/tests/blocker_unlink.mjs +251 -0
- package/tests/hall_record_world.mjs +23 -1
- package/tests/module_loader.mjs +27 -0
- package/tests/npm_release_where.mjs +323 -0
package/release-notes.json
CHANGED
|
@@ -7553,5 +7553,17 @@
|
|
|
7553
7553
|
"id": "1001433",
|
|
7554
7554
|
"text": "The nightly security check of the main code line no longer trusts the name typed on each change, which anyone can fake. It now checks that every change to a sensitive file carries the server's own sign-off from a senior builde"
|
|
7555
7555
|
}
|
|
7556
|
+
],
|
|
7557
|
+
"1.19.1043": [
|
|
7558
|
+
{
|
|
7559
|
+
"id": "1004301",
|
|
7560
|
+
"text": "A task's own page now says where its code is — which version carries it, whether it is running on Bongos, whether it is released — and says plainly when a task marked done never actually landed."
|
|
7561
|
+
}
|
|
7562
|
+
],
|
|
7563
|
+
"1.19.1044": [
|
|
7564
|
+
{
|
|
7565
|
+
"id": "1003518",
|
|
7566
|
+
"text": "A blocker that was linked to the wrong task — a duplicate, or a mistake — can now be unlinked, and the task goes back to being claimable if nothing else was holding it. Before this the only way out was to mark the blocker reso"
|
|
7567
|
+
}
|
|
7556
7568
|
]
|
|
7557
7569
|
}
|
|
@@ -103,7 +103,7 @@ const EXEMPTIONS = [
|
|
|
103
103
|
+ 'absorbed into this check.',
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
|
-
at: 'src/module-loader/loader.js:
|
|
106
|
+
at: 'src/module-loader/loader.js:309',
|
|
107
107
|
why: 'The poller twin of the route require at :196 — same DEFAULT_ROOTS bound, same plugin-by-'
|
|
108
108
|
+ 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
|
|
109
109
|
+ 'change to either call re-opens only that one.',
|
|
@@ -95,11 +95,13 @@ const SEAM_CONSUMERS = {
|
|
|
95
95
|
artifact: (dir, entry) => path.join(dir, 'rooms', `${entry}.js`),
|
|
96
96
|
},
|
|
97
97
|
uiSections: {
|
|
98
|
-
// Per-MODULE, not per-entry: hallWidgetScripts looks for one
|
|
99
|
-
// however many sections the module names.
|
|
98
|
+
// Per-MODULE, not per-entry: hallWidgetScripts looks for one widget file per page
|
|
99
|
+
// however many sections the module names — hall-widget.js for the index, and since
|
|
100
|
+
// task 1004301 task-widget.js for a task's page. Either one wires the declaration.
|
|
100
101
|
consumer: 'src/module-loader/loader.js hallWidgetScripts',
|
|
101
102
|
kind: 'file',
|
|
102
103
|
artifact: (dir) => path.join(dir, 'public', 'hall-widget.js'),
|
|
104
|
+
alternates: (dir) => [path.join(dir, 'public', 'task-widget.js')],
|
|
103
105
|
},
|
|
104
106
|
skills: {
|
|
105
107
|
// moduleSkillSources resolves a declared name against the module's OWN tree or
|
|
@@ -77,7 +77,8 @@ let express;
|
|
|
77
77
|
try { express = require(path.join(ROOT, 'node_modules', 'express')); }
|
|
78
78
|
catch (_) { express = require('express'); }
|
|
79
79
|
const { buildStampedHtml } = require(path.join(ROOT, 'src', 'asset-version.js'));
|
|
80
|
-
const { injectBrandingGlobal, mountInternalSurfaces } = require(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'));
|
|
80
|
+
const { injectBrandingGlobal, mountInternalSurfaces, injectModuleHallWidgets } = require(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'));
|
|
81
|
+
const { hallWidgetScripts } = require(path.join(ROOT, 'src', 'module-loader', 'loader.js'));
|
|
81
82
|
const { clientModules } = require(path.join(ROOT, 'src', 'modules.js'));
|
|
82
83
|
const { overrideKey, resolveLiveIdentity } = require('./live-identity');
|
|
83
84
|
|
|
@@ -128,6 +129,9 @@ const FIXTURE_FALLBACKS = [
|
|
|
128
129
|
// without it /deploy?project=… only ever rendered its "could not read" state, which
|
|
129
130
|
// is the trap this directory's README calls out by name.
|
|
130
131
|
[/^provisioning\/instances\/\d+\/core-upgrade$/, 'provisioning__instances__core-upgrade'],
|
|
132
|
+
// the npm-release module's line on a task's page (task 1004301): one reading serves any
|
|
133
|
+
// id, as the core-upgrade preview above does — the page shows one task.
|
|
134
|
+
[/^npm-release\/task\/\d+$/, 'npm-release__task'],
|
|
131
135
|
];
|
|
132
136
|
|
|
133
137
|
// A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
|
|
@@ -283,6 +287,15 @@ for (const key of MODULES_OFF) {
|
|
|
283
287
|
for (const key of MODULES_ON) {
|
|
284
288
|
(mods.enabled = mods.enabled || {})[key] = true;
|
|
285
289
|
}
|
|
290
|
+
// Module page widgets, injected at boot exactly as serve-internal.js does for prod: the
|
|
291
|
+
// hall index's hall-widget.js and a task page's task-widget.js (task 1004301), for the
|
|
292
|
+
// modules THIS harness runs with. Without it a module's line on a page could never be
|
|
293
|
+
// previewed — the page would only ever render its empty slot.
|
|
294
|
+
{
|
|
295
|
+
const isOn = (k) => !!(mods.enabled && mods.enabled[k]);
|
|
296
|
+
if (stamped['index.html']) stamped['index.html'] = injectModuleHallWidgets(stamped['index.html'], hallWidgetScripts({ isEnabled: isOn }));
|
|
297
|
+
if (stamped['task.html']) stamped['task.html'] = injectModuleHallWidgets(stamped['task.html'], hallWidgetScripts({ isEnabled: isOn, file: 'task-widget.js' }), 'task');
|
|
298
|
+
}
|
|
286
299
|
|
|
287
300
|
function sendPage(res, name) {
|
|
288
301
|
const html = stamped[name];
|
|
@@ -443,14 +443,17 @@ function clientBundleScriptTag() {
|
|
|
443
443
|
// module's widget is never injected (it vanishes); contributeHallWidget is the second
|
|
444
444
|
// gate (it no-ops a widget whose module is off). `scripts` is the loader projection
|
|
445
445
|
// [{ key, src }]; default reads the live enabled set. Pure + exported for the test.
|
|
446
|
-
function injectModuleHallWidgets(html, scripts = hallWidgetScripts({ isEnabled: isModuleEnabled })) {
|
|
446
|
+
function injectModuleHallWidgets(html, scripts = hallWidgetScripts({ isEnabled: isModuleEnabled }), consumer = 'builders') {
|
|
447
447
|
if (!Array.isArray(scripts) || scripts.length === 0) return html;
|
|
448
448
|
const tags = scripts
|
|
449
449
|
.map((s) => `<script src="${s.src}" defer></script>`)
|
|
450
450
|
.join('');
|
|
451
|
-
// Anchor on
|
|
452
|
-
//
|
|
453
|
-
|
|
451
|
+
// Anchor on the consuming page script's <script> (builders.js on the index, task.js on
|
|
452
|
+
// a task's page — task 1004301) regardless of its ?v= stamp, so the module widgets
|
|
453
|
+
// always land immediately before the consumer: both are `defer`, so they run in
|
|
454
|
+
// document order, and a widget's listener exists before the page first announces.
|
|
455
|
+
const name = /^[a-z-]+$/.test(consumer) ? consumer : 'builders';
|
|
456
|
+
const anchor = html.match(new RegExp(`<script src="\\/builders\\/${name}\\.js[^"]*"[^>]*><\\/script>`));
|
|
454
457
|
if (anchor) return html.replace(anchor[0], tags + anchor[0]);
|
|
455
458
|
return html.includes('</body>') ? html.replace('</body>', `${tags}</body>`) : html + tags;
|
|
456
459
|
}
|
|
@@ -1070,6 +1073,17 @@ function mountInternalSurfaces(app) {
|
|
|
1070
1073
|
if (buildersHtml['index.html']) {
|
|
1071
1074
|
buildersHtml['index.html'] = injectModuleHallWidgets(buildersHtml['index.html']);
|
|
1072
1075
|
}
|
|
1076
|
+
// task 1004301: the same seam for a task's own page. A module that ships
|
|
1077
|
+
// public/task-widget.js is loaded just before task.js, and fills the slot task.js
|
|
1078
|
+
// renders and announces (`otb:task-shown`). The page names no module; with none
|
|
1079
|
+
// enabled, nothing is injected and the slot stays empty.
|
|
1080
|
+
if (buildersHtml['task.html']) {
|
|
1081
|
+
buildersHtml['task.html'] = injectModuleHallWidgets(
|
|
1082
|
+
buildersHtml['task.html'],
|
|
1083
|
+
hallWidgetScripts({ isEnabled: isModuleEnabled, file: 'task-widget.js' }),
|
|
1084
|
+
'task',
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1073
1087
|
|
|
1074
1088
|
// Serve enabled modules' browser assets at /modules/<key>/public/* — the URL the
|
|
1075
1089
|
// loader's hallWidgetScripts projection points at. Mounted BEFORE the /builders
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.1044'; // 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');
|
|
@@ -213,15 +213,25 @@ function mountModuleRoutes(router, { isEnabled } = {}, opts) {
|
|
|
213
213
|
// never injected), and contributeHallWidget itself no-ops a stale widget whose module
|
|
214
214
|
// is off. No-op (empty list) until a module ships a hall widget — same SAFE-BY-
|
|
215
215
|
// CONSTRUCTION posture as every other seam here. Returns [{ key, src }].
|
|
216
|
-
|
|
216
|
+
//
|
|
217
|
+
// ONE SEAM, ONE FILE PER PAGE (task 1004301). `file` names which page's widget is being
|
|
218
|
+
// asked for: hall-widget.js for the hall index (the default, and the only caller until
|
|
219
|
+
// then), task-widget.js for a task's own page. Same declaration (uiSections), same
|
|
220
|
+
// enabled-only asset mount, same "a disabled module simply is not there" — so a module
|
|
221
|
+
// adds a line to the task page without the page's own code ever naming it. The name is
|
|
222
|
+
// matched against PAGE_WIDGET_FILES rather than trusted, so no caller can turn this into
|
|
223
|
+
// a way to serve an arbitrary file.
|
|
224
|
+
const PAGE_WIDGET_FILES = Object.freeze(['hall-widget.js', 'task-widget.js']);
|
|
225
|
+
function hallWidgetScripts({ isEnabled, file = 'hall-widget.js' } = {}, opts) {
|
|
226
|
+
if (!PAGE_WIDGET_FILES.includes(file)) return [];
|
|
217
227
|
const out = [];
|
|
218
228
|
for (const { key, dir, manifest } of discovered(opts).loaded) {
|
|
219
229
|
if (typeof isEnabled === 'function' && !isEnabled(key)) continue;
|
|
220
230
|
const sections = (manifest.contributes && manifest.contributes.uiSections) || [];
|
|
221
231
|
if (sections.length === 0) continue;
|
|
222
|
-
const scriptPath = path.join(dir, 'public',
|
|
232
|
+
const scriptPath = path.join(dir, 'public', file);
|
|
223
233
|
if (!fs.existsSync(scriptPath)) continue; // declarative-only uiSections (e.g. status-page section)
|
|
224
|
-
out.push({ key, src: `/modules/${key}/public
|
|
234
|
+
out.push({ key, src: `/modules/${key}/public/${file}` });
|
|
225
235
|
}
|
|
226
236
|
return out;
|
|
227
237
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// tests/blocker_unlink.mjs
|
|
2
|
+
//
|
|
3
|
+
// Task 1003518 — a blocker can be UNLINKED from a task. Linking is one-way and
|
|
4
|
+
// Metic+, and post-core_223 a task is released only when its LAST open blocker
|
|
5
|
+
// resolves, so a wrong link (wrong task id, a duplicate filed twice, a blocker
|
|
6
|
+
// later understood to be irrelevant) was permanent: the only escape was to
|
|
7
|
+
// resolve a blocker that was never real, putting a falsehood in the ledger.
|
|
8
|
+
//
|
|
9
|
+
// TWO HALVES, mirroring tests/blocker_resolve_guard.mjs:
|
|
10
|
+
// 1. the JS is an OBSERVER — it deletes and reads back, it never promotes;
|
|
11
|
+
// 2. the promotion itself is core_256's trigger, pinned as SQL TEXT so a
|
|
12
|
+
// later edit cannot quietly drop the guard or "simplify" it into a bug.
|
|
13
|
+
//
|
|
14
|
+
// The JS half is behavioural, not shape assertions. The module-level `pool` is
|
|
15
|
+
// replaced through require.cache BEFORE blockers.js loads, so the default
|
|
16
|
+
// (zero-injection) path is the one under test — the shape a `deps.pool || pool`
|
|
17
|
+
// shadowing bug would break while a harness that always injects stayed green.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/blocker_unlink.mjs
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const repoRoot = join(__dirname, '..');
|
|
30
|
+
const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const { test, summary } = makeRunner();
|
|
33
|
+
|
|
34
|
+
// -- the fake pool, installed as the module's OWN pool ----------------------
|
|
35
|
+
|
|
36
|
+
let current = null; // the pool the stubbed module-api hands out this test
|
|
37
|
+
let releases = 0; // how many times client.release() was called
|
|
38
|
+
|
|
39
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
40
|
+
require.cache[apiPath] = {
|
|
41
|
+
id: apiPath,
|
|
42
|
+
filename: apiPath,
|
|
43
|
+
loaded: true,
|
|
44
|
+
exports: { pool: { connect: (...a) => current.connect(...a) } },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const blockers = require('../modules/ideas/blockers.js');
|
|
48
|
+
|
|
49
|
+
// before/after: the task's status either side of the DELETE (the trigger is what
|
|
50
|
+
// changes it in production, so the fake simply reports what it would have done).
|
|
51
|
+
// deleted: rows the DELETE returns (0 = there was no such link).
|
|
52
|
+
// failOn: a substring; the first matching statement throws.
|
|
53
|
+
function scenario({ before = 'blocked', after = 'ready', deleted = 1, failOn = null } = {}) {
|
|
54
|
+
let seenDelete = false;
|
|
55
|
+
const pool = makeSqlAwareClient((sql) => {
|
|
56
|
+
if (failOn && sql.includes(failOn)) throw new Error('boom');
|
|
57
|
+
if (/^DELETE FROM task_blockers/.test(sql)) {
|
|
58
|
+
seenDelete = true;
|
|
59
|
+
return { rows: Array.from({ length: deleted }, () => ({ task_id: 7 })) };
|
|
60
|
+
}
|
|
61
|
+
if (/^SELECT status FROM tasks/.test(sql)) {
|
|
62
|
+
return { rows: [{ status: seenDelete ? after : before }] };
|
|
63
|
+
}
|
|
64
|
+
return { rows: [] };
|
|
65
|
+
});
|
|
66
|
+
const realRelease = pool._client.release.bind(pool._client);
|
|
67
|
+
pool._client.release = () => { releases += 1; realRelease(); };
|
|
68
|
+
current = pool;
|
|
69
|
+
releases = 0;
|
|
70
|
+
return pool;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sqlOf = (pool) => pool.queries.map((q) => String(q.sql).replace(/\s+/g, ' ').trim());
|
|
74
|
+
const unlink = (opts) => blockers.unlinkBlockerFromTask({ blockerId: 93, taskId: 7 }, opts);
|
|
75
|
+
|
|
76
|
+
// == 1. the JS observes =====================================================
|
|
77
|
+
|
|
78
|
+
await test('the link row is deleted, keyed on BOTH ids', async () => {
|
|
79
|
+
const pool = scenario();
|
|
80
|
+
await unlink();
|
|
81
|
+
const del = pool.queries.find((q) => /DELETE FROM task_blockers/.test(q.sql));
|
|
82
|
+
assert.ok(del, 'no DELETE was issued');
|
|
83
|
+
// Order matters: the SQL is (task_id, blocker_id), so a swap deletes a
|
|
84
|
+
// different edge — or silently none.
|
|
85
|
+
assert.deepEqual(del.params, [7, 93]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await test('the JS never promotes — that is core_256s job', async () => {
|
|
89
|
+
// The same assertion blocker_resolve_guard.mjs makes about the resolve path,
|
|
90
|
+
// restated for unlink so the next person to "just fix it here" trips a test.
|
|
91
|
+
const src = read('modules', 'ideas', 'blockers.js');
|
|
92
|
+
assert.doesNotMatch(src, /UPDATE\s+tasks[\s\S]{0,200}?status\s*=\s*'ready'/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await test('the whole unlink is one transaction', async () => {
|
|
96
|
+
const pool = scenario();
|
|
97
|
+
await unlink();
|
|
98
|
+
const s = sqlOf(pool);
|
|
99
|
+
assert.equal(s[0], 'BEGIN');
|
|
100
|
+
assert.equal(s[s.length - 1], 'COMMIT');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('the before-status is read FOR UPDATE so the read-back cannot be raced', async () => {
|
|
104
|
+
const pool = scenario();
|
|
105
|
+
await unlink();
|
|
106
|
+
const s = sqlOf(pool);
|
|
107
|
+
const first = s.find((q) => q.startsWith('SELECT status FROM tasks'));
|
|
108
|
+
assert.match(first, /FOR UPDATE/);
|
|
109
|
+
// …and it is read BEFORE the delete, or "did this unlink move it?" is unanswerable.
|
|
110
|
+
assert.ok(s.indexOf(first) < s.findIndex((q) => q.startsWith('DELETE FROM task_blockers')));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await test('released:true when the task went blocked -> ready', async () => {
|
|
114
|
+
scenario({ before: 'blocked', after: 'ready' });
|
|
115
|
+
assert.deepEqual(await unlink(), { unlinked: true, released: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('released:false while another blocker is still open', async () => {
|
|
119
|
+
// The trigger's NOT EXISTS fails, so the task stays blocked. The unlink still
|
|
120
|
+
// happened — but saying released:true here is the task-1002559 lie.
|
|
121
|
+
scenario({ before: 'blocked', after: 'blocked' });
|
|
122
|
+
assert.deepEqual(await unlink(), { unlinked: true, released: false });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await test('released:false when the task was never blocked in the first place', async () => {
|
|
126
|
+
// A link can land on a task that LINK_BLOCKABLE_STATUSES never parks (active,
|
|
127
|
+
// backlog, shipped). Reporting "released" for one of those would claim a
|
|
128
|
+
// promotion that never happened — and would be exactly what a naive
|
|
129
|
+
// "is it ready now?" read-back returns.
|
|
130
|
+
scenario({ before: 'active', after: 'active' });
|
|
131
|
+
assert.deepEqual(await unlink(), { unlinked: true, released: false });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await test('released:false for a task that was ALREADY ready before the unlink', async () => {
|
|
135
|
+
// THE DISCRIMINATING CASE. A legacy blocker_ref link, or one that landed while
|
|
136
|
+
// the task was not blockable, leaves a 'ready' task carrying a link. Removing
|
|
137
|
+
// it changes nothing — but a naive "is it ready NOW?" read-back reports a
|
|
138
|
+
// release that never happened. Only comparing against the BEFORE status can
|
|
139
|
+
// tell the two apart, which is why that status is read at all.
|
|
140
|
+
scenario({ before: 'ready', after: 'ready' });
|
|
141
|
+
assert.deepEqual(await unlink(), { unlinked: true, released: false });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await test('a link that is not there refuses with LINK_NOT_FOUND and rolls back', async () => {
|
|
145
|
+
const pool = scenario({ deleted: 0 });
|
|
146
|
+
await assert.rejects(() => unlink(), (e) => e.code === 'LINK_NOT_FOUND');
|
|
147
|
+
const s = sqlOf(pool);
|
|
148
|
+
assert.ok(s.includes('ROLLBACK'), 'must roll back');
|
|
149
|
+
assert.ok(!s.includes('COMMIT'), 'must not commit');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await test('an unexpected failure rolls back exactly once', async () => {
|
|
153
|
+
const pool = scenario({ failOn: 'DELETE FROM task_blockers' });
|
|
154
|
+
await assert.rejects(() => unlink());
|
|
155
|
+
const s = sqlOf(pool);
|
|
156
|
+
assert.equal(s.filter((q) => q === 'ROLLBACK').length, 1);
|
|
157
|
+
assert.ok(!s.includes('COMMIT'));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
await test('the client is returned to the pool on EVERY path', async () => {
|
|
161
|
+
scenario();
|
|
162
|
+
await unlink();
|
|
163
|
+
assert.equal(releases, 1, 'success path');
|
|
164
|
+
|
|
165
|
+
scenario({ deleted: 0 });
|
|
166
|
+
await assert.rejects(() => unlink());
|
|
167
|
+
assert.equal(releases, 1, 'refusal path');
|
|
168
|
+
|
|
169
|
+
scenario({ failOn: 'DELETE FROM task_blockers' });
|
|
170
|
+
await assert.rejects(() => unlink());
|
|
171
|
+
assert.equal(releases, 1, 'error path');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await test('an injected pool overrides the module-level one', async () => {
|
|
175
|
+
scenario({ before: 'active', after: 'active' }); // module-level: would say false
|
|
176
|
+
let sawDelete = false;
|
|
177
|
+
const other = makeSqlAwareClient((sql) => {
|
|
178
|
+
if (/^DELETE FROM task_blockers/.test(sql)) { sawDelete = true; return { rows: [{ task_id: 7 }] }; }
|
|
179
|
+
if (/^SELECT status FROM tasks/.test(sql)) return { rows: [{ status: sawDelete ? 'ready' : 'blocked' }] };
|
|
180
|
+
return { rows: [] };
|
|
181
|
+
});
|
|
182
|
+
assert.equal((await unlink({ pool: other })).released, true, 'the injected pool must be the one used');
|
|
183
|
+
assert.equal(current.queries.length, 0, 'the module-level pool saw none');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// == 2. core_256 — the trigger that actually releases =======================
|
|
187
|
+
|
|
188
|
+
const MIG = 'core_256_blocker_unlink_releases_task.sql';
|
|
189
|
+
const mig = read('migrations', MIG);
|
|
190
|
+
const migN = mig.replace(/\s+/g, ' ');
|
|
191
|
+
// Comments carry the rationale; strip them when asserting on the executable
|
|
192
|
+
// body, so a claim in prose can never stand in for the real SQL.
|
|
193
|
+
const body = mig.replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
|
|
194
|
+
|
|
195
|
+
await test('256 installs an AFTER DELETE row trigger on task_blockers', () => {
|
|
196
|
+
assert.match(body, /CREATE OR REPLACE FUNCTION trg_release_task_on_blocker_unlink\(\)/);
|
|
197
|
+
assert.match(body, /AFTER DELETE ON task_blockers/);
|
|
198
|
+
assert.match(body, /FOR EACH ROW/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await test('the release targets ONLY a blocked task', () => {
|
|
202
|
+
assert.match(body, /UPDATE tasks SET status = 'ready'/);
|
|
203
|
+
assert.match(body, /WHERE id = OLD\.task_id/);
|
|
204
|
+
// Without this, an unlink would launder any task straight to 'ready' — past
|
|
205
|
+
// the metic+archon promote gate.
|
|
206
|
+
assert.match(body, /AND status = 'blocked'/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await test("core_223's NOT EXISTS over the task's remaining open blockers is carried", () => {
|
|
210
|
+
assert.match(body, /AND NOT EXISTS \( SELECT 1 FROM task_blockers tb JOIN blockers b ON b\.id = tb\.blocker_id/);
|
|
211
|
+
assert.match(body, /WHERE tb\.task_id = OLD\.task_id/);
|
|
212
|
+
assert.match(body, /AND b\.status = 'open'/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await test("and deliberately has NO `b.id <> …` exclusion — AFTER DELETE, the row is gone", () => {
|
|
216
|
+
// core_223 needs that clause because it runs BEFORE UPDATE, where the blocker
|
|
217
|
+
// being resolved still reads 'open' and would count itself, stalling forever.
|
|
218
|
+
// Here the link row is already deleted when the body runs, so the NOT EXISTS
|
|
219
|
+
// sees only what remains. Copying the clause across would be cargo-cult.
|
|
220
|
+
assert.doesNotMatch(body, /b\.id <>/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await test('256 does NOT also gate on dependencies — that would strand the task', () => {
|
|
224
|
+
// The dep-promotion triggers (163) only flip backlog -> ready, so a task held
|
|
225
|
+
// in 'blocked' for unshipped deps would have no release path at all.
|
|
226
|
+
assert.doesNotMatch(body, /task_is_fully_unblocked/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await test('256 is transactional and re-appliable', () => {
|
|
230
|
+
assert.match(migN, /BEGIN;.*COMMIT;/);
|
|
231
|
+
// A trigger has no CREATE OR REPLACE before PG 14, so the drop is what makes
|
|
232
|
+
// a re-run clean rather than a duplicate-object error.
|
|
233
|
+
assert.match(body, /DROP TRIGGER IF EXISTS release_task_on_blocker_unlink ON task_blockers/);
|
|
234
|
+
assert.doesNotMatch(body, /DROP FUNCTION/i);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await test('256 is the ONLY definition of the unlink-release function', () => {
|
|
238
|
+
const defs = readdirSync(join(repoRoot, 'migrations'))
|
|
239
|
+
.filter((f) => f.endsWith('.sql'))
|
|
240
|
+
.filter((f) => /FUNCTION trg_release_task_on_blocker_unlink/.test(read('migrations', f)))
|
|
241
|
+
.sort();
|
|
242
|
+
// migrate.sh applies in filename order, so a second, lower-numbered
|
|
243
|
+
// definition landing later would silently reinstate whatever it said.
|
|
244
|
+
assert.deepEqual(defs, [MIG]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await test('unlinkBlockerFromTask is exported', () => {
|
|
248
|
+
assert.equal(typeof blockers.unlinkBlockerFromTask, 'function');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
summary();
|
|
@@ -305,6 +305,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
305
305
|
const requests = [];
|
|
306
306
|
const writes = [];
|
|
307
307
|
const toasts = [];
|
|
308
|
+
const dispatched = []; // task 1004301: what the page announced (otb:task-shown)
|
|
308
309
|
const els = new Map();
|
|
309
310
|
const makeEl = () => ({
|
|
310
311
|
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
|
|
@@ -326,6 +327,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
326
327
|
querySelectorAll() { return []; },
|
|
327
328
|
createElement() { return makeEl(); },
|
|
328
329
|
addEventListener(type, fn) { if (type === 'DOMContentLoaded') onReady = fn; },
|
|
330
|
+
dispatchEvent(e) { dispatched.push(e); return true; },
|
|
329
331
|
};
|
|
330
332
|
const client = {
|
|
331
333
|
request: async (method, p, opts) => {
|
|
@@ -393,7 +395,8 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
393
395
|
confirm: () => true,
|
|
394
396
|
navigator: { clipboard: { writeText: async () => {} } },
|
|
395
397
|
};
|
|
396
|
-
|
|
398
|
+
class CustomEvent { constructor(type, init) { this.type = type; this.detail = init && init.detail; } }
|
|
399
|
+
const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Date, URL, URLSearchParams, CustomEvent };
|
|
397
400
|
sandbox.globalThis = sandbox;
|
|
398
401
|
vm.createContext(sandbox);
|
|
399
402
|
if (withReader) vm.runInContext(MD_SRC, sandbox, { filename: 'md-reader.js' });
|
|
@@ -407,6 +410,8 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
|
|
|
407
410
|
requests,
|
|
408
411
|
writes,
|
|
409
412
|
toasts,
|
|
413
|
+
dispatched,
|
|
414
|
+
win: windowObj,
|
|
410
415
|
};
|
|
411
416
|
}
|
|
412
417
|
|
|
@@ -1052,3 +1057,20 @@ test("a move-to-goal refusal (403) surfaces the server's own sentence, verbatim"
|
|
|
1052
1057
|
`the server's own sentence must reach the toast, not a generic fallback — got: ${r.toasts.join(' | ')}`);
|
|
1053
1058
|
assert.equal(task.goal_id, 1000071, 'a refused PATCH must not move the task locally');
|
|
1054
1059
|
});
|
|
1060
|
+
|
|
1061
|
+
// task 1004301 — a slot a MODULE may fill, and the announcement that tells it which task.
|
|
1062
|
+
// The page names no module: it draws an empty #task-contrib under the lifecycle rail and
|
|
1063
|
+
// announces each render, and a module's task-widget.js does the rest.
|
|
1064
|
+
test('the task page renders an empty module slot and announces every render', async () => {
|
|
1065
|
+
const r = renderTask({ ...BASE_TASK });
|
|
1066
|
+
r.boot();
|
|
1067
|
+
for (let i = 0; i < 5; i++) await settle();
|
|
1068
|
+
const html = r.html();
|
|
1069
|
+
assert.match(html, /<div class="tk-contrib" id="task-contrib" aria-live="polite"><\/div>/, 'an empty slot');
|
|
1070
|
+
assert.ok(html.indexOf('id="task-contrib"') > html.indexOf('class="tk-rail"'), 'under the lifecycle rail');
|
|
1071
|
+
const shown = r.dispatched.filter((e) => e.type === 'otb:task-shown');
|
|
1072
|
+
assert.equal(shown.length, 1);
|
|
1073
|
+
assert.deepEqual({ ...shown[0].detail }, { id: '1003305', status: 'shipped' }, 'only the id and the status travel');
|
|
1074
|
+
assert.deepEqual({ ...r.win.OTBTaskShown }, { id: '1003305', status: 'shipped' }, 'left on window for a widget that loads late');
|
|
1075
|
+
assert.doesNotMatch(read('task.js'), /npm-release/, 'the core page names no module');
|
|
1076
|
+
});
|
package/tests/module_loader.mjs
CHANGED
|
@@ -330,6 +330,33 @@ test('hallWidgetScripts: a module with no uiSections is never listed', () => {
|
|
|
330
330
|
loader._reset();
|
|
331
331
|
});
|
|
332
332
|
|
|
333
|
+
// task 1004301 — the same seam, one file per page: task-widget.js for a task's own page.
|
|
334
|
+
test('hallWidgetScripts: file task-widget.js lists the modules that ship one, and only those', () => {
|
|
335
|
+
loader._reset();
|
|
336
|
+
const root = fixtureRoot({
|
|
337
|
+
alpha: { manifest: validManifest('alpha', { contributes: { uiSections: ['a'] } }), files: { 'public/task-widget.js': '// task line' } },
|
|
338
|
+
beta: { manifest: validManifest('beta', { contributes: { uiSections: ['b'] } }), files: { 'public/hall-widget.js': '// index only' } },
|
|
339
|
+
});
|
|
340
|
+
const opts = { roots: [root], core: '1.0.0' };
|
|
341
|
+
assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true, file: 'task-widget.js' }, opts),
|
|
342
|
+
[{ key: 'alpha', src: '/modules/alpha/public/task-widget.js' }]);
|
|
343
|
+
assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true }, opts),
|
|
344
|
+
[{ key: 'beta', src: '/modules/beta/public/hall-widget.js' }], 'the default is still the index widget');
|
|
345
|
+
assert.deepEqual(loader.hallWidgetScripts({ isEnabled: (k) => k !== 'alpha', file: 'task-widget.js' }, opts), [], 'disabled: never listed');
|
|
346
|
+
loader._reset();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('hallWidgetScripts: a file name outside the page allowlist lists nothing — it is not a file server', () => {
|
|
350
|
+
loader._reset();
|
|
351
|
+
const root = fixtureRoot({
|
|
352
|
+
alpha: { manifest: validManifest('alpha', { contributes: { uiSections: ['a'] } }), files: { 'public/secrets.js': '//', 'public/task-widget.js': '//' } },
|
|
353
|
+
});
|
|
354
|
+
for (const file of ['secrets.js', '../module.json', 'public/task-widget.js', 'TASK-WIDGET.JS']) {
|
|
355
|
+
assert.deepEqual(loader.hallWidgetScripts({ isEnabled: () => true, file }, { roots: [root], core: '1.0.0' }), [], file);
|
|
356
|
+
}
|
|
357
|
+
loader._reset();
|
|
358
|
+
});
|
|
359
|
+
|
|
333
360
|
// --- moduleWebSurfaces: an enabled module's host-routed static surface resolves,
|
|
334
361
|
// a disabled one vanishes (BV1.R84 / task 1635, ADR 0093 §1 / 02-module-map §6) ---
|
|
335
362
|
|