@bongos/core 1.19.375 → 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 +47 -32
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +76 -5
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +128 -127
- package/docs/copy-registry.json +175 -156
- package/migrations/core_227_task_created_by.sql +83 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- 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/task-lib.js +29 -0
- 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/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/goal_sort_staleness.mjs +286 -0
- package/tests/goal_subtitle.mjs +6 -1
- package/tests/goals_lib.mjs +16 -4
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
-- core_227_task_created_by.sql — a task remembers who created it (task 1003278,
|
|
2
|
+
-- goal 1000072, criterion legibility-sort-and-staleness).
|
|
3
|
+
--
|
|
4
|
+
-- THE GAP. "You can find your own work" was impossible on the task tier: tasks
|
|
5
|
+
-- carried source/source_ref (which PIPELINE filed it) and shipped_by (who landed
|
|
6
|
+
-- it), but nothing said which PERSON created it. A goal has had created_by since
|
|
7
|
+
-- migration 160; a task had nothing, so the owner could not ask the board "what
|
|
8
|
+
-- did I create" — one of the exact questions goal 1000072 exists to answer.
|
|
9
|
+
--
|
|
10
|
+
-- NULLABLE, NO BACKFILL, ON PURPOSE. History is honestly unattributable: the
|
|
11
|
+
-- ~1,357 existing BONGOS-V1 tasks were created before this column, and guessing
|
|
12
|
+
-- (from shipped_by, from a claim, from the source string) would write confident
|
|
13
|
+
-- lies into an attribution column. NULL = created before attribution existed, or
|
|
14
|
+
-- by a system path with no builder in the request — the same convention as
|
|
15
|
+
-- goals.created_by (migration 160: "NULL = system-attributed"). The UI's filter
|
|
16
|
+
-- gets a 'system / unattributed' bucket, exactly like the goals owner filter.
|
|
17
|
+
--
|
|
18
|
+
-- Additive + forward-safe: every existing reader either SELECTs an explicit
|
|
19
|
+
-- column list or `t.*` (which tolerates a new column); nothing NOT NULL, nothing
|
|
20
|
+
-- renamed (tests/migrations_forward_safe.mjs).
|
|
21
|
+
--
|
|
22
|
+
-- THE VIEWS MUST BE REBUILT WITH IT. A Postgres view created with `SELECT t.*`
|
|
23
|
+
-- pins its column list at CREATE VIEW time — core_190's own header documents six
|
|
24
|
+
-- columns that silently never reached the claim feed this way, and the Work
|
|
25
|
+
-- Board's Creator filter reads created_by off exactly that feed. So this
|
|
26
|
+
-- migration re-runs core_190's DROP+CREATE verbatim (no WHERE/CASE change), the
|
|
27
|
+
-- same way core_190 itself repaired the 044-era views.
|
|
28
|
+
|
|
29
|
+
BEGIN;
|
|
30
|
+
|
|
31
|
+
ALTER TABLE tasks
|
|
32
|
+
ADD COLUMN IF NOT EXISTS created_by bigint REFERENCES builders(id);
|
|
33
|
+
|
|
34
|
+
COMMENT ON COLUMN tasks.created_by IS
|
|
35
|
+
'The builder who created this task (task 1003278). NULL = created before attribution existed, or by a system path with no builder in the request — never backfilled, because a guessed attribution is a confident lie. The goals.created_by convention (migration 160).';
|
|
36
|
+
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks (created_by);
|
|
38
|
+
|
|
39
|
+
-- Rebuild the two t.* views so created_by is projected (bodies identical to
|
|
40
|
+
-- core_190 — the wildcard re-expands to every current column).
|
|
41
|
+
|
|
42
|
+
DROP VIEW IF EXISTS claimable_tasks;
|
|
43
|
+
CREATE VIEW claimable_tasks AS
|
|
44
|
+
SELECT t.*,
|
|
45
|
+
pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
|
|
46
|
+
FROM tasks t
|
|
47
|
+
WHERE t.status = 'ready'
|
|
48
|
+
AND t.title NOT LIKE '\_\_smoke\_\_%' ESCAPE '\'
|
|
49
|
+
AND NOT EXISTS (
|
|
50
|
+
SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id
|
|
51
|
+
)
|
|
52
|
+
AND NOT EXISTS (
|
|
53
|
+
SELECT 1 FROM claims c
|
|
54
|
+
JOIN tasks t2 ON t2.id = c.task_id
|
|
55
|
+
WHERE c.released_at IS NULL
|
|
56
|
+
AND t2.touches && t.touches
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
DROP VIEW IF EXISTS tasks_session_fit;
|
|
60
|
+
CREATE VIEW tasks_session_fit AS
|
|
61
|
+
SELECT t.*,
|
|
62
|
+
CASE
|
|
63
|
+
WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
|
|
64
|
+
AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
|
|
65
|
+
THEN ARRAY['autonomous','check-in','active']
|
|
66
|
+
WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
|
|
67
|
+
THEN ARRAY['active']
|
|
68
|
+
ELSE ARRAY['check-in','active']
|
|
69
|
+
END AS fits_session_modes,
|
|
70
|
+
pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated,
|
|
71
|
+
CASE
|
|
72
|
+
WHEN t.credits_reward > 0
|
|
73
|
+
AND pms_calibrated_minutes(t.est_minutes, t.kind) IS NOT NULL
|
|
74
|
+
AND pms_calibrated_minutes(t.est_minutes, t.kind) > 0
|
|
75
|
+
THEN ROUND(t.credits_reward::numeric / pms_calibrated_minutes(t.est_minutes, t.kind), 3)
|
|
76
|
+
ELSE NULL
|
|
77
|
+
END AS credits_per_minute
|
|
78
|
+
FROM tasks t;
|
|
79
|
+
|
|
80
|
+
COMMIT;
|
|
81
|
+
|
|
82
|
+
INSERT INTO schema_migrations (version) VALUES ('core_227_task_created_by')
|
|
83
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -366,6 +366,8 @@ function createClient(opts = {}) {
|
|
|
366
366
|
getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
|
|
367
367
|
// POST /goals/reorder — rank: metic+archon — POST /goals/reorder
|
|
368
368
|
postGoalsReorder: (args) => request("POST", "/goals/reorder", { hasBody: true }, args),
|
|
369
|
+
// GET /goals/rollups — rank: any-builder — GET /goals/rollups
|
|
370
|
+
getGoalsRollups: (args) => request("GET", "/goals/rollups", { hasBody: false }, args),
|
|
369
371
|
// GET /goals/suggestions — rank: any-builder — GET /goals/suggestions
|
|
370
372
|
getGoalsSuggestions: (args) => request("GET", "/goals/suggestions", { hasBody: false }, args),
|
|
371
373
|
},
|
|
@@ -84,11 +84,11 @@
|
|
|
84
84
|
return false;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
// ms helpers for the goal ordering (task 1002965)
|
|
88
|
-
// task
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
const
|
|
87
|
+
// ms helpers for the goal ordering (task 1002965). The DEFINITIONS moved to
|
|
88
|
+
// task-lib.js in task 1003278 so the Tasks board and the Goals page read ONE
|
|
89
|
+
// meaning of "touched"; re-exported here so this lib's API is unchanged.
|
|
90
|
+
// goals.html loads task-lib.js before this file (the render-family order).
|
|
91
|
+
const { msOf, taskActivityMs } = window.OTBTaskLib;
|
|
92
92
|
|
|
93
93
|
// task 1003224 — on a LIST, a goal's subtitle is a TOOLTIP, never a second
|
|
94
94
|
// visible line. The board's job there is to let someone find a goal by name;
|
|
@@ -144,37 +144,60 @@
|
|
|
144
144
|
// Array#sort is stable in every browser this hall supports — so goals sharing
|
|
145
145
|
// a timestamp (or both missing one) keep their priority order as the tiebreak.
|
|
146
146
|
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// one
|
|
147
|
+
// Per-goal rollups: readiness counters, the last-activity timestamp, and the
|
|
148
|
+
// craft set. Since task 1003278 the SERVER computes these (GET /goals/rollups),
|
|
149
|
+
// one small row per goal.
|
|
150
|
+
//
|
|
151
|
+
// WHY IT MOVED, in one number. This used to fetch every task in every version
|
|
152
|
+
// the board spans — `GET /tasks?version=…&include=deps` — and reduce them here.
|
|
153
|
+
// On the real corpus that single request measured **5,229,455 bytes for ~1,400
|
|
154
|
+
// tasks** (2026-08-25) to produce about forty counter objects. The criterion
|
|
155
|
+
// this page serves says it plainly: "~1,357 tasks on BONGOS-V1 alone. A view
|
|
156
|
+
// that does not survive that number is not a solution." It did not survive it;
|
|
157
|
+
// it just did not visibly fall over.
|
|
158
|
+
//
|
|
159
|
+
// The buckets, the activity clock and the next-ready pick are unchanged — the
|
|
160
|
+
// SQL mirrors goals-lib.js `readinessOf` exactly, and a test holds the two
|
|
161
|
+
// against each other, because a divergence would show a person different
|
|
162
|
+
// numbers depending on which surface they opened.
|
|
163
|
+
//
|
|
164
|
+
// Failure-tolerant, and deliberately NOT falling back to the old whole-version
|
|
165
|
+
// fetch: a "fallback" that quietly re-downloads 5 MB is the bug wearing a
|
|
166
|
+
// safety belt. An older server yields empty rollups and the cards render plain.
|
|
152
167
|
async function loadRollups() {
|
|
153
168
|
const versions = [...new Set(state.goals.map((g) => g.version_id).filter(Boolean))];
|
|
154
169
|
const lists = await Promise.all(versions.map((v) =>
|
|
155
|
-
getJSON(`${API}/
|
|
156
|
-
.then((r) => r.
|
|
170
|
+
getJSON(`${API}/goals/rollups?version=${encodeURIComponent(v)}`)
|
|
171
|
+
.then((r) => r.rollups || []).catch(() => [])));
|
|
157
172
|
const roll = new Map();
|
|
158
173
|
const activity = new Map();
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const key = String(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
const disciplines = new Map(); // task 1003278 — goalId -> Set(craft)
|
|
175
|
+
for (const row of lists.flat()) {
|
|
176
|
+
const key = String(row.goal_id);
|
|
177
|
+
roll.set(key, {
|
|
178
|
+
done: row.done || 0,
|
|
179
|
+
active: row.active || 0,
|
|
180
|
+
ready: row.ready || 0,
|
|
181
|
+
blocked: row.blocked || 0,
|
|
182
|
+
backlog: row.backlog || 0,
|
|
183
|
+
cut: row.cut || 0,
|
|
184
|
+
total: row.total || 0,
|
|
185
|
+
// The row shape the renderers already expect: `nextReady` is a task-like
|
|
186
|
+
// object with an id and a title, and nothing else was ever read off it.
|
|
187
|
+
nextReady: row.next_ready_id ? { id: row.next_ready_id, title: row.next_ready_title } : null,
|
|
188
|
+
});
|
|
189
|
+
const ts = msOf(row.last_activity_at);
|
|
190
|
+
if (ts) activity.set(key, ts);
|
|
191
|
+
// The goal's CRAFT set. "Role" here is the craft axis ADR 0174 reserves the
|
|
192
|
+
// word for (engineer | artist | ideator | ui), never a goal membership —
|
|
193
|
+
// that is `membership_kind` (ADR 0177). A goal has no discipline column and
|
|
194
|
+
// must not get one: its craft set is what its tasks carry, which is why the
|
|
195
|
+
// server aggregates it rather than the goal row storing it.
|
|
196
|
+
disciplines.set(key, new Set(row.disciplines || []));
|
|
175
197
|
}
|
|
176
198
|
state.rollups = roll;
|
|
177
199
|
state.activity = activity;
|
|
200
|
+
state.disciplines = disciplines;
|
|
178
201
|
}
|
|
179
202
|
|
|
180
203
|
/* ---------------- list view ---------------- */
|
|
@@ -893,6 +916,14 @@
|
|
|
893
916
|
// task 1003276 — filled from GET /work-categories after boot
|
|
894
917
|
// (populateCategoryFilter), the populateOwnerFilter pattern.
|
|
895
918
|
{ id: 'f-goal-category', label: 'Category', options: [{ value: '', label: '— any —' }] },
|
|
919
|
+
// task 1003278 — the craft axis (ADR 0174 reserves "role" for the crafts;
|
|
920
|
+
// a goal membership is a membership_kind, ADR 0177). Values are the
|
|
921
|
+
// disciplines a goal's tasks carry, derived in loadRollups. Staleness
|
|
922
|
+
// buckets are the shared STALE_CHOICES from task-lib.js so this page and
|
|
923
|
+
// the Tasks board offer the same lenses.
|
|
924
|
+
{ id: 'f-goal-role', label: 'Craft', options: [ // ADR 0174: "role" means a craft
|
|
925
|
+
{ value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui'] },
|
|
926
|
+
{ id: 'f-goal-stale', label: 'Age', options: window.OTBTaskLib.STALE_CHOICES },
|
|
896
927
|
{ id: 'f-goal-order',
|
|
897
928
|
label: 'Sort',
|
|
898
929
|
value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
|
|
@@ -60,6 +60,9 @@
|
|
|
60
60
|
newest: { label: 'newest first', cmp: (a, b) => goalCreatedMs(b) - goalCreatedMs(a), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
|
|
61
61
|
oldest: { label: 'oldest first', cmp: (a, b) => goalCreatedMs(a) - goalCreatedMs(b), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
|
|
62
62
|
active: { label: 'recently active', cmp: (a, b) => goalActivityMs(b) - goalActivityMs(a), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
|
|
63
|
+
// task 1003278 — the rot view: least-recently-touched first, on the SAME
|
|
64
|
+
// clock as 'recently active' so the two can never disagree.
|
|
65
|
+
stale: { label: 'stalest first', cmp: (a, b) => goalActivityMs(a) - goalActivityMs(b), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
|
|
63
66
|
};
|
|
64
67
|
const currentGoalSort = () => GOAL_SORTS[$('#f-goal-order')?.value] || GOAL_SORTS.priority;
|
|
65
68
|
function sortGoals(rows) {
|
|
@@ -93,6 +96,16 @@
|
|
|
93
96
|
|| (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
|
|
94
97
|
const matchesCategory = (g) => !cat
|
|
95
98
|
|| (cat === 'none' ? !g.category : (g.category?.slug === cat));
|
|
99
|
+
// task 1003278 — role + staleness. A goal's role is the discipline set its
|
|
100
|
+
// tasks carry (loadRollups derives it from the per-version task fetch); its
|
|
101
|
+
// clock is the same activity timestamp the 'recently active' sort reads, so
|
|
102
|
+
// sorting and filtering can never disagree about what "touched" means.
|
|
103
|
+
const role = $('#f-goal-role')?.value || '';
|
|
104
|
+
const staleDays = $('#f-goal-stale')?.value || '';
|
|
105
|
+
const { isStale } = window.OTBTaskLib;
|
|
106
|
+
const matchesRole = (g) => !role
|
|
107
|
+
|| !!(state.disciplines && state.disciplines.get(String(g.id))?.has(role));
|
|
108
|
+
const matchesStale = (g) => !staleDays || isStale(goalActivityMs(g), staleDays);
|
|
96
109
|
const myId = selfId();
|
|
97
110
|
const iAmMember = (g) => {
|
|
98
111
|
const d = state.details.get(String(g.id));
|
|
@@ -104,6 +117,8 @@
|
|
|
104
117
|
matchesSearch(g) &&
|
|
105
118
|
matchesOwner(g) &&
|
|
106
119
|
matchesCategory(g) &&
|
|
120
|
+
matchesRole(g) &&
|
|
121
|
+
matchesStale(g) &&
|
|
107
122
|
(!mineOnly || iAmMember(g)));
|
|
108
123
|
}
|
|
109
124
|
function goalStampHtml(g) {
|
|
@@ -108,6 +108,34 @@
|
|
|
108
108
|
// case leaked a machine code into the Goals page UI. Adding a server-side reason
|
|
109
109
|
// without adding it here degrades quietly rather than loudly, so keep this in
|
|
110
110
|
// step with the claim route's refusal codes.
|
|
111
|
+
// ---- staleness (task 1003278, criterion legibility-sort-and-staleness) ----
|
|
112
|
+
// ONE definition of "touched", shared by both tiers: updated_at moves on every
|
|
113
|
+
// task write (claim, status flip, edit); shipped_at is folded in because a ship
|
|
114
|
+
// path can stamp it without touching updated_at (the task-1002965 owner call,
|
|
115
|
+
// previously private to goals-lib.js — moved HERE so the Tasks board and the
|
|
116
|
+
// Goals page cannot disagree about what "untouched" means).
|
|
117
|
+
const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
|
|
118
|
+
const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
|
|
119
|
+
|
|
120
|
+
// The staleness buckets both boards offer. A CLIENT-side vocabulary, not the
|
|
121
|
+
// project's configured stale timer (task 1003279 wires that into the sweep);
|
|
122
|
+
// these are lenses for a person browsing, and the sweep is the enforcement.
|
|
123
|
+
const STALE_CHOICES = [
|
|
124
|
+
{ value: '', label: '— any age —' },
|
|
125
|
+
{ value: '14', label: 'untouched 14+ days' },
|
|
126
|
+
{ value: '30', label: 'untouched 30+ days' },
|
|
127
|
+
{ value: '60', label: 'untouched 60+ days' },
|
|
128
|
+
];
|
|
129
|
+
// Does this activity timestamp fall past the chosen bucket? '' = no filter.
|
|
130
|
+
// 0 (no readable timestamp at all) is treated as STALE, not fresh — a row
|
|
131
|
+
// nobody can date is exactly what a rot-hunt must not hide.
|
|
132
|
+
function isStale(activityMs, days, nowMs) {
|
|
133
|
+
const d = Number(days);
|
|
134
|
+
if (!d) return true;
|
|
135
|
+
const now = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
136
|
+
return now - activityMs >= d * 24 * 60 * 60 * 1000;
|
|
137
|
+
}
|
|
138
|
+
|
|
111
139
|
function friendlyBatchReason(item) {
|
|
112
140
|
switch (item && item.reason) {
|
|
113
141
|
case 'TASK_NOT_FOUND': return 'no longer exists';
|
|
@@ -128,6 +156,7 @@
|
|
|
128
156
|
}
|
|
129
157
|
|
|
130
158
|
const api = {
|
|
159
|
+
msOf, taskActivityMs, STALE_CHOICES, isStale,
|
|
131
160
|
priorityRank, touchesConflict, rankTagHtml, deHashRefs,
|
|
132
161
|
creditsPerMinute, buildPromptForTask, friendlyBatchReason,
|
|
133
162
|
};
|
|
@@ -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 (
|