@bongos/core 1.23.21 → 1.23.22
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 +117 -57
- package/.claude/skills/goal-create/SKILL.md +5 -2
- 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/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +87 -0
- package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
- package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
- package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
- package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
- package/docs/adr/README.md +5 -0
- package/docs/api/openapi.json +59 -4
- package/docs/api-reference.md +7 -1
- package/docs/file-map.md +3 -0
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +1 -1
- package/modules/ai-studio/CLAUDE.md +15 -0
- package/modules/ai-studio/module.json +26 -0
- package/modules/ai-studio/routes/ai-studio.js +22 -0
- package/modules/government/db.js +74 -0
- package/modules/government/routes/government.js +28 -18
- package/modules/grading/grader-subagent.js +30 -11
- package/modules/hall-ui/public/city.html +15 -15
- package/modules/lifecycle/db-analytics.js +10 -3
- package/modules/lifecycle/db-goals.js +30 -13
- package/modules/lifecycle/db-shared.js +9 -0
- package/modules/lifecycle/db-versions.js +21 -8
- package/modules/lifecycle/ship-assurance.js +4 -1
- package/modules/lifecycle/ship-card.js +8 -2
- package/modules/onboarding/howto-moments.js +8 -1
- package/modules/provisioning/starter-bundles.js +4 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +66 -0
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/strand-watch.js +106 -10
- package/src/bongos/module-scope-map.js +4 -0
- package/src/module-api.js +1 -1
- package/src/platform-server.js +27 -1
- package/tests/batched_writes_parity.mjs +340 -0
- package/tests/conductor_main_e2e.mjs +4 -0
- package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
- package/tests/goal_archive_disposition.mjs +5 -5
- package/tests/government_routes.mjs +145 -22
- package/tests/grader.mjs +31 -0
- package/tests/hall_city.mjs +8 -0
- package/tests/hook_shell_tools.mjs +8 -0
- package/tests/hooks_drain.mjs +1 -1
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/howto_moments.mjs +17 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/module_skills_hook.mjs +69 -0
- package/tests/public_landing_projects.mjs +37 -0
- package/tests/session_start_freshness.mjs +66 -1
- package/tests/shell_hooks.mjs +209 -0
- package/tests/ship_assurance.mjs +28 -0
- package/tests/ship_card.mjs +20 -5
- package/tests/strand_watch.mjs +48 -0
- package/tests/version_close_route.mjs +1 -1
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
//
|
|
32
32
|
// Usage:
|
|
33
33
|
// node scripts/bongos/strand-watch.js # human-readable report
|
|
34
|
-
// node scripts/bongos/strand-watch.js --line # ONE line, or nothing; always exit 0
|
|
34
|
+
// node scripts/bongos/strand-watch.js --line # ONE line, or nothing; always exit 0 (cached; see linePlan)
|
|
35
|
+
// node scripts/bongos/strand-watch.js --refresh-line # refresh that cache; prints nothing (spawned detached by --line)
|
|
35
36
|
// node scripts/bongos/strand-watch.js --json # machine shape
|
|
36
37
|
// node scripts/bongos/strand-watch.js --hours N # override the `completed` threshold
|
|
37
38
|
//
|
|
@@ -273,6 +274,77 @@ function report(r) {
|
|
|
273
274
|
console.log('');
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
// ---- the SessionStart line, kept off the critical path (task 1004928) --------
|
|
278
|
+
//
|
|
279
|
+
// THE MEASUREMENT. The credit-burn audit timed this hook at 8.2 s — the slowest SessionStart
|
|
280
|
+
// producer, and so the floor for every session start. The shared cache it used (task 1003689)
|
|
281
|
+
// lived 60 s, which only helps sessions opened together; the next session an hour later paid
|
|
282
|
+
// the whole ledger read again, up to 8 s per call (and the held-tweak pass can add another).
|
|
283
|
+
//
|
|
284
|
+
// THE FIX. A strand is measured in hours (one hour for `confirmed`, twelve for `completed`), so
|
|
285
|
+
// an answer a few minutes old is as true as a fresh one. So: keep the answer LINE_FRESH_MS and
|
|
286
|
+
// print it at once; past that, up to LINE_STALE_MAX_MS, print it at once LABELLED WITH ITS AGE
|
|
287
|
+
// and refresh the cache in a detached child for the next session; with nothing usable, read the
|
|
288
|
+
// ledger but give up after LINE_DEADLINE_MS. An undetermined read is never cached — a stored
|
|
289
|
+
// silence would pass for a clean board for the whole fresh window.
|
|
290
|
+
const LINE_CACHE_KEY = 'strand-watch-line';
|
|
291
|
+
const LINE_FRESH_MS = 15 * 60 * 1000;
|
|
292
|
+
const LINE_STALE_MAX_MS = 2 * 60 * 60 * 1000;
|
|
293
|
+
const LINE_DEADLINE_MS = 4000;
|
|
294
|
+
|
|
295
|
+
// PURE — what to do with the cached record: 'fresh' (print it), 'stale' (print it with its age
|
|
296
|
+
// and refresh in the background) or 'miss' (read the ledger now, time-boxed). A record from the
|
|
297
|
+
// future means the clock moved, and is treated as a miss rather than trusted.
|
|
298
|
+
function linePlan(record, nowMs, { freshMs = LINE_FRESH_MS, staleMaxMs = LINE_STALE_MAX_MS } = {}) {
|
|
299
|
+
if (!record || typeof record.at !== 'number' || !Number.isFinite(nowMs)) return 'miss';
|
|
300
|
+
const age = nowMs - record.at;
|
|
301
|
+
if (age < 0) return 'miss';
|
|
302
|
+
if (age < freshMs) return 'fresh';
|
|
303
|
+
if (age < staleMaxMs) return 'stale';
|
|
304
|
+
return 'miss';
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// PURE — the line as printed from a stale cache: the same words, plus how old they are, so a
|
|
308
|
+
// reader can weigh them. A clean (null) answer stays silent: there is nothing to label.
|
|
309
|
+
function labelStale(text, ageMs) {
|
|
310
|
+
if (!text) return null;
|
|
311
|
+
return `${text} (Checked ${humanAge(ageMs)} ago; refreshing for the next session.)`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The ledger read for the line, bounded as a WHOLE (check() bounds each call, not the sum).
|
|
315
|
+
// Throws on an undetermined read so probe-cache stores nothing.
|
|
316
|
+
async function produceLine(opts, deadlineMs) {
|
|
317
|
+
let timer;
|
|
318
|
+
const deadline = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('deadline')), deadlineMs); });
|
|
319
|
+
try {
|
|
320
|
+
const r = await Promise.race([check({ ...opts, timeoutMs: Math.min(deadlineMs, DEFAULT_TIMEOUT_MS) }), deadline]);
|
|
321
|
+
if (!r || r.undetermined) throw new Error('undetermined');
|
|
322
|
+
return line(r);
|
|
323
|
+
} finally { clearTimeout(timer); }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function readLineRecord() {
|
|
327
|
+
try {
|
|
328
|
+
const { cachePath } = require('./probe-cache.js');
|
|
329
|
+
return JSON.parse(require('node:fs').readFileSync(cachePath(LINE_CACHE_KEY), 'utf8'));
|
|
330
|
+
} catch { return null; }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Detached + unref'd + hidden, so the session start never waits on it (windowsHide: a detached
|
|
334
|
+
// child on Windows otherwise flashes a console window). --hours is passed through so the
|
|
335
|
+
// refreshed answer uses the same threshold.
|
|
336
|
+
function spawnRefresh() {
|
|
337
|
+
try {
|
|
338
|
+
const args = [__filename, '--refresh-line'];
|
|
339
|
+
const hours = arg('--hours');
|
|
340
|
+
if (hours) args.push('--hours', String(hours));
|
|
341
|
+
const child = require('node:child_process').spawn(process.execPath, args, {
|
|
342
|
+
detached: true, stdio: 'ignore', windowsHide: true, cwd: process.cwd(),
|
|
343
|
+
});
|
|
344
|
+
child.unref();
|
|
345
|
+
} catch { /* the next session reads the ledger itself */ }
|
|
346
|
+
}
|
|
347
|
+
|
|
276
348
|
async function main() {
|
|
277
349
|
const hours = Number(arg('--hours'));
|
|
278
350
|
const opts = Number.isFinite(hours) && hours > 0 ? { completedStaleMs: hours * 3600000 } : {};
|
|
@@ -285,16 +357,39 @@ async function main() {
|
|
|
285
357
|
// deploy-divergence's identical comment).
|
|
286
358
|
if (hasFlag('--line')) {
|
|
287
359
|
try {
|
|
288
|
-
// SHARED ACROSS
|
|
289
|
-
// within three seconds — one worktree per claim, the normal pattern here — used
|
|
290
|
-
// to make this same call four times and ALL of them timed out, which produces no
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
360
|
+
// SHARED ACROSS SESSION STARTS (task 1003689, widened by task 1004928). Four sessions
|
|
361
|
+
// opened within three seconds — one worktree per claim, the normal pattern here — used
|
|
362
|
+
// to make this same call four times and ALL of them timed out, which produces no line
|
|
363
|
+
// at all. The first session to ask answers for the rest; see linePlan for how long.
|
|
364
|
+
const rec = readLineRecord();
|
|
365
|
+
const plan = linePlan(rec, Date.now());
|
|
366
|
+
if (plan === 'fresh') {
|
|
367
|
+
if (rec.value) console.log(rec.value);
|
|
368
|
+
} else if (plan === 'stale') {
|
|
369
|
+
const one = labelStale(rec.value, Date.now() - rec.at);
|
|
370
|
+
if (one) console.log(one);
|
|
371
|
+
spawnRefresh();
|
|
372
|
+
} else {
|
|
373
|
+
const { cached } = require('./probe-cache.js');
|
|
374
|
+
const { value: one, from } = await cached(LINE_CACHE_KEY, () => produceLine(opts, LINE_DEADLINE_MS), { ttlMs: LINE_FRESH_MS });
|
|
375
|
+
// A sibling holds the lock and the cache handed back its last answer, however old:
|
|
376
|
+
// say how old, so a long-expired line is never passed off as current.
|
|
377
|
+
const shown = from === 'stale-while-locked' && rec && typeof rec.at === 'number'
|
|
378
|
+
? labelStale(one, Date.now() - rec.at) : one;
|
|
379
|
+
if (shown) console.log(shown);
|
|
380
|
+
}
|
|
381
|
+
} catch { /* no session, offline, unreadable, past the deadline — all silence */ }
|
|
382
|
+
process.exitCode = 0;
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// --refresh-line: the detached child spawnRefresh starts. ttlMs 0 forces a fresh read; a
|
|
387
|
+
// sibling already refreshing holds the lock, and then this child simply stops. Prints nothing.
|
|
388
|
+
if (hasFlag('--refresh-line')) {
|
|
389
|
+
try {
|
|
294
390
|
const { cached } = require('./probe-cache.js');
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
} catch { /* no session, offline, unreadable — all silence */ }
|
|
391
|
+
await cached(LINE_CACHE_KEY, () => produceLine(opts, DEFAULT_TIMEOUT_MS * 2), { ttlMs: 0 });
|
|
392
|
+
} catch { /* the next session reads the ledger itself */ }
|
|
298
393
|
process.exitCode = 0;
|
|
299
394
|
return;
|
|
300
395
|
}
|
|
@@ -311,5 +406,6 @@ if (require.main === module) {
|
|
|
311
406
|
|
|
312
407
|
module.exports = {
|
|
313
408
|
check, line, report, classifyStrand, rankStrands, humanAge,
|
|
409
|
+
linePlan, labelStale, produceLine, LINE_FRESH_MS, LINE_STALE_MAX_MS, LINE_DEADLINE_MS,
|
|
314
410
|
CONFIRMED_STALE_MS, COMPLETED_STALE_MS, STRAND_REASON_PREFIX, WATCHED, PAGE_LIMIT,
|
|
315
411
|
};
|
|
@@ -182,6 +182,10 @@ const MODULE_GLOBS = {
|
|
|
182
182
|
'page-notes': [
|
|
183
183
|
'modules/page-notes/',
|
|
184
184
|
],
|
|
185
|
+
// AI generation for artists (task 1004916, ADR 0392): keys, prepaid balance, generate.
|
|
186
|
+
'ai-studio': [
|
|
187
|
+
'modules/ai-studio/',
|
|
188
|
+
],
|
|
185
189
|
// The /raise investor page (task 1004847, ADR 0384): hub-only.
|
|
186
190
|
raise: [
|
|
187
191
|
'modules/raise/',
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.23.
|
|
84
|
+
const CORE_VERSION = '1.23.22'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/src/platform-server.js
CHANGED
|
@@ -142,6 +142,24 @@ function stampAssets(html) {
|
|
|
142
142
|
assetStamp(url) ? `${attr}="${url}?v=${assetStamp(url)}"` : m);
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// The cache lifetime a landing asset is served with (task 1004925). A URL whose
|
|
146
|
+
// ?v= is the file's CURRENT stamp can never name different bytes — a new file
|
|
147
|
+
// means a new stamp, so a new URL — so the browser may keep it for a year instead
|
|
148
|
+
// of re-fetching it after Cloudflare's four-hour default runs out. Only an exact
|
|
149
|
+
// match earns that, the hall's rule (serve-internal.js, task 1004855): a stale or
|
|
150
|
+
// made-up ?v=, or a bare URL, keeps whatever lifetime it had. Images and fonts
|
|
151
|
+
// that no page stamps (a css url(), a script-built src) get one day, up from four
|
|
152
|
+
// hours; everything else is left to the default. Returns null for "no header".
|
|
153
|
+
const MEDIA_ASSET = /\.(?:png|jpe?g|webp|avif|gif|svg|ico|woff2?|ttf|otf)$/i;
|
|
154
|
+
function landingAssetCache(urlPath, v) {
|
|
155
|
+
if (typeof urlPath !== 'string') return null;
|
|
156
|
+
if (typeof v === 'string' && /^[0-9a-f]{8}$/.test(v) && assetStamp(urlPath) === v) {
|
|
157
|
+
return 'public, max-age=31536000, immutable';
|
|
158
|
+
}
|
|
159
|
+
if (MEDIA_ASSET.test(urlPath)) return 'public, max-age=86400';
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
145
163
|
const _landingPages = new Map(); // memoized per file; '' if absent (instance ships no landing)
|
|
146
164
|
function landingPage(file) {
|
|
147
165
|
if (!_landingPages.has(file)) {
|
|
@@ -364,7 +382,15 @@ function buildPlatformApp() {
|
|
|
364
382
|
});
|
|
365
383
|
// Assets fall through to the 404 tail when no module claims the apex root — the
|
|
366
384
|
// handler is not mounted at all rather than pointed at a missing directory.
|
|
367
|
-
const landingAssets = landingDir() && express.static(landingDir(), {
|
|
385
|
+
const landingAssets = landingDir() && express.static(landingDir(), {
|
|
386
|
+
index: false,
|
|
387
|
+
fallthrough: true,
|
|
388
|
+
setHeaders: (res) => {
|
|
389
|
+
const req = res.req || {};
|
|
390
|
+
const cache = landingAssetCache(req.path, req.query && req.query.v);
|
|
391
|
+
if (cache) res.setHeader('Cache-Control', cache);
|
|
392
|
+
},
|
|
393
|
+
});
|
|
368
394
|
if (landingAssets) {
|
|
369
395
|
app.use((req, res, next) => {
|
|
370
396
|
if (!isApexHost(req)) return next();
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// tests/batched_writes_parity.mjs — the batched goal archive, version close and
|
|
2
|
+
// weekly karma tally give the SAME results as the one-query-per-row loops they
|
|
3
|
+
// replaced (task 1004929).
|
|
4
|
+
//
|
|
5
|
+
// Each case runs the old loop (tests/fixtures/per-row-loops-legacy.cjs, a
|
|
6
|
+
// verbatim copy) and the new code over the SAME seeded fake database, then
|
|
7
|
+
// compares what each returned or threw AND what each left in the tables. The
|
|
8
|
+
// fake is a small interpreter for exactly the statements both versions send —
|
|
9
|
+
// old per-row reads and new batched reads answer from one store — and it hands
|
|
10
|
+
// bigint ids back as STRINGS, as node-pg does, so a plan that carries numbers is
|
|
11
|
+
// matched back to rows the way production matches them. BEGIN snapshots the
|
|
12
|
+
// store and ROLLBACK restores it, so a thrown run is compared on what survived.
|
|
13
|
+
//
|
|
14
|
+
// The rank-set apply parity lives in tests/government_routes.mjs, where the real
|
|
15
|
+
// router is already standing.
|
|
16
|
+
//
|
|
17
|
+
// Run: node --test tests/batched_writes_parity.mjs
|
|
18
|
+
|
|
19
|
+
import assert from 'node:assert/strict';
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
|
|
23
|
+
process.env.NODE_ENV = 'test';
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const { archiveGoalWithDispositions, closeVersion } = require('../modules/lifecycle/db.js');
|
|
26
|
+
const { tallyPeerVotes } = require('../modules/lifecycle/db-analytics.js');
|
|
27
|
+
const { idKey } = require('../modules/lifecycle/db-shared.js');
|
|
28
|
+
const pgPool = require('../src/bongos/pool').pool;
|
|
29
|
+
const legacy = require('./fixtures/per-row-loops-legacy.cjs');
|
|
30
|
+
|
|
31
|
+
const TERMINAL = new Set(['shipped', 'abandoned']);
|
|
32
|
+
const same = (a, b) => String(a) === String(b);
|
|
33
|
+
const inIds = (arr, v) => arr.some((x) => same(idKey(x), idKey(v)));
|
|
34
|
+
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
35
|
+
|
|
36
|
+
// ---- the fake database -------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function fakeDb(seed) {
|
|
39
|
+
let st = clone(seed);
|
|
40
|
+
let snap = null;
|
|
41
|
+
const queries = [];
|
|
42
|
+
const task = (id) => st.tasks.find((t) => same(t.id, idKey(id)));
|
|
43
|
+
const goal = (id) => st.goals.find((g) => same(g.id, idKey(id)));
|
|
44
|
+
const out = (row) => (row ? { ...row, id: String(row.id) } : row);
|
|
45
|
+
const openWork = (t) => !TERMINAL.has(t.status) && !t.title.startsWith('__smoke__');
|
|
46
|
+
|
|
47
|
+
async function query(sql, p = []) {
|
|
48
|
+
const s = String(sql).replace(/\s+/g, ' ').trim();
|
|
49
|
+
queries.push(s);
|
|
50
|
+
if (s === 'BEGIN') { snap = clone(st); return { rows: [] }; }
|
|
51
|
+
if (s === 'COMMIT') { snap = null; return { rows: [] }; }
|
|
52
|
+
if (s === 'ROLLBACK') { if (snap) st = snap; snap = null; return { rows: [] }; }
|
|
53
|
+
|
|
54
|
+
// ---- goal archive -------------------------------------------------------
|
|
55
|
+
if (/^SELECT .* FROM goals WHERE id = \$1 FOR UPDATE$/.test(s)) return { rows: [goal(p[0])].filter(Boolean).map(out) };
|
|
56
|
+
if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE$/.test(s)) {
|
|
57
|
+
const t = task(p[0]);
|
|
58
|
+
return { rows: t && same(t.goal_id, p[1]) ? [out(t)] : [] };
|
|
59
|
+
}
|
|
60
|
+
if (/FROM tasks WHERE id = ANY\(\$1::bigint\[\]\) AND goal_id = \$2 ORDER BY id FOR UPDATE$/.test(s)) {
|
|
61
|
+
return { rows: st.tasks.filter((t) => inIds(p[0], t.id) && same(t.goal_id, p[1])).sort((a, b) => a.id - b.id).map(out) };
|
|
62
|
+
}
|
|
63
|
+
if (/^SELECT id FROM claims WHERE task_id = \$1 AND released_at IS NULL LIMIT 1$/.test(s)) {
|
|
64
|
+
return { rows: st.claims.filter((c) => same(c.task_id, idKey(p[0])) && !c.released_at).slice(0, 1).map((c) => ({ id: String(c.id) })) };
|
|
65
|
+
}
|
|
66
|
+
if (/^SELECT DISTINCT task_id FROM claims WHERE task_id = ANY\(\$1::bigint\[\]\) AND released_at IS NULL$/.test(s)) {
|
|
67
|
+
const ids = [...new Set(st.claims.filter((c) => inIds(p[0], c.task_id) && !c.released_at).map((c) => String(c.task_id)))];
|
|
68
|
+
return { rows: ids.map((task_id) => ({ task_id })) };
|
|
69
|
+
}
|
|
70
|
+
if (/^UPDATE tasks SET status = 'abandoned', .* WHERE id = \$1$/.test(s)) {
|
|
71
|
+
const t = task(p[0]); t.status = 'abandoned'; if (p[1]) t.value_summary = p[1];
|
|
72
|
+
return { rows: [], rowCount: 1 };
|
|
73
|
+
}
|
|
74
|
+
if (/^SELECT id, status FROM goals WHERE id = \$1 FOR SHARE$/.test(s)) return { rows: [goal(p[0])].filter(Boolean).map((g) => ({ id: String(g.id), status: g.status })) };
|
|
75
|
+
if (/^UPDATE tasks SET goal_id = \$2, updated_at = now\(\) WHERE id = \$1$/.test(s)) {
|
|
76
|
+
task(p[0]).goal_id = Number(p[1]);
|
|
77
|
+
return { rows: [], rowCount: 1 };
|
|
78
|
+
}
|
|
79
|
+
if (/count\(\*\)::int AS n FROM (tasks|goals)/.test(s)) return { rows: [{ n: 0 }] }; // the re-counts are not under test
|
|
80
|
+
if (/^UPDATE goal_membership_requests/.test(s)) return { rows: [], rowCount: 0 };
|
|
81
|
+
|
|
82
|
+
// ---- version close ------------------------------------------------------
|
|
83
|
+
if (/FROM versions WHERE id = \$1 FOR UPDATE$/.test(s)) return { rows: [{ id: p[0], status: 'building' }] };
|
|
84
|
+
if (/FROM goals WHERE id = \$1 AND version_id = \$2 FOR UPDATE$/.test(s)) {
|
|
85
|
+
const g = goal(p[0]);
|
|
86
|
+
return { rows: g && same(g.version_id, p[1]) ? [out(g)] : [] };
|
|
87
|
+
}
|
|
88
|
+
if (/FROM goals WHERE id = ANY\(\$1::bigint\[\]\) AND version_id = \$2 ORDER BY id FOR UPDATE$/.test(s)) {
|
|
89
|
+
return { rows: st.goals.filter((g) => inIds(p[0], g.id) && same(g.version_id, p[1])).sort((a, b) => a.id - b.id).map(out) };
|
|
90
|
+
}
|
|
91
|
+
if (/^UPDATE tasks SET status = 'abandoned', .* WHERE goal_id = \$1 AND .* RETURNING id$/.test(s)) {
|
|
92
|
+
const hit = st.tasks.filter((t) => same(t.goal_id, idKey(p[0])) && openWork(t));
|
|
93
|
+
for (const t of hit) { t.status = 'abandoned'; t.value_summary = p[1]; }
|
|
94
|
+
return { rows: hit.map((t) => ({ id: String(t.id) })) };
|
|
95
|
+
}
|
|
96
|
+
if (/^UPDATE goals SET status = 'archived', updated_at = now\(\) WHERE id = \$1/.test(s)) {
|
|
97
|
+
const g = goal(p[0]); g.status = 'archived';
|
|
98
|
+
return { rows: [out(g)], rowCount: 1 };
|
|
99
|
+
}
|
|
100
|
+
if (/^WITH next_order AS .* INSERT INTO goals/.test(s)) {
|
|
101
|
+
const src = goal(p[0]);
|
|
102
|
+
const id = st.nextGoalId++;
|
|
103
|
+
st.goals.push({ id, version_id: p[1], title: src.title, status: 'open', succeeded_by_goal_id: null });
|
|
104
|
+
return { rows: [{ id: String(id) }] };
|
|
105
|
+
}
|
|
106
|
+
if (/^INSERT INTO goal_members/.test(s)) return { rows: [], rowCount: 0 };
|
|
107
|
+
if (/^UPDATE tasks SET goal_id = \$2, version_id = \$3, .* RETURNING id$/.test(s)) {
|
|
108
|
+
const hit = st.tasks.filter((t) => same(t.goal_id, idKey(p[0])) && openWork(t));
|
|
109
|
+
for (const t of hit) { t.goal_id = Number(p[1]); t.version_id = p[2]; }
|
|
110
|
+
return { rows: hit.map((t) => ({ id: String(t.id) })) };
|
|
111
|
+
}
|
|
112
|
+
if (/^UPDATE goals SET succeeded_by_goal_id = \$2/.test(s)) { goal(p[0]).succeeded_by_goal_id = Number(p[1]); return { rows: [] }; }
|
|
113
|
+
if (/FROM tasks t LEFT JOIN goals g/.test(s)) return { rows: [] };
|
|
114
|
+
if (/^UPDATE versions SET status = 'shipped'/.test(s)) return { rows: [{ id: p[0], status: 'shipped' }] };
|
|
115
|
+
|
|
116
|
+
// ---- weekly karma tally -------------------------------------------------
|
|
117
|
+
if (/FROM peer_votes pv JOIN tasks t/.test(s)) {
|
|
118
|
+
return { rows: st.peerVotes.filter((v) => !v.tallied).map((v) => ({ id: String(v.id), builder_id: String(v.builder_id), vote: v.vote })) };
|
|
119
|
+
}
|
|
120
|
+
if (/to_regclass\('ideas_votes'\)/.test(s)) return { rows: [{ has: st.ideaVotes !== null }] };
|
|
121
|
+
if (/FROM ideas_votes iv JOIN idea_inbox i/.test(s)) {
|
|
122
|
+
return { rows: st.ideaVotes.filter((v) => !v.tallied).map((v) => ({ id: String(v.id), builder_id: String(v.builder_id), vote: v.vote })) };
|
|
123
|
+
}
|
|
124
|
+
if (/^INSERT INTO karma_log .* VALUES \(\$1, \$2, 'peer_vote_weekly', NULL\)$/.test(s)) {
|
|
125
|
+
st.karma.push({ builder_id: String(p[0]), delta: Number(p[1]), reason: 'peer_vote_weekly', granted_by: null });
|
|
126
|
+
return { rows: [], rowCount: 1 };
|
|
127
|
+
}
|
|
128
|
+
if (/^INSERT INTO karma_log .* FROM unnest\(\$1::bigint\[\], \$2::int\[\]\) WITH ORDINALITY .* ORDER BY k\.ord$/.test(s)) {
|
|
129
|
+
p[0].forEach((b, i) => st.karma.push({ builder_id: String(b), delta: Number(p[1][i]), reason: 'peer_vote_weekly', granted_by: null }));
|
|
130
|
+
return { rows: [], rowCount: p[0].length };
|
|
131
|
+
}
|
|
132
|
+
if (/^UPDATE (peer_votes|ideas_votes) SET tallied_at = now\(\) WHERE id = ANY/.test(s)) {
|
|
133
|
+
const list = s.startsWith('UPDATE peer_votes') ? st.peerVotes : st.ideaVotes;
|
|
134
|
+
for (const v of list) if (inIds(p[0], v.id)) v.tallied = true;
|
|
135
|
+
return { rows: [], rowCount: p[0].length };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(`fake db: unhandled statement: ${s.slice(0, 120)}`);
|
|
139
|
+
}
|
|
140
|
+
const client = { query, release() {} };
|
|
141
|
+
return {
|
|
142
|
+
pool: { connect: async () => client, query },
|
|
143
|
+
client,
|
|
144
|
+
queries,
|
|
145
|
+
get state() { return clone(st); },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Run a legacy loop the way its function ran it: inside one transaction, rolled
|
|
150
|
+
// back on a throw. Returns { result } or { error }.
|
|
151
|
+
async function runLegacyTx(db, fn) {
|
|
152
|
+
await db.client.query('BEGIN');
|
|
153
|
+
try {
|
|
154
|
+
const result = await fn(db.client);
|
|
155
|
+
await db.client.query('COMMIT');
|
|
156
|
+
return { result };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
await db.client.query('ROLLBACK');
|
|
159
|
+
return { error };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function runNew(fn) {
|
|
163
|
+
try { return { result: await fn() }; } catch (error) { return { error }; }
|
|
164
|
+
}
|
|
165
|
+
const errShape = (e) => (e ? { code: e.code, taskId: e.taskId, taskTitle: e.taskTitle, goalId: e.goalId, toGoalId: e.toGoalId, targetStatus: e.targetStatus } : null);
|
|
166
|
+
|
|
167
|
+
// ---- goal archive ------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
const ARCHIVE_SEED = {
|
|
170
|
+
goals: [
|
|
171
|
+
{ id: 7, version_id: 'V1', title: 'the goal being archived', status: 'open' },
|
|
172
|
+
{ id: 8, version_id: 'V1', title: 'a neighbour', status: 'open' },
|
|
173
|
+
{ id: 9, version_id: 'V1', title: 'an open move target', status: 'open' },
|
|
174
|
+
{ id: 10, version_id: 'V1', title: 'a closed move target', status: 'archived' },
|
|
175
|
+
],
|
|
176
|
+
tasks: [
|
|
177
|
+
{ id: 1, goal_id: 7, version_id: 'V1', status: 'ready', title: 'one', value_summary: null },
|
|
178
|
+
{ id: 2, goal_id: 7, version_id: 'V1', status: 'shipped', title: 'two', value_summary: null },
|
|
179
|
+
{ id: 3, goal_id: 8, version_id: 'V1', status: 'ready', title: 'three (elsewhere)', value_summary: null },
|
|
180
|
+
{ id: 4, goal_id: 7, version_id: 'V1', status: 'active', title: 'four (claimed)', value_summary: null },
|
|
181
|
+
{ id: 5, goal_id: 7, version_id: 'V1', status: 'backlog', title: 'five', value_summary: null },
|
|
182
|
+
{ id: 6, goal_id: 7, version_id: 'V1', status: 'ready', title: 'six', value_summary: null },
|
|
183
|
+
],
|
|
184
|
+
claims: [{ id: 99, task_id: 4, released_at: null }, { id: 98, task_id: 6, released_at: '2026-01-01' }],
|
|
185
|
+
nextGoalId: 100,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const ARCHIVE_PLANS = {
|
|
189
|
+
'mixed verbs, skips and a move': [
|
|
190
|
+
{ taskId: 1, verb: 'abandon' }, { taskId: 2, verb: 'abandon' }, { taskId: 3, verb: 'abandon' },
|
|
191
|
+
{ taskId: 5, verb: 'move', toGoalId: 9 }, { taskId: 6, verb: 'abandon' },
|
|
192
|
+
],
|
|
193
|
+
'plan order is not id order': [{ taskId: 6, verb: 'abandon' }, { taskId: 1, verb: 'move', toGoalId: 9 }, { taskId: 5, verb: 'abandon' }],
|
|
194
|
+
'a task named twice reads what the first step left': [
|
|
195
|
+
{ taskId: 1, verb: 'abandon' }, { taskId: 1, verb: 'abandon' },
|
|
196
|
+
{ taskId: 5, verb: 'move', toGoalId: 9 }, { taskId: 5, verb: 'abandon' },
|
|
197
|
+
],
|
|
198
|
+
'ids as strings, as a JSON body carries them': [{ taskId: '1', verb: 'abandon' }, { taskId: '5', verb: 'move', toGoalId: '9' }],
|
|
199
|
+
'an actively claimed task stops it, after earlier steps': [{ taskId: 1, verb: 'abandon' }, { taskId: 4, verb: 'abandon' }, { taskId: 6, verb: 'abandon' }],
|
|
200
|
+
'a closed move target stops it': [{ taskId: 1, verb: 'abandon' }, { taskId: 5, verb: 'move', toGoalId: 10 }],
|
|
201
|
+
'a missing move target stops it': [{ taskId: 5, verb: 'move', toGoalId: 404 }],
|
|
202
|
+
'a task that does not exist is skipped': [{ taskId: 777, verb: 'abandon' }, { taskId: 6, verb: 'abandon' }],
|
|
203
|
+
'an empty plan': [],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
for (const [name, plan] of Object.entries(ARCHIVE_PLANS)) {
|
|
207
|
+
test(`goal archive, old loop vs batched: ${name}`, async () => {
|
|
208
|
+
const args = { goalId: 7, plan, reason: 'cut in review' };
|
|
209
|
+
const oldDb = fakeDb(ARCHIVE_SEED);
|
|
210
|
+
const old = await runLegacyTx(oldDb, (c) => legacy.legacyArchiveLoop(c, args));
|
|
211
|
+
const neuDb = fakeDb(ARCHIVE_SEED);
|
|
212
|
+
const neu = await runNew(() => archiveGoalWithDispositions(args, { pool: neuDb.pool }));
|
|
213
|
+
|
|
214
|
+
assert.deepEqual(errShape(neu.error), errShape(old.error), 'the same refusal, naming the same task');
|
|
215
|
+
if (!old.error) assert.deepEqual(neu.result.applied, old.result, 'the same applied list, in plan order');
|
|
216
|
+
assert.deepEqual(neuDb.state.tasks, oldDb.state.tasks, 'the same tasks left behind');
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
test('goal archive reads every plan task in ONE locked statement and the claims in one more', async () => {
|
|
221
|
+
const db = fakeDb(ARCHIVE_SEED);
|
|
222
|
+
await archiveGoalWithDispositions({ goalId: 7, plan: ARCHIVE_PLANS['mixed verbs, skips and a move'], reason: 'r' }, { pool: db.pool });
|
|
223
|
+
assert.equal(db.queries.filter((q) => /FROM tasks WHERE id = ANY/.test(q)).length, 1);
|
|
224
|
+
assert.equal(db.queries.filter((q) => /FROM claims WHERE task_id/.test(q)).length, 1);
|
|
225
|
+
assert.equal(db.queries.filter((q) => /FROM tasks WHERE id = \$1/.test(q)).length, 0, 'no per-step read is left');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ---- version close -----------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
const CLOSE_SEED = {
|
|
231
|
+
goals: [
|
|
232
|
+
{ id: 1, version_id: 'V1', title: 'g1', status: 'open', succeeded_by_goal_id: null },
|
|
233
|
+
{ id: 2, version_id: 'V1', title: 'g2', status: 'open', succeeded_by_goal_id: null },
|
|
234
|
+
{ id: 3, version_id: 'V1', title: 'g3 (already closed)', status: 'archived', succeeded_by_goal_id: null },
|
|
235
|
+
{ id: 4, version_id: 'V9', title: 'g4 (another version)', status: 'open', succeeded_by_goal_id: null },
|
|
236
|
+
{ id: 5, version_id: 'V1', title: 'g5', status: 'open', succeeded_by_goal_id: null },
|
|
237
|
+
],
|
|
238
|
+
tasks: [
|
|
239
|
+
{ id: 11, goal_id: 1, version_id: 'V1', status: 'ready', title: 'a', value_summary: null },
|
|
240
|
+
{ id: 12, goal_id: 1, version_id: 'V1', status: 'shipped', title: 'b', value_summary: 'done' },
|
|
241
|
+
{ id: 13, goal_id: 1, version_id: 'V1', status: 'ready', title: '__smoke__ fixture', value_summary: null },
|
|
242
|
+
{ id: 21, goal_id: 2, version_id: 'V1', status: 'backlog', title: 'c', value_summary: null },
|
|
243
|
+
{ id: 22, goal_id: 2, version_id: 'V1', status: 'active', title: 'd', value_summary: null },
|
|
244
|
+
{ id: 51, goal_id: 5, version_id: 'V1', status: 'ready', title: 'e', value_summary: null },
|
|
245
|
+
],
|
|
246
|
+
claims: [],
|
|
247
|
+
nextGoalId: 100,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const CLOSE_CASES = {
|
|
251
|
+
'abandon and roll forward, with skips': {
|
|
252
|
+
plan: [{ goalId: 1, verb: 'abandon' }, { goalId: 3, verb: 'abandon' }, { goalId: 4, verb: 'roll_forward' }, { goalId: 2, verb: 'roll_forward' }],
|
|
253
|
+
planningVersionId: 'V2',
|
|
254
|
+
},
|
|
255
|
+
'plan order is not id order': {
|
|
256
|
+
plan: [{ goalId: 5, verb: 'roll_forward' }, { goalId: 2, verb: 'abandon' }, { goalId: 1, verb: 'roll_forward' }],
|
|
257
|
+
planningVersionId: 'V2',
|
|
258
|
+
},
|
|
259
|
+
'a goal named twice reads archived the second time': {
|
|
260
|
+
plan: [{ goalId: 1, verb: 'abandon' }, { goalId: 1, verb: 'roll_forward' }, { goalId: 2, verb: 'roll_forward' }, { goalId: 2, verb: 'abandon' }],
|
|
261
|
+
planningVersionId: 'V2',
|
|
262
|
+
},
|
|
263
|
+
'ids as strings': { plan: [{ goalId: '2', verb: 'abandon' }, { goalId: '5', verb: 'roll_forward' }], planningVersionId: 'V2' },
|
|
264
|
+
'a roll forward with no planning version throws after earlier steps': {
|
|
265
|
+
plan: [{ goalId: 1, verb: 'abandon' }, { goalId: 2, verb: 'roll_forward' }],
|
|
266
|
+
planningVersionId: null,
|
|
267
|
+
},
|
|
268
|
+
'an empty plan': { plan: [], planningVersionId: 'V2' },
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
for (const [name, { plan, planningVersionId }] of Object.entries(CLOSE_CASES)) {
|
|
272
|
+
test(`version close, old loop vs batched: ${name}`, async () => {
|
|
273
|
+
const args = { versionId: 'V1', plan, reason: 'V1 is done', planningVersionId };
|
|
274
|
+
const oldDb = fakeDb(CLOSE_SEED);
|
|
275
|
+
const old = await runLegacyTx(oldDb, (c) => legacy.legacyCloseLoop(c, args));
|
|
276
|
+
const neuDb = fakeDb(CLOSE_SEED);
|
|
277
|
+
const neu = await runNew(() => closeVersion(args, { pool: neuDb.pool }));
|
|
278
|
+
|
|
279
|
+
assert.deepEqual(errShape(neu.error), errShape(old.error), 'the same refusal');
|
|
280
|
+
if (!old.error) assert.deepEqual(neu.result.applied, old.result, 'the same applied list, in plan order');
|
|
281
|
+
assert.deepEqual(neuDb.state.goals, oldDb.state.goals, 'the same goals, successors included');
|
|
282
|
+
assert.deepEqual(neuDb.state.tasks, oldDb.state.tasks, 'the same tasks');
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
test('version close locks every plan goal in ONE statement, in id order', async () => {
|
|
287
|
+
const db = fakeDb(CLOSE_SEED);
|
|
288
|
+
await closeVersion({ versionId: 'V1', plan: CLOSE_CASES['plan order is not id order'].plan, reason: 'r', planningVersionId: 'V2' }, { pool: db.pool });
|
|
289
|
+
const locks = db.queries.filter((q) => /FROM goals WHERE id = ANY\(\$1::bigint\[\]\) AND version_id = \$2 ORDER BY id FOR UPDATE/.test(q));
|
|
290
|
+
assert.equal(locks.length, 1);
|
|
291
|
+
assert.equal(db.queries.filter((q) => /FROM goals WHERE id = \$1 AND version_id/.test(q)).length, 0, 'no per-step lock is left');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ---- weekly karma tally ------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
const TALLY_SEED = {
|
|
297
|
+
goals: [], tasks: [], claims: [], nextGoalId: 1, karma: [],
|
|
298
|
+
peerVotes: [
|
|
299
|
+
{ id: 1, builder_id: 30, vote: 1 }, { id: 2, builder_id: 10, vote: 1 }, { id: 3, builder_id: 30, vote: 1 },
|
|
300
|
+
{ id: 4, builder_id: 20, vote: 1 }, { id: 5, builder_id: 20, vote: -1 }, // nets to zero: no row
|
|
301
|
+
{ id: 6, builder_id: 40, vote: -1 },
|
|
302
|
+
],
|
|
303
|
+
ideaVotes: [{ id: 7, builder_id: 10, vote: 1 }, { id: 8, builder_id: 50, vote: 1 }],
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
async function withFakePool(db, fn) {
|
|
307
|
+
const had = Object.prototype.hasOwnProperty.call(pgPool, 'connect');
|
|
308
|
+
const real = pgPool.connect;
|
|
309
|
+
pgPool.connect = db.pool.connect;
|
|
310
|
+
try { return await fn(); } finally { if (had) pgPool.connect = real; else delete pgPool.connect; }
|
|
311
|
+
}
|
|
312
|
+
const fakeWithTx = (db) => async (fn) => {
|
|
313
|
+
await db.client.query('BEGIN');
|
|
314
|
+
try { const r = await fn(db.client); await db.client.query('COMMIT'); return r; } catch (e) { await db.client.query('ROLLBACK'); throw e; }
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
for (const [name, seed] of Object.entries({
|
|
318
|
+
'task and idea votes, a zero net, first-seen order': TALLY_SEED,
|
|
319
|
+
'no ideas module': { ...TALLY_SEED, ideaVotes: null },
|
|
320
|
+
'nothing matured': { ...TALLY_SEED, peerVotes: [], ideaVotes: [] },
|
|
321
|
+
})) {
|
|
322
|
+
test(`weekly karma tally, old loop vs batched: ${name}`, async () => {
|
|
323
|
+
const oldDb = fakeDb(seed);
|
|
324
|
+
const old = await legacy.legacyTallyPeerVotes(fakeWithTx(oldDb));
|
|
325
|
+
const neuDb = fakeDb(seed);
|
|
326
|
+
const neu = await withFakePool(neuDb, () => tallyPeerVotes());
|
|
327
|
+
|
|
328
|
+
assert.deepEqual(neu, old, 'the same reply');
|
|
329
|
+
assert.deepEqual(neuDb.state.karma, oldDb.state.karma, 'the same karma_log rows, in the same order');
|
|
330
|
+
assert.deepEqual(neuDb.state.peerVotes, oldDb.state.peerVotes, 'the same votes stamped');
|
|
331
|
+
assert.deepEqual(neuDb.state.ideaVotes, oldDb.state.ideaVotes);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
test('the tally writes every credited builder in ONE insert', async () => {
|
|
336
|
+
const db = fakeDb(TALLY_SEED);
|
|
337
|
+
const r = await withFakePool(db, () => tallyPeerVotes());
|
|
338
|
+
assert.equal(r.builders_credited, 4);
|
|
339
|
+
assert.equal(db.queries.filter((q) => /^INSERT INTO karma_log/.test(q)).length, 1);
|
|
340
|
+
});
|
|
@@ -161,6 +161,10 @@ await test('asking for the next how-to step twice in one session reaches the AI
|
|
|
161
161
|
const r = await runHook(mePayload(), { prompts: ["what's next in the tutorial", "what's next in the tutorial"] });
|
|
162
162
|
assert.equal(r.outs.length, 2);
|
|
163
163
|
for (const [i, out] of r.outs.entries()) assert.ok(out.includes('npx bongos howto next'), `ask ${i + 1} was swallowed; stdout: ${JSON.stringify(out.slice(0, 200))}`);
|
|
164
|
+
// task 1004742: the second ask prints the how-to answer ALONE; the rest of the
|
|
165
|
+
// block (here, the interaction profile) was already shown this session.
|
|
166
|
+
assert.ok(r.outs[0].includes(INTERACTION), 'the first prompt carries the whole block');
|
|
167
|
+
assert.ok(!r.outs[1].includes(INTERACTION), `the repeat re-printed the whole block; stdout: ${JSON.stringify(r.outs[1].slice(0, 300))}`);
|
|
164
168
|
});
|
|
165
169
|
|
|
166
170
|
summary();
|