@bongos/core 1.23.20 → 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 +137 -67
- 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 +4 -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/copy-desk/chrome-pages.js +178 -0
- package/modules/copy-desk/page-data.js +34 -2
- package/modules/copy-desk/tests/copy_chrome_pages.mjs +127 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +1 -1
- 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 +72 -0
- package/scripts/bongos/copy-apply.js +3 -1
- package/scripts/bongos/publish-manifest.js +1 -0
- package/scripts/bongos/strand-watch.js +106 -10
- package/scripts/bongos/tweak-renders.js +9 -2
- 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/copy_desk_page_docx.mjs +3 -3
- package/tests/copy_desk_page_editor.mjs +6 -6
- package/tests/copy_desk_page_next.mjs +3 -2
- package/tests/copy_desk_page_reads.mjs +2 -1
- package/tests/copy_desk_page_submit.mjs +30 -2
- 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/profile_role_stats.mjs +2 -1
- 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
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();
|
|
@@ -75,12 +75,12 @@ const errDetail = (b) => (b && b.error && b.error.details) || {};
|
|
|
75
75
|
|
|
76
76
|
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
77
77
|
const STUDIO_LINES = [
|
|
78
|
-
L('L0001', '
|
|
79
|
-
L('L0002', '
|
|
78
|
+
L('L0001', 'shell bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
79
|
+
L('L0002', 'shell bar', 'Ctrl K', 'shared'),
|
|
80
80
|
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
81
81
|
L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
|
|
82
82
|
L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
83
|
-
L('L0006', '
|
|
83
|
+
L('L0006', 'shell foot', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0006' }),
|
|
84
84
|
];
|
|
85
85
|
const INDEX_LINES = [
|
|
86
86
|
L('L0001', 'hero', 'Build here', 'placed', { file: 'modules/public-landing/public/index.html', line: 3, string_id: 'ix0001' }),
|
|
@@ -64,11 +64,11 @@ const errCode = (b) => (b && b.error && b.error.code) || null;
|
|
|
64
64
|
|
|
65
65
|
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
66
66
|
const STUDIO_LINES = [
|
|
67
|
-
L('L0001', '
|
|
68
|
-
L('L0002', '
|
|
67
|
+
L('L0001', 'shell bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
68
|
+
L('L0002', 'shell bar', 'Ctrl K', 'shared'),
|
|
69
69
|
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
70
70
|
L('L0004', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
71
|
-
L('L0005', '
|
|
71
|
+
L('L0005', 'shell foot', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0005' }),
|
|
72
72
|
];
|
|
73
73
|
const readingsWith = (studioHash, lines = STUDIO_LINES) => ({
|
|
74
74
|
schema: 1,
|
|
@@ -96,13 +96,13 @@ try {
|
|
|
96
96
|
assert.equal(r.body.page.surface_word, 'builders hall');
|
|
97
97
|
assert.equal(r.body.reading.reading_hash, 'studio-h1');
|
|
98
98
|
assert.deepEqual(r.body.reading.lines.map((l) => [l.key, l.section, l.text, l.note]), [
|
|
99
|
-
['L0001', '
|
|
99
|
+
['L0001', 'shell bar', 'Studio', 'shared'],
|
|
100
100
|
// Shared shell text with no registry row cannot be applied mechanically,
|
|
101
101
|
// so it goes to an engineer at submit, and the note says so.
|
|
102
|
-
['L0002', '
|
|
102
|
+
['L0002', 'shell bar', 'Ctrl K', 'engineer'],
|
|
103
103
|
['L0003', 'greeting', 'Welcome back', null],
|
|
104
104
|
['L0004', 'greeting', 'Your desk is quiet today', 'engineer'],
|
|
105
|
-
['L0005', '
|
|
105
|
+
['L0005', 'shell foot', 'Resume where you left off', null],
|
|
106
106
|
]);
|
|
107
107
|
assert.deepEqual(r.body.notes, { shared: 'changes this on every page', engineer: 'goes to an engineer', guarded: 'must still say' });
|
|
108
108
|
assert.equal(r.body.round, null);
|
|
@@ -101,7 +101,8 @@ try {
|
|
|
101
101
|
|
|
102
102
|
const { status, body } = await getNext(ALICE);
|
|
103
103
|
assert.equal(status, 200, JSON.stringify(body));
|
|
104
|
-
assert.deepEqual(order(body), ['landing:projects', 'status:explore', 'builders:studio', 'landing:index']);
|
|
104
|
+
assert.deepEqual(order(body), ['landing:projects', 'status:explore', 'builders:studio', 'landing:index', 'landing:skip-link']);
|
|
105
|
+
// landing:skip-link is the surface's shared chrome, a page of its own, untweaked
|
|
105
106
|
assert.equal(body.recommended.why, '2 people asked for it');
|
|
106
107
|
assert.equal(body.recommended.why_line, 'landing · 2 people asked for it · untweaked');
|
|
107
108
|
assert.equal(body.alternatives[1].why, '1 line changed since your last pass', 'the round artist is the web claim, alice');
|
|
@@ -145,7 +146,7 @@ try {
|
|
|
145
146
|
assert.equal(take.body.page_id, 'status:explore');
|
|
146
147
|
const after = (await getNext(CAROL)).body;
|
|
147
148
|
assert.deepEqual(after.skipped.map((s) => s.page_id).sort(), ['landing:projects', 'status:explore']);
|
|
148
|
-
assert.deepEqual(order(after), ['builders:studio', 'landing:index']);
|
|
149
|
+
assert.deepEqual(order(after), ['builders:studio', 'landing:index', 'landing:skip-link', 'builders:top-bar']);
|
|
149
150
|
});
|
|
150
151
|
|
|
151
152
|
await test('a submitted round is in flight: skipped, and the claim agrees', async () => {
|
|
@@ -115,7 +115,8 @@ try {
|
|
|
115
115
|
assert.equal(p('builders:studio').status_label, 'Text tweaked 1x');
|
|
116
116
|
assert.equal(p('builders:studio').drift.changed_count, 1);
|
|
117
117
|
assert.equal(p('builders:studio').open_round.state, 'writing');
|
|
118
|
-
|
|
118
|
+
// Each surface's shared chrome is a page of its own now: landing gains a skip link page, builders a top bar page.
|
|
119
|
+
assert.deepEqual(body.surfaces.map((s) => s.line), ['1 of 3 tweaked', '1 of 2 tweaked', '0 of 1 tweaked']);
|
|
119
120
|
assert.deepEqual(body.readings, { ok: true, at: 'docs/page-readings.json' });
|
|
120
121
|
assert.deepEqual(body.inventory, { at: 'docs/page-inventory.json' });
|
|
121
122
|
});
|
|
@@ -65,8 +65,8 @@ const errDetail = (b) => (b && b.error && b.error.details) || {};
|
|
|
65
65
|
// and an unplaced line.
|
|
66
66
|
const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
|
|
67
67
|
const STUDIO_LINES = [
|
|
68
|
-
L('L0001', '
|
|
69
|
-
L('L0002', '
|
|
68
|
+
L('L0001', 'shell bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
|
|
69
|
+
L('L0002', 'shell bar', 'Ctrl K', 'shared'),
|
|
70
70
|
L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
|
|
71
71
|
L('L0004', 'greeting', '{…} pages tweaked', 'placed', { file: 'modules/hall-ui/public/studio.js', line: 88, string_id: 'st0004' }),
|
|
72
72
|
L('L0005', 'greeting', 'Your desk is quiet today', 'unplaced'),
|
|
@@ -360,6 +360,34 @@ try {
|
|
|
360
360
|
guarded._setList(null);
|
|
361
361
|
}
|
|
362
362
|
});
|
|
363
|
+
|
|
364
|
+
// Task 1004904: a shared-chrome page is a page like any other, whole path.
|
|
365
|
+
await test('a chrome page: claimed, drafted and submitted through the real routes, its lines placed from the first occurrence', async () => {
|
|
366
|
+
const TOP = '/copy-desk/pages/builders:top-bar';
|
|
367
|
+
const chromeLine = (key, text, row) => L(key, 'top bar', text, 'shared', row);
|
|
368
|
+
const r0 = readingsWith(
|
|
369
|
+
{ hash: 'studio-h1', lines: [chromeLine('L0001', 'Projects', { file: 'modules/hall-ui/public/shell.js', line: 41, string_id: 'sh0041' }), chromeLine('L0002', 'Sign out', { file: 'modules/hall-ui/public/shell.js', line: 42, string_id: 'sh0042' }), ...STUDIO_LINES.slice(2)] },
|
|
370
|
+
{ hash: 'index-h2', lines: INDEX_LINES },
|
|
371
|
+
);
|
|
372
|
+
writeReadings(r0);
|
|
373
|
+
const studio = await call('GET', '/copy-desk/pages/builders:studio', ALICE);
|
|
374
|
+
assert.equal(studio.status, 200);
|
|
375
|
+
const page = await call('GET', TOP, ALICE);
|
|
376
|
+
assert.equal(page.status, 200, JSON.stringify(page.body));
|
|
377
|
+
assert.equal((await call('POST', `${TOP}/claim`, ALICE)).status, 201);
|
|
378
|
+
const read = await call('GET', `${TOP}/draft`, ALICE);
|
|
379
|
+
assert.equal(read.status, 200, JSON.stringify(read.body));
|
|
380
|
+
const hash = read.body.reading.reading_hash || read.body.reading_hash;
|
|
381
|
+
assert.ok(hash, 'the chrome page has a reading hash of its own');
|
|
382
|
+
assert.deepEqual(read.body.reading.lines.map((l) => l.text), ['Projects', 'Sign out']);
|
|
383
|
+
const saved = await call('PUT', `${TOP}/draft`, ALICE, { reading_hash: hash, lines: [{ key: 'L0001', after: 'Your projects' }] });
|
|
384
|
+
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
|
385
|
+
const sub = await call('POST', `${TOP}/submit`, ALICE);
|
|
386
|
+
assert.equal(sub.status, 200, JSON.stringify(sub.body));
|
|
387
|
+
const round = db.tasks.find((t) => t.source_ref === 'page-tweak/builders:top-bar/r1');
|
|
388
|
+
const batch = pages.parseBatchBlock(round.description).batch;
|
|
389
|
+
assert.deepEqual(batch.lines.map((l) => [l.key, l.file, l.line, l.before, l.after]), [['L0001', 'modules/hall-ui/public/shell.js', 41, 'Projects', 'Your projects']]);
|
|
390
|
+
});
|
|
363
391
|
} finally {
|
|
364
392
|
server.close();
|
|
365
393
|
pageData._setRoot(null);
|