@bongos/core 1.19.374 → 1.19.376
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 +99 -54
- 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 +9 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0194-joinability-one-door-decision.md +42 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +158 -5
- package/docs/api-reference.md +6 -4
- package/docs/copy-inventory.md +373 -356
- package/docs/copy-registry.json +646 -452
- package/migrations/core_227_task_created_by.sql +83 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/government/catalog.js +1 -1
- package/modules/hall-ui/public/builders.js +5 -3
- package/modules/hall-ui/public/goals-lib.js +5 -5
- package/modules/hall-ui/public/goals-page.js +54 -23
- package/modules/hall-ui/public/goals-render.js +15 -0
- package/modules/hall-ui/public/hall-render.js +63 -22
- package/modules/hall-ui/public/my-projects.js +6 -1
- package/modules/hall-ui/public/task-lib.js +29 -0
- package/modules/hall-ui/public/watch.css +8 -0
- package/modules/hall-ui/public/watch.js +56 -3
- package/modules/hall-ui/public/work.js +117 -5
- package/modules/ideas/inbox.js +4 -0
- package/modules/lifecycle/db-tasks.js +61 -5
- package/modules/lifecycle/goal-rollup.js +118 -0
- package/modules/lifecycle/routes/goals.js +26 -0
- package/modules/lifecycle/routes/task-write-routes.js +3 -0
- package/modules/lifecycle/routes/tasks.js +47 -1
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +87 -1
- package/modules/platform-identity/routes/my-projects.js +35 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +169 -3
- package/scripts/gds/login.js +24 -3
- package/src/bongos/auth-admission.js +50 -5
- package/src/bongos/auth.js +2 -1
- package/src/bongos/routes/auth.js +15 -0
- package/src/branding.js +3 -2
- package/src/module-api.js +22 -1
- package/tests/access_requests_invite.mjs +165 -0
- package/tests/auth_admission_gate.mjs +42 -0
- package/tests/goal_sort_staleness.mjs +286 -0
- package/tests/goal_subtitle.mjs +6 -1
- package/tests/goals_lib.mjs +16 -4
- package/tests/join_relay_outcome.mjs +61 -0
- package/tests/joinability_apply.mjs +72 -0
- package/tests/joinability_invite_only.mjs +192 -0
- package/tests/joinability_open.mjs +89 -0
- package/tests/module_api.mjs +1 -0
- package/tests/projects_hub.mjs +76 -2
- package/tests/projects_hub_pre_uat.mjs +4 -2
- package/tests/public_landing_projects.mjs +10 -3
|
@@ -46,6 +46,45 @@
|
|
|
46
46
|
} = window.OTBTaskLib; // deHashRefs is not called here — it is used inside buildPromptForTask
|
|
47
47
|
// Shared component kit (hall-kit.js, task 2166): tabs, pager, filter bar.
|
|
48
48
|
const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar } = window.OTBKit;
|
|
49
|
+
// task 1003278 — the shared staleness vocabulary + activity clock (task-lib.js,
|
|
50
|
+
// one definition for both tiers) and the creator axis.
|
|
51
|
+
const { taskActivityMs, STALE_CHOICES, isStale } = window.OTBTaskLib;
|
|
52
|
+
// task 1003278 — Creator and Age are SERVER-side axes: the board asks the feed
|
|
53
|
+
// a narrower question rather than downloading it and reducing it here. That is
|
|
54
|
+
// the criterion's explicit branch ("if it does, the new axes filter and page
|
|
55
|
+
// SERVER-side"), and it is why changing either control re-fetches instead of
|
|
56
|
+
// just re-rendering. Every other control stays client-side over what came
|
|
57
|
+
// back, exactly as before — those narrow an already-loaded page, this narrows
|
|
58
|
+
// what gets loaded.
|
|
59
|
+
// The Backlog group, BOUNDED and server-filtered. This read measured 690,625
|
|
60
|
+
// bytes for ~298 rows on 2026-08-25 and had no ceiling at all — it is
|
|
61
|
+
// `SELECT * FROM tasks WHERE status='backlog'`, so it grew with the project
|
|
62
|
+
// forever. Its own function because Creator/Age re-fetch it, same as the
|
|
63
|
+
// claimable feed. Best-effort: on failure the section stays as it was.
|
|
64
|
+
function loadBacklog() {
|
|
65
|
+
const q = serverFilterQuery().replace(/^\?/, '&');
|
|
66
|
+
return getJSON(`${API}/tasks?status=backlog&limit=${BACKLOG_LIMIT}${q}`)
|
|
67
|
+
.then((r) => { taskState.backlog = r.tasks || []; renderBacklog(taskState.backlog); })
|
|
68
|
+
.catch(() => { /* backlog stays as it was */ });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The last server-facing filter query we actually fetched with. Compared on
|
|
72
|
+
// every filter change to decide re-fetch vs re-render.
|
|
73
|
+
let lastServerFilterQuery = '';
|
|
74
|
+
|
|
75
|
+
function serverFilterQuery() {
|
|
76
|
+
const parts = [];
|
|
77
|
+
const creator = $('#f-creator') ? $('#f-creator').value : '';
|
|
78
|
+
const stale = $('#f-stale') ? $('#f-stale').value : '';
|
|
79
|
+
if (creator) parts.push(`created_by=${encodeURIComponent(creator === '__none__' ? 'none' : creator)}`);
|
|
80
|
+
if (stale) parts.push(`stale_days=${encodeURIComponent(stale)}`);
|
|
81
|
+
return parts.length ? `?${parts.join('&')}` : '';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// task 1003278 — the ceiling on the Backlog read. Comfortably above today's
|
|
85
|
+
// ~300 rows so nothing visibly changes now, and low enough that the page can
|
|
86
|
+
// never be asked to hold the whole table as the project grows.
|
|
87
|
+
const BACKLOG_LIMIT = 500;
|
|
49
88
|
const fmtDate = (iso) => window.OTB.fmtDate(iso);
|
|
50
89
|
const toast = (msg) => window.OTB.toast(msg, { duration: 2200, clear: true });
|
|
51
90
|
const curLabel = (caps) => { const l = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits'; return caps ? l.charAt(0).toUpperCase() + l.slice(1) : l; };
|
|
@@ -255,6 +294,11 @@
|
|
|
255
294
|
const kFilter = $('#f-kind').value;
|
|
256
295
|
const dFilter = $('#f-discipline').value;
|
|
257
296
|
const nFilter = $('#f-newcomer') ? $('#f-newcomer').value : '';
|
|
297
|
+
// task 1003278 — creator + staleness. '__none__' on Creator isolates the
|
|
298
|
+
// unattributed (created before tasks.created_by existed, or by a system path)
|
|
299
|
+
// — the goals owner filter's 'system / unattributed' convention.
|
|
300
|
+
const cFilter = $('#f-creator') ? $('#f-creator').value : '';
|
|
301
|
+
const staleFilter = $('#f-stale') ? $('#f-stale').value : '';
|
|
258
302
|
const sortBy = $('#f-sort').value;
|
|
259
303
|
const sQuery = searchQuery();
|
|
260
304
|
|
|
@@ -267,6 +311,13 @@
|
|
|
267
311
|
if (kFilter && t.kind !== kFilter) return false;
|
|
268
312
|
if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
|
|
269
313
|
if (nFilter === 'yes' && !t.newcomer_friendly) return false;
|
|
314
|
+
// Creator + Age were applied by the SERVER (serverFilterQuery), so they are
|
|
315
|
+
// re-checked here only as a belt on a stale render — never as the primary
|
|
316
|
+
// filter. Re-applying them is cheap and keeps the view honest if a poll
|
|
317
|
+
// lands between a control change and its re-fetch.
|
|
318
|
+
if (cFilter === '__none__' && t.created_by != null) return false;
|
|
319
|
+
if (cFilter && cFilter !== '__none__' && String(t.created_by) !== cFilter) return false;
|
|
320
|
+
if (staleFilter && !isStale(taskActivityMs(t), staleFilter)) return false;
|
|
270
321
|
return true;
|
|
271
322
|
});
|
|
272
323
|
|
|
@@ -295,6 +346,10 @@
|
|
|
295
346
|
} else if (sortBy === 'id') {
|
|
296
347
|
// task 1830: sort by task number, ascending (lowest/oldest first).
|
|
297
348
|
list = list.slice().sort((a, b) => Number(a.id) - Number(b.id));
|
|
349
|
+
} else if (sortBy === 'stale') {
|
|
350
|
+
// task 1003278: what is rotting — least-recently-touched first, on the
|
|
351
|
+
// shared activity clock (updated_at ∨ shipped_at, task-lib.js).
|
|
352
|
+
list = list.slice().sort((a, b) => taskActivityMs(a) - taskActivityMs(b));
|
|
298
353
|
}
|
|
299
354
|
|
|
300
355
|
const count = $('#claim-count');
|
|
@@ -405,6 +460,44 @@
|
|
|
405
460
|
}
|
|
406
461
|
}
|
|
407
462
|
|
|
463
|
+
// task 1003278: the Creator filter — options are the builders who created the
|
|
464
|
+
// tasks on the board (claimable + backlog), labelled via the directory the
|
|
465
|
+
// goals page also reads (GET /builders/directory, best-effort — 'builder #id'
|
|
466
|
+
// until it lands). '__none__' isolates the unattributed: every task created
|
|
467
|
+
// before tasks.created_by existed (core_227 deliberately backfills nothing —
|
|
468
|
+
// a guessed attribution is a confident lie), plus system paths like the
|
|
469
|
+
// newcomer restock.
|
|
470
|
+
async function populateCreatorFilter() {
|
|
471
|
+
const sel = $('#f-creator');
|
|
472
|
+
if (!sel) return;
|
|
473
|
+
const rows = [...taskState.all, ...(taskState.backlog || [])];
|
|
474
|
+
const ids = [...new Set(rows.filter((t) => t.created_by != null).map((t) => String(t.created_by)))];
|
|
475
|
+
let byId = new Map();
|
|
476
|
+
if (ids.length) {
|
|
477
|
+
try {
|
|
478
|
+
const d = await getJSON(`${API}/builders/directory`);
|
|
479
|
+
byId = new Map((d.builders || []).map((b) => [String(b.id), b]));
|
|
480
|
+
} catch (_) { /* labels fall back to builder #id */ }
|
|
481
|
+
}
|
|
482
|
+
const label = (id) => {
|
|
483
|
+
const b = byId.get(id);
|
|
484
|
+
return b ? (b.display_name || '@' + b.github_login) : `builder #${id}`;
|
|
485
|
+
};
|
|
486
|
+
sel.length = 1; // keep the leading "— any —" option
|
|
487
|
+
for (const id of ids.sort((a, b) => label(a).localeCompare(label(b)))) {
|
|
488
|
+
const opt = document.createElement('option');
|
|
489
|
+
opt.value = id;
|
|
490
|
+
opt.textContent = label(id);
|
|
491
|
+
sel.appendChild(opt);
|
|
492
|
+
}
|
|
493
|
+
if (rows.some((t) => t.created_by == null)) {
|
|
494
|
+
const opt = document.createElement('option');
|
|
495
|
+
opt.value = '__none__';
|
|
496
|
+
opt.textContent = 'system / unattributed';
|
|
497
|
+
sel.appendChild(opt);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
408
501
|
// task 1755: the Goal filter — options are the goals present on the claimable
|
|
409
502
|
// feed, labeled by title (falls back to 'Goal #id' before titles load).
|
|
410
503
|
// '__none__' isolates ungoaled tasks. Populated once after the feed loads.
|
|
@@ -983,7 +1076,7 @@
|
|
|
983
1076
|
// the same way loadAll does so a transient failure doesn't blank the board.
|
|
984
1077
|
async function refreshLive() {
|
|
985
1078
|
try {
|
|
986
|
-
const claimResp = await getJSON(`${API}/tasks/claimable`);
|
|
1079
|
+
const claimResp = await getJSON(`${API}/tasks/claimable${serverFilterQuery()}`);
|
|
987
1080
|
renderInFlight(claimResp.active_claims || []);
|
|
988
1081
|
taskState.all = claimResp.claimable || [];
|
|
989
1082
|
renderClaimable();
|
|
@@ -1072,9 +1165,7 @@
|
|
|
1072
1165
|
|
|
1073
1166
|
// task 1754: the Backlog group — not-yet-ready tasks. Best-effort + independent
|
|
1074
1167
|
// of the claimable feed; leaves the section hidden on failure.
|
|
1075
|
-
|
|
1076
|
-
.then((r) => { taskState.backlog = r.tasks || []; renderBacklog(taskState.backlog); })
|
|
1077
|
-
.catch(() => { /* backlog stays hidden */ });
|
|
1168
|
+
loadBacklog();
|
|
1078
1169
|
|
|
1079
1170
|
// task 1755: goal id → title map for the Goal filter (best-effort).
|
|
1080
1171
|
taskState.goalTitles = {};
|
|
@@ -1096,7 +1187,7 @@
|
|
|
1096
1187
|
// empty-but-honest (the gate also blocks active_claims, so say so).
|
|
1097
1188
|
try {
|
|
1098
1189
|
const [claimResp, versionsResp] = await Promise.all([
|
|
1099
|
-
getJSON(`${API}/tasks/claimable`),
|
|
1190
|
+
getJSON(`${API}/tasks/claimable${serverFilterQuery()}`),
|
|
1100
1191
|
getJSON(`${API}/versions`).catch(() => ({ versions: [] })),
|
|
1101
1192
|
]);
|
|
1102
1193
|
taskState.versions = versionsResp.versions || [];
|
|
@@ -1106,6 +1197,7 @@
|
|
|
1106
1197
|
taskState.all = claimResp.claimable || [];
|
|
1107
1198
|
renderClaimable();
|
|
1108
1199
|
populateGoalFilter(); // task 1755
|
|
1200
|
+
populateCreatorFilter().catch(() => {}); // task 1003278 — fire-and-forget, the owner-filter posture
|
|
1109
1201
|
// task 2170: a shared URL's goal filter can only apply once the options
|
|
1110
1202
|
// exist — restore it now and re-render if it changed anything.
|
|
1111
1203
|
if (restoreUrlFilter('f-goal')) renderClaimable();
|
|
@@ -1168,6 +1260,7 @@
|
|
|
1168
1260
|
{ value: 'minutes', label: 'minutes (low → high)' },
|
|
1169
1261
|
{ value: 'priority', label: 'priority (P0 first)' },
|
|
1170
1262
|
{ value: 'id', label: 'task number (low → high)' },
|
|
1263
|
+
{ value: 'stale', label: 'stalest first (untouched longest)' },
|
|
1171
1264
|
],
|
|
1172
1265
|
},
|
|
1173
1266
|
{ id: 'f-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
|
|
@@ -1191,6 +1284,11 @@
|
|
|
1191
1284
|
label: 'Newcomer',
|
|
1192
1285
|
options: [{ value: '', label: '— any —' }, { value: 'yes', label: 'newcomer-friendly only' }],
|
|
1193
1286
|
},
|
|
1287
|
+
// task 1003278 — Creator options fill after the feeds land
|
|
1288
|
+
// (populateCreatorFilter, the f-goal pattern); staleness buckets are the
|
|
1289
|
+
// shared STALE_CHOICES so both tiers offer the same lenses.
|
|
1290
|
+
{ id: 'f-creator', label: 'Creator', options: [{ value: '', label: '— any —' }] },
|
|
1291
|
+
{ id: 'f-stale', label: 'Age', options: STALE_CHOICES },
|
|
1194
1292
|
{
|
|
1195
1293
|
id: 'f-fit',
|
|
1196
1294
|
label: 'Run mode',
|
|
@@ -1203,6 +1301,20 @@
|
|
|
1203
1301
|
onChange: () => {
|
|
1204
1302
|
pagers.ready.page = 1;
|
|
1205
1303
|
pagers.backlog.page = 1;
|
|
1304
|
+
// task 1003278 — Creator and Age are answered by the SERVER, so a change
|
|
1305
|
+
// to either re-fetches a narrower feed instead of re-reducing the wide
|
|
1306
|
+
// one. The test is whether the SERVER-FACING QUESTION changed, not which
|
|
1307
|
+
// control fired: makeFilterBar's onChange is handed every value, not the
|
|
1308
|
+
// changed id (hall-kit.js:269), and comparing the query is the direct
|
|
1309
|
+
// question anyway — a control that does not appear in it can never cost
|
|
1310
|
+
// a round-trip, which is what keeps the other filters instant.
|
|
1311
|
+
const q = serverFilterQuery();
|
|
1312
|
+
if (q !== lastServerFilterQuery) {
|
|
1313
|
+
lastServerFilterQuery = q;
|
|
1314
|
+
refreshLive();
|
|
1315
|
+
loadBacklog();
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1206
1318
|
renderClaimable();
|
|
1207
1319
|
renderBacklog();
|
|
1208
1320
|
},
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -429,6 +429,10 @@ async function routeIdeaToGoal({
|
|
|
429
429
|
// (db.findTaskBySourceRef): one idea can only ever mint one routed task.
|
|
430
430
|
source: 'idea-route',
|
|
431
431
|
sourceRef: `idea-${idea.id}`,
|
|
432
|
+
// task 1003278 — the filer created this task, via the route. capturedBy is
|
|
433
|
+
// already the authenticated filer's id (never body-supplied), so routing an
|
|
434
|
+
// idea attributes the task to the person whose idea it was.
|
|
435
|
+
createdBy: capturedBy ?? null,
|
|
432
436
|
}, { client });
|
|
433
437
|
|
|
434
438
|
// d. The lineage row — origin='route' (ideas_003 widened the CHECK for
|
|
@@ -72,6 +72,11 @@ async function createTask({
|
|
|
72
72
|
// (requiresRank stays null for the goal vector → derived purely from touches +
|
|
73
73
|
// sensitivity), so goal_id never influences the requires_rank clamp.
|
|
74
74
|
goalId = null,
|
|
75
|
+
// task 1003278: WHO created it — the builder id from the authenticated request.
|
|
76
|
+
// Nullable, appended at the end (the goals bind-order convention): a system
|
|
77
|
+
// path with no builder in the request (a seed, a migration backfill) passes
|
|
78
|
+
// nothing and the row honestly reads unattributed, never guessed.
|
|
79
|
+
createdBy = null,
|
|
75
80
|
}, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
|
|
76
81
|
// task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
|
|
77
82
|
// from touches + sensitivity, then let an explicit requiresRank override only
|
|
@@ -112,12 +117,12 @@ async function createTask({
|
|
|
112
117
|
est_minutes, est_cost_usd, manual_degree, priority,
|
|
113
118
|
automation_tag, credits_reward, source, source_ref, value_summary,
|
|
114
119
|
kind, discipline, parallel_safe, newcomer_friendly, needs_migration,
|
|
115
|
-
security_sensitive, requires_rank, module_key, goal_id, promoted_at)
|
|
120
|
+
security_sensitive, requires_rank, module_key, goal_id, created_by, promoted_at)
|
|
116
121
|
VALUES ($1, $2, $3, $4, $5,
|
|
117
122
|
$6, $7, $8, $9,
|
|
118
123
|
$10, $11, $12, $13, $14,
|
|
119
124
|
$15, $16, $17, $18, $19,
|
|
120
|
-
$20, $21, $22, $23,
|
|
125
|
+
$20, $21, $22, $23, $24,
|
|
121
126
|
CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END)
|
|
122
127
|
RETURNING *`,
|
|
123
128
|
[
|
|
@@ -144,6 +149,7 @@ async function createTask({
|
|
|
144
149
|
requiresRankEffective,
|
|
145
150
|
moduleKey ?? null,
|
|
146
151
|
goalIdEffective ?? null,
|
|
152
|
+
createdBy ?? null,
|
|
147
153
|
]
|
|
148
154
|
);
|
|
149
155
|
// task 1677 / ADR 0096: audit each creation-time auto-assignment so the trail is
|
|
@@ -473,7 +479,7 @@ async function getTask(id) {
|
|
|
473
479
|
}
|
|
474
480
|
|
|
475
481
|
|
|
476
|
-
async function listTasks({ versionId, status, kind, discipline, goalId } = {}) {
|
|
482
|
+
async function listTasks({ versionId, status, kind, discipline, goalId, createdBy, staleDays, limit, offset } = {}) {
|
|
477
483
|
const params = [];
|
|
478
484
|
const where = [];
|
|
479
485
|
if (versionId) {
|
|
@@ -496,13 +502,44 @@ async function listTasks({ versionId, status, kind, discipline, goalId } = {}) {
|
|
|
496
502
|
params.push(goalId);
|
|
497
503
|
where.push(`goal_id = $${params.length}`);
|
|
498
504
|
}
|
|
505
|
+
// task 1003278 — the creator + staleness axes, server-side. They exist HERE and
|
|
506
|
+
// not only in the browser because the board was reducing the whole table to
|
|
507
|
+
// answer them: GET /tasks?status=backlog measured 690,625 bytes on 2026-08-25,
|
|
508
|
+
// and the Goals page's whole-version read 5.2 MB. `'none'` selects the
|
|
509
|
+
// unattributed (created before tasks.created_by existed, or by a system path) —
|
|
510
|
+
// the goals owner-filter convention, and a real question ("what has no owner").
|
|
511
|
+
if (createdBy === 'none') {
|
|
512
|
+
where.push('created_by IS NULL');
|
|
513
|
+
} else if (createdBy) {
|
|
514
|
+
params.push(createdBy);
|
|
515
|
+
where.push(`created_by = $${params.length}`);
|
|
516
|
+
}
|
|
517
|
+
// Untouched for N days, on the SAME clock the clients use: max(updated_at,
|
|
518
|
+
// shipped_at) — a ship path can stamp shipped_at without touching updated_at.
|
|
519
|
+
// A row with neither is stale by construction (GREATEST of two epochs), which
|
|
520
|
+
// matches isStale()'s treatment of an undated row: a row nobody can date is
|
|
521
|
+
// what a rot-hunt must not hide.
|
|
522
|
+
if (staleDays) {
|
|
523
|
+
params.push(staleDays);
|
|
524
|
+
where.push(
|
|
525
|
+
`GREATEST(COALESCE(updated_at, 'epoch'::timestamptz), COALESCE(shipped_at, 'epoch'::timestamptz))
|
|
526
|
+
<= now() - ($${params.length}::int * INTERVAL '1 day')`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
499
529
|
const sql =
|
|
500
530
|
`SELECT t.*, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
|
|
501
531
|
pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
|
|
502
532
|
FROM tasks t` +
|
|
503
533
|
(where.length ? ` WHERE ${where.join(' AND ')}` : '') +
|
|
504
534
|
` ORDER BY priority NULLS LAST, created_at`;
|
|
505
|
-
|
|
535
|
+
// BOUND, not interpolated. Both call sites validate to a finite non-negative
|
|
536
|
+
// integer today, so string interpolation would not be exploitable — but a
|
|
537
|
+
// query builder that is only safe because of who happens to call it is one
|
|
538
|
+
// careless caller away from not being safe at all (raised in review).
|
|
539
|
+
let tail = '';
|
|
540
|
+
if (limit) { params.push(Number(limit)); tail += ` LIMIT $${params.length}`; }
|
|
541
|
+
if (offset) { params.push(Number(offset)); tail += ` OFFSET $${params.length}`; }
|
|
542
|
+
const { rows } = await pool.query(sql + tail, params);
|
|
506
543
|
return rows;
|
|
507
544
|
}
|
|
508
545
|
|
|
@@ -571,7 +608,7 @@ const SESSION_FIT_SQL = `
|
|
|
571
608
|
END AS fits_session_modes
|
|
572
609
|
FROM tasks t`;
|
|
573
610
|
|
|
574
|
-
async function listClaimableTasks({ versionId = null, builderId = null, discipline = null, fit = null } = {}) {
|
|
611
|
+
async function listClaimableTasks({ versionId = null, builderId = null, discipline = null, fit = null, createdBy = null, staleDays = null } = {}) {
|
|
575
612
|
const params = [];
|
|
576
613
|
const where = [];
|
|
577
614
|
let goalJoin = '';
|
|
@@ -584,6 +621,25 @@ async function listClaimableTasks({ versionId = null, builderId = null, discipli
|
|
|
584
621
|
params.push(discipline);
|
|
585
622
|
where.push(`v.discipline = $${params.length}`);
|
|
586
623
|
}
|
|
624
|
+
// task 1003278 — the SAME creator + staleness axes GET /tasks offers, so the
|
|
625
|
+
// Work Board asks the server a narrow question instead of pulling the feed and
|
|
626
|
+
// reducing it in the browser. 'none' selects the unattributed; the staleness
|
|
627
|
+
// clock is max(updated_at, shipped_at), identical to listTasks, the rollup SQL
|
|
628
|
+
// and task-lib.js — four expressions of one rule, pinned against each other by
|
|
629
|
+
// tests/goal_sort_staleness.mjs.
|
|
630
|
+
if (createdBy === 'none') {
|
|
631
|
+
where.push('v.created_by IS NULL');
|
|
632
|
+
} else if (createdBy) {
|
|
633
|
+
params.push(createdBy);
|
|
634
|
+
where.push(`v.created_by = $${params.length}`);
|
|
635
|
+
}
|
|
636
|
+
if (staleDays) {
|
|
637
|
+
params.push(staleDays);
|
|
638
|
+
where.push(
|
|
639
|
+
`GREATEST(COALESCE(v.updated_at, 'epoch'::timestamptz), COALESCE(v.shipped_at, 'epoch'::timestamptz))
|
|
640
|
+
<= now() - ($${params.length}::int * INTERVAL '1 day')`
|
|
641
|
+
);
|
|
642
|
+
}
|
|
587
643
|
if (builderId) {
|
|
588
644
|
params.push(builderId);
|
|
589
645
|
where.push(`NOT EXISTS (
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/goal-rollup.js — per-goal task rollups, computed in Postgres
|
|
4
|
+
// instead of in the browser (task 1003278, goal 1000072, criterion
|
|
5
|
+
// legibility-sort-and-staleness).
|
|
6
|
+
//
|
|
7
|
+
// THE MEASUREMENT THAT FORCED THIS. The Goals page derived each goal's readiness
|
|
8
|
+
// counters, its last-activity timestamp and (task 1003278) its craft set by
|
|
9
|
+
// FETCHING EVERY TASK IN THE VERSION — `GET /tasks?version=BONGOS-V1&include=deps`
|
|
10
|
+
// — and reducing them client-side. On the real corpus that request is
|
|
11
|
+
// **5,229,455 bytes for ~1,400 tasks**, measured 2026-08-25, to produce roughly
|
|
12
|
+
// forty small counter objects. The criterion's own words: "~1,357 tasks on
|
|
13
|
+
// BONGOS-V1 alone. A view that does not survive that number is not a solution."
|
|
14
|
+
// It did not survive it; it merely did not fall over.
|
|
15
|
+
//
|
|
16
|
+
// So the reduction moves to where the rows already are. One query per version
|
|
17
|
+
// returns one row per goal, and the payload stops scaling with the task count.
|
|
18
|
+
//
|
|
19
|
+
// THE BUCKETS ARE THE CLIENT'S, VERBATIM. goals-lib.js `readinessOf` is the
|
|
20
|
+
// definition of record and this SQL mirrors it exactly — done (shipped |
|
|
21
|
+
// confirmed | completed) · cut (abandoned | dropped, excluded from the total the
|
|
22
|
+
// way migration 017 keeps them out of version_progress) · active · ready ·
|
|
23
|
+
// blocked (backlog with >=1 unshipped dependency) · backlog. A divergence here
|
|
24
|
+
// would show a person different numbers depending on which surface they opened,
|
|
25
|
+
// so tests/goal_sort_staleness.mjs pins the two against each other.
|
|
26
|
+
//
|
|
27
|
+
// LAST ACTIVITY is `max(updated_at, shipped_at)` — the same "any task activity
|
|
28
|
+
// counts" rule the client uses (task 1002965, the owner's call), now expressed
|
|
29
|
+
// once in SQL and once in task-lib.js because the browser still needs it for
|
|
30
|
+
// rows it holds itself.
|
|
31
|
+
|
|
32
|
+
const api = require('../../src/module-api');
|
|
33
|
+
const { pool } = api;
|
|
34
|
+
|
|
35
|
+
// The status vocabularies, kept beside the SQL that uses them so a reader can
|
|
36
|
+
// check them against goals-lib.js in one glance.
|
|
37
|
+
const DONE_STATUSES = ['shipped', 'confirmed', 'completed'];
|
|
38
|
+
const CUT_STATUSES = ['abandoned', 'dropped'];
|
|
39
|
+
|
|
40
|
+
// goalRollups — one row per goal that has at least one task in `versionId`.
|
|
41
|
+
// Returns [{ goal_id, done, active, ready, blocked, backlog, cut, total,
|
|
42
|
+
// last_activity_at, disciplines[], next_ready_id, next_ready_title }].
|
|
43
|
+
//
|
|
44
|
+
// `blocked` needs the dependency edges, so the CTE resolves each backlog task's
|
|
45
|
+
// unshipped-dependency count once rather than per row. deps.pool honors the #538
|
|
46
|
+
// test seam (NODE_ENV==='test' only), like every other helper in this module.
|
|
47
|
+
// VISIBILITY. A rollup is a COUNT, and counts on a private goal are not the
|
|
48
|
+
// secret — its prose is (ADR 0112's leak rule: a non-member must not learn a
|
|
49
|
+
// private goal's title or scope). But `next_ready_title` is a real task title
|
|
50
|
+
// off a real goal, so this returns it ONLY to a member or an Archon. Everyone
|
|
51
|
+
// else gets the counters and a null title: the board still renders the ring and
|
|
52
|
+
// the readiness line, and the "Claim next" affordance simply has no name to show
|
|
53
|
+
// — which is correct, because they cannot claim it either (task 1003137's
|
|
54
|
+
// membership gate). Raised in review; the pre-existing GET /goals listing has
|
|
55
|
+
// the same unfiltered shape, and tightening THAT is its own task, not something
|
|
56
|
+
// to smuggle in here.
|
|
57
|
+
async function goalRollups({ versionId = null, viewerId = null, viewerIsArchon = false } = {}, deps = {}) {
|
|
58
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
59
|
+
const { rows } = await activePool.query(
|
|
60
|
+
`WITH scoped AS (
|
|
61
|
+
SELECT t.id, t.goal_id, t.status, t.title, t.priority, t.discipline,
|
|
62
|
+
GREATEST(COALESCE(t.updated_at, 'epoch'::timestamptz),
|
|
63
|
+
COALESCE(t.shipped_at, 'epoch'::timestamptz)) AS activity_at,
|
|
64
|
+
EXISTS (
|
|
65
|
+
SELECT 1 FROM dependencies d
|
|
66
|
+
JOIN tasks dep ON dep.id = d.to_id
|
|
67
|
+
WHERE d.from_kind = 'task' AND d.from_id = t.id
|
|
68
|
+
AND d.to_kind = 'task' AND dep.status <> 'shipped'
|
|
69
|
+
) AS has_unshipped_dep
|
|
70
|
+
FROM tasks t
|
|
71
|
+
WHERE t.goal_id IS NOT NULL
|
|
72
|
+
AND ($1::text IS NULL OR t.version_id = $1)
|
|
73
|
+
), bucketed AS (
|
|
74
|
+
SELECT s.*,
|
|
75
|
+
CASE
|
|
76
|
+
WHEN s.status = ANY($2::text[]) THEN 'done'
|
|
77
|
+
WHEN s.status = ANY($3::text[]) THEN 'cut'
|
|
78
|
+
WHEN s.status = 'active' THEN 'active'
|
|
79
|
+
WHEN s.status = 'ready' THEN 'ready'
|
|
80
|
+
WHEN s.status = 'backlog' AND s.has_unshipped_dep THEN 'blocked'
|
|
81
|
+
ELSE 'backlog'
|
|
82
|
+
END AS bucket
|
|
83
|
+
FROM scoped s
|
|
84
|
+
), next_ready AS (
|
|
85
|
+
-- The goal's next claimable task, picked the way the board orders work:
|
|
86
|
+
-- priority first (NULLS LAST), then oldest id. DISTINCT ON gives one row
|
|
87
|
+
-- per goal without a second round trip.
|
|
88
|
+
SELECT DISTINCT ON (goal_id) goal_id, id, title
|
|
89
|
+
FROM bucketed
|
|
90
|
+
WHERE bucket = 'ready'
|
|
91
|
+
ORDER BY goal_id, priority NULLS LAST, id
|
|
92
|
+
)
|
|
93
|
+
SELECT b.goal_id::text AS goal_id,
|
|
94
|
+
COUNT(*) FILTER (WHERE b.bucket = 'done')::int AS done,
|
|
95
|
+
COUNT(*) FILTER (WHERE b.bucket = 'active')::int AS active,
|
|
96
|
+
COUNT(*) FILTER (WHERE b.bucket = 'ready')::int AS ready,
|
|
97
|
+
COUNT(*) FILTER (WHERE b.bucket = 'blocked')::int AS blocked,
|
|
98
|
+
COUNT(*) FILTER (WHERE b.bucket = 'backlog')::int AS backlog,
|
|
99
|
+
COUNT(*) FILTER (WHERE b.bucket = 'cut')::int AS cut,
|
|
100
|
+
COUNT(*) FILTER (WHERE b.bucket <> 'cut')::int AS total,
|
|
101
|
+
MAX(b.activity_at) AS last_activity_at,
|
|
102
|
+
ARRAY_AGG(DISTINCT COALESCE(b.discipline, 'unclassified')) AS disciplines,
|
|
103
|
+
MAX(nr.id)::text AS next_ready_id,
|
|
104
|
+
CASE WHEN bool_or(g.visibility IS DISTINCT FROM 'private')
|
|
105
|
+
OR $4::boolean
|
|
106
|
+
OR bool_or(gm.builder_id IS NOT NULL)
|
|
107
|
+
THEN MAX(nr.title) END AS next_ready_title
|
|
108
|
+
FROM bucketed b
|
|
109
|
+
JOIN goals g ON g.id = b.goal_id
|
|
110
|
+
LEFT JOIN next_ready nr ON nr.goal_id = b.goal_id
|
|
111
|
+
LEFT JOIN goal_members gm ON gm.goal_id = b.goal_id AND gm.builder_id = $5::bigint
|
|
112
|
+
GROUP BY b.goal_id`,
|
|
113
|
+
[versionId, DONE_STATUSES, CUT_STATUSES, viewerIsArchon === true, viewerId]
|
|
114
|
+
);
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { goalRollups, DONE_STATUSES, CUT_STATUSES };
|
|
@@ -29,6 +29,7 @@ const hierarchy = require('../hierarchy');
|
|
|
29
29
|
const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
|
|
30
30
|
const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
|
|
31
31
|
const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
|
|
32
|
+
const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
|
|
32
33
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
33
34
|
const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
34
35
|
const { validateOrRespond, parseId, LIMITS } = api;
|
|
@@ -145,6 +146,29 @@ module.exports = function buildGoalsRouter() {
|
|
|
145
146
|
}
|
|
146
147
|
});
|
|
147
148
|
|
|
149
|
+
// GET /goals/rollups?version= — one small row per goal: readiness counters, the
|
|
150
|
+
// last-activity timestamp, the craft set, and the next claimable task
|
|
151
|
+
// (task 1003278). It replaces the Goals page's whole-version task fetch, which
|
|
152
|
+
// measured 5,229,455 bytes for ~1,400 tasks on 2026-08-25 to produce ~40 counter
|
|
153
|
+
// objects. Registered BEFORE /goals/:id or Express routes 'rollups' to the param.
|
|
154
|
+
// rank: any-builder — the same read as the goal board it feeds.
|
|
155
|
+
router.get('/goals/rollups', auth.requireBuilder, async (req, res) => {
|
|
156
|
+
try {
|
|
157
|
+
// The viewer decides whether a PRIVATE goal's next-ready task TITLE comes
|
|
158
|
+
// back (counters always do — a count is not the secret, the prose is;
|
|
159
|
+
// ADR 0112). Identity from the authenticated request, never the query.
|
|
160
|
+
const rollups = await goalRollup.goalRollups({
|
|
161
|
+
versionId: req.query.version || null,
|
|
162
|
+
viewerId: req.builder.id,
|
|
163
|
+
viewerIsArchon: req.builder.rank === 'archon',
|
|
164
|
+
});
|
|
165
|
+
return res.json({ rollups });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
log.error('[gds] GET /goals/rollups', err);
|
|
168
|
+
return res.fail('rollups_failed', { status: 500, message: 'internal error' });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
148
172
|
// GET /goals?version=&status= — list goals (read).
|
|
149
173
|
// rank: any-builder — viewing the goal board.
|
|
150
174
|
router.get('/goals', auth.requireBuilder, async (req, res) => {
|
|
@@ -1150,6 +1174,8 @@ module.exports = function buildGoalsRouter() {
|
|
|
1150
1174
|
discipline,
|
|
1151
1175
|
source: 'goal-authored',
|
|
1152
1176
|
goalId: goal.id,
|
|
1177
|
+
// task 1003278 — attribution from the authenticated request (never the body).
|
|
1178
|
+
createdBy: req.builder.id,
|
|
1153
1179
|
});
|
|
1154
1180
|
|
|
1155
1181
|
// Attach the sub-task parent (validated above; attachParent re-checks the
|
|
@@ -195,6 +195,9 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
195
195
|
requiresRank: body.requires_rank ?? null,
|
|
196
196
|
moduleKey: body.module_key ?? null,
|
|
197
197
|
goalId: body.goal_id ?? null,
|
|
198
|
+
// task 1003278 — attribution, from the AUTHENTICATED request, never the
|
|
199
|
+
// body: who created a task is a fact about the caller, not an input.
|
|
200
|
+
createdBy: req.builder.id,
|
|
198
201
|
});
|
|
199
202
|
// task 438: link resolved criteria (source='planning' — task-create is the
|
|
200
203
|
// planning vector). The ids were version-checked above, so this is a single
|
|
@@ -71,6 +71,25 @@ module.exports = function buildTasksRouter() {
|
|
|
71
71
|
const router = express.Router();
|
|
72
72
|
|
|
73
73
|
// rank: any-builder — reads (filtered task list); preserved per CRITICAL §4.
|
|
74
|
+
// task 1003278 — shared by GET /tasks and GET /tasks/claimable, so the two
|
|
75
|
+
// surfaces cannot drift on what a valid creator id or age window is. Returns
|
|
76
|
+
// undefined (absent), a number, or { bad } for the caller to turn into a 400.
|
|
77
|
+
const posInt = (raw, code, max) => {
|
|
78
|
+
if (raw === undefined || raw === '') return undefined;
|
|
79
|
+
const n = Number(raw);
|
|
80
|
+
if (!Number.isInteger(n) || n <= 0 || (max && n > max)) return { bad: code };
|
|
81
|
+
return n;
|
|
82
|
+
};
|
|
83
|
+
// 'none' is a REAL value here — the unattributed set ("what has no owner"), not
|
|
84
|
+
// a missing filter. Anything else must be a positive builder id.
|
|
85
|
+
function parseCreatedBy(raw) {
|
|
86
|
+
if (raw === undefined || raw === '') return undefined;
|
|
87
|
+
if (raw === 'none') return 'none';
|
|
88
|
+
const n = Number(raw);
|
|
89
|
+
return (Number.isInteger(n) && n > 0) ? n : { bad: 'bad_created_by' };
|
|
90
|
+
}
|
|
91
|
+
const CREATED_BY_MSG = "created_by must be a positive builder id, or 'none' for the unattributed.";
|
|
92
|
+
|
|
74
93
|
router.get('/tasks', auth.requireBuilder, async (req, res) => {
|
|
75
94
|
const { version, status, kind, discipline, goal_id } = req.query;
|
|
76
95
|
if (kind && !VALID_KINDS.has(kind)) {
|
|
@@ -88,7 +107,25 @@ module.exports = function buildTasksRouter() {
|
|
|
88
107
|
return res.fail('bad_goal_id', { status: 400, message: 'goal_id must be a positive integer.' });
|
|
89
108
|
}
|
|
90
109
|
}
|
|
91
|
-
|
|
110
|
+
// task 1003278 — created_by / stale_days / limit / offset, so a board can ask
|
|
111
|
+
// the server a narrow question instead of reducing the whole table in the
|
|
112
|
+
// browser. All four are optional; omitting them is exactly today's behaviour.
|
|
113
|
+
const { created_by: createdByRaw, stale_days: staleRaw, limit: limitRaw, offset: offsetRaw } = req.query;
|
|
114
|
+
const createdBy = parseCreatedBy(createdByRaw);
|
|
115
|
+
if (createdBy && createdBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
|
|
116
|
+
const staleDays = posInt(staleRaw, 'bad_stale_days', 3650);
|
|
117
|
+
if (staleDays && staleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
|
|
118
|
+
// The cap is the point of the parameter: an unbounded limit would be the
|
|
119
|
+
// whole-table read this axis exists to stop.
|
|
120
|
+
const limit = posInt(limitRaw, 'bad_limit', 1000);
|
|
121
|
+
if (limit && limit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer up to 1000.' });
|
|
122
|
+
const offset = posInt(offsetRaw, 'bad_offset');
|
|
123
|
+
if (offset && offset.bad) return res.fail('bad_offset', { status: 400, message: 'offset must be a positive integer.' });
|
|
124
|
+
|
|
125
|
+
const tasks = await db.listTasks({
|
|
126
|
+
versionId: version, status, kind, discipline, goalId,
|
|
127
|
+
createdBy, staleDays, limit, offset,
|
|
128
|
+
});
|
|
92
129
|
// ?include=deps (hall redesign task 1002439) — enrich each task with its
|
|
93
130
|
// dependency summary (`blocked_by[]` with per-target satisfied state, and a
|
|
94
131
|
// `blocks` count) so the Goals rollup + Tasks dependency view can paint
|
|
@@ -117,12 +154,21 @@ module.exports = function buildTasksRouter() {
|
|
|
117
154
|
if (fit && !VALID_FITS.has(fit)) {
|
|
118
155
|
return res.fail('bad_fit', 400, { valid: Array.from(VALID_FITS) });
|
|
119
156
|
}
|
|
157
|
+
// task 1003278 — the creator + staleness axes, so the Work Board narrows the
|
|
158
|
+
// feed at the SOURCE instead of downloading it and reducing it in the browser.
|
|
159
|
+
// Same validators as GET /tasks: one vocabulary, two surfaces.
|
|
160
|
+
const claimCreatedBy = parseCreatedBy(req.query.created_by);
|
|
161
|
+
if (claimCreatedBy && claimCreatedBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
|
|
162
|
+
const claimStaleDays = posInt(req.query.stale_days, 'bad_stale_days', 3650);
|
|
163
|
+
if (claimStaleDays && claimStaleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
|
|
120
164
|
const [claimable, active] = await Promise.all([
|
|
121
165
|
db.listClaimableTasks({
|
|
122
166
|
versionId: version || null,
|
|
123
167
|
builderId: req.builder.id,
|
|
124
168
|
discipline: discipline || null,
|
|
125
169
|
fit: fit || null,
|
|
170
|
+
createdBy: claimCreatedBy ?? null,
|
|
171
|
+
staleDays: claimStaleDays ?? null,
|
|
126
172
|
}),
|
|
127
173
|
db.listActiveClaims(),
|
|
128
174
|
]);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"key": "onboarding",
|
|
3
3
|
"title": "Onboarding (admission · setup · newcomer floor)",
|
|
4
4
|
"description": "The newcomer ADMISSION + ORIENTATION domain — the public access-request → Archon-approval gate, the server-ticked onboarding state machine (the 'Path into the City' checklist that graduates a Xenos to Thetes), the friction analyzer (feedback-without-asking on consented onboarding transcripts), and the evergreen newcomer-chore restock (a fresh first-builder task is always waiting). A core-domain module carved out of the GDS core in BV1.R81 (ADR 0093 §1); default-on because every instance onboards its own builders. The access-requests admission routes mount here; the core lifecycle (db.js claim/ship), auth callbacks, and the me/builders profile readers resolve the `onboarding` kernel PORT for the state-machine + restock surface they need — never importing the module.",
|
|
5
|
-
"coreVersion": "^1.
|
|
5
|
+
"coreVersion": "^1.19.374",
|
|
6
6
|
"default": true,
|
|
7
7
|
"maintenance": { "status": "core-maintained" },
|
|
8
8
|
"contributes": { "routes": ["onboarding"] },
|