@bongos/core 1.19.485 → 1.19.486

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.
@@ -0,0 +1,193 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/project-settings.js — the instance's live, DB-backed knobs, and the
4
+ // typed accessor every reader goes through (task 1003279, goal 1000072).
5
+ //
6
+ // WHY THIS FILE EXISTS. Goal 1000072's rotting half needs a number — "untouched
7
+ // for how long?" — and task 1003280 states where it must live: "STALE TIMER —
8
+ // live, DB-backed", followed by "do not fork a second store". A knob an owner can
9
+ // change from a page cannot be an env var that needs a redeploy, and it must not
10
+ // become a THIRD place staleness is configured. So there is one table
11
+ // (project_settings, migration core_231) and one accessor, here.
12
+ //
13
+ // HOW THIS RELATES TO src/stale-timer.js — READ THIS BEFORE ADDING A THIRD TIMER.
14
+ // ADR 0226 collapsed four disagreeing answers to "how long may a CLAIM go silent"
15
+ // into one resolver. This file does not reopen that. The two timers measure
16
+ // different things, are owned by different surfaces, and have different
17
+ // consequences:
18
+ //
19
+ // src/stale-timer.js a CLAIM goes silent hours env the sweep RELEASES it
20
+ // this file work goes UNTOUCHED days DB a human is ASKED
21
+ //
22
+ // The distinction that keeps them from collapsing into each other: the claim timer
23
+ // is about a builder who stopped responding, and the machine acts alone. The rot
24
+ // timer is about work nobody picked up, and the machine only ever raises a
25
+ // question. One enforces, one reports — which is exactly the asymmetry ADR 0226's
26
+ // header names, applied one level up.
27
+ //
28
+ // This is why the rot read deliberately EXCLUDES tasks at 'active': a claimed task
29
+ // that has gone quiet is the claim timer's question, and reporting it here too
30
+ // would put the same rot in two inboxes with two different numbers — the precise
31
+ // failure ADR 0226 exists to prevent.
32
+ //
33
+ // FAIL POSTURE: REPORTING, SO IT DEGRADES. Every caller of this file is a read
34
+ // surface (the needs-attention inbox, the settings page). A knob typed wrong shows
35
+ // up ON the screen of the person who can fix it, labelled, instead of 500-ing the
36
+ // inbox they were using to find the problem. Nothing here throws on a bad value;
37
+ // it reports one. (An enforcing caller would have to refuse instead — see the
38
+ // stale-timer header. There is no enforcing caller of the rot timer, by design.)
39
+
40
+ const { pool } = require('./pool');
41
+ // The structured logger rather than console.* — both are kernel files (this one is on
42
+ // fitness.js's KERNEL_FILES roster), a structured line is attributable and rides
43
+ // ADR 0022's redaction, and the quality ratchet counts bare console calls.
44
+ const { logger } = require('./logger');
45
+
46
+ // The knob names. Namespaced by the surface that owns them so a settings page can
47
+ // group rows without a second column, and so a future knob from another surface
48
+ // cannot collide with this one.
49
+ const SETTING_KEYS = {
50
+ ROT_DAYS: 'attention.rot_days',
51
+ };
52
+
53
+ // The last-resort rot timer, in days. 30 because that is the shortest window in
54
+ // which "nobody has touched this" is evidence of rot rather than evidence of a
55
+ // normal week — a fortnight would fire on every ordinary holiday, and a quarter is
56
+ // long enough that the backlog has already stopped being read.
57
+ const DEFAULT_ROT_DAYS = 30;
58
+
59
+ // The accepted range, in days. 1 because "yesterday" is the shortest question worth
60
+ // asking. 365 because past a year the timer is an off switch, and an off switch
61
+ // should be asked for out loud rather than typed as a big number — the same
62
+ // reasoning, and the same sentence, as the 720h bound in src/stale-timer.js.
63
+ const MIN_ROT_DAYS = 1;
64
+ const MAX_ROT_DAYS = 365;
65
+
66
+ // Parse one candidate into whole days, or say why it is unusable. Pure, never
67
+ // throws: both the DB tier and the override tier want to decide for themselves what
68
+ // an unusable value means. Mirrors parseHours in src/stale-timer.js deliberately —
69
+ // two timers with two different range checks would drift into two different ideas
70
+ // of what "valid" means.
71
+ function parseDays(raw) {
72
+ if (raw == null || raw === '') return { reason: 'empty' };
73
+ const n = Number(raw);
74
+ if (!Number.isFinite(n)) return { reason: 'not a number' };
75
+ if (!Number.isInteger(n)) return { reason: 'must be a whole number of days' };
76
+ if (n <= 0) return { reason: 'must be positive' };
77
+ if (n < MIN_ROT_DAYS || n > MAX_ROT_DAYS) {
78
+ return { reason: `out of range (${MIN_ROT_DAYS}..${MAX_ROT_DAYS})` };
79
+ }
80
+ return { days: n };
81
+ }
82
+
83
+ // getSetting(key) — the raw row, or null when the key has never been set.
84
+ // NULL IS MEANINGFUL: absence means "never configured, use the code default", which
85
+ // is what lets a fresh instance boot with an empty table and what lets the settings
86
+ // page tell "never touched" apart from "deliberately set to the default value".
87
+ // Never seed a default row to avoid the null.
88
+ async function getSetting(key) {
89
+ const { rows } = await pool.query(
90
+ 'SELECT key, value, updated_at, updated_by FROM project_settings WHERE key = $1',
91
+ [key]
92
+ );
93
+ return rows[0] || null;
94
+ }
95
+
96
+ // setSetting(key, value, builderId) — upsert one knob, returning the stored row.
97
+ // The value is stored verbatim as text and validated by the typed resolver at READ
98
+ // time, not here, so a value that was valid when written and later fell outside a
99
+ // tightened range degrades on the screen rather than becoming unreadable.
100
+ // builderId may be null for a system path with no builder in the request.
101
+ async function setSetting(key, value, builderId = null) {
102
+ const { rows } = await pool.query(
103
+ `INSERT INTO project_settings (key, value, updated_by, updated_at)
104
+ VALUES ($1, $2, $3, now())
105
+ ON CONFLICT (key) DO UPDATE
106
+ SET value = EXCLUDED.value,
107
+ updated_by = EXCLUDED.updated_by,
108
+ updated_at = now()
109
+ RETURNING key, value, updated_at, updated_by`,
110
+ [key, String(value), builderId]
111
+ );
112
+ return rows[0];
113
+ }
114
+
115
+ // resolveRotDays({ override }) — the project's rot timer and where the number came
116
+ // from, for any surface that reports staleness.
117
+ //
118
+ // Precedence, widest-scope last (the same shape as resolveStaleClaimHours):
119
+ // 1. override — a per-request lens (?rot_days=). Someone asking "what would 7
120
+ // days show me?" is not reconfiguring the project, so this never
121
+ // becomes the project's answer and is never written back.
122
+ // 2. the DB — project_settings['attention.rot_days']. THIS is the project's
123
+ // configured timer, the row task 1003280's page writes.
124
+ // 3. default — DEFAULT_ROT_DAYS.
125
+ //
126
+ // Returns { days, source, invalid }:
127
+ // days — always a usable positive integer, whatever went wrong
128
+ // source — 'override' | 'project_settings' | 'default'
129
+ // invalid — null, or { where, raw, reason }: something was SET and unusable.
130
+ // `days` has already fallen through to the next tier; a reporting
131
+ // caller should say so on screen.
132
+ //
133
+ // A DB that is unreachable is NOT an invalid value — it is a failure to read, and
134
+ // this returns the default with source 'default' rather than taking the inbox down.
135
+ // The inbox is how someone would notice the database is unhappy.
136
+ async function resolveRotDays({ override } = {}) {
137
+ if (override != null && override !== '') {
138
+ const got = parseDays(override);
139
+ if (got.days != null) return { days: got.days, source: 'override', invalid: null };
140
+ return {
141
+ days: DEFAULT_ROT_DAYS,
142
+ source: 'default',
143
+ invalid: { where: 'override', raw: String(override), reason: got.reason },
144
+ };
145
+ }
146
+
147
+ let row = null;
148
+ try {
149
+ row = await getSetting(SETTING_KEYS.ROT_DAYS);
150
+ } catch (err) {
151
+ logger.error(`project_settings read failed; falling back to the default rot timer: ${err && err.message}`);
152
+ return { days: DEFAULT_ROT_DAYS, source: 'default', invalid: null };
153
+ }
154
+
155
+ if (row && row.value != null && row.value !== '') {
156
+ const got = parseDays(row.value);
157
+ if (got.days != null) return { days: got.days, source: 'project_settings', invalid: null };
158
+ return {
159
+ days: DEFAULT_ROT_DAYS,
160
+ source: 'default',
161
+ invalid: { where: `project_settings['${SETTING_KEYS.ROT_DAYS}']`, raw: String(row.value), reason: got.reason },
162
+ };
163
+ }
164
+
165
+ return { days: DEFAULT_ROT_DAYS, source: 'default', invalid: null };
166
+ }
167
+
168
+ // describeRotTimer(resolved) — one sentence naming the timer and its provenance, for
169
+ // an API receipt or a settings row. A number with no provenance is what let a 6h and
170
+ // a 24h coexist unnoticed for months (ADR 0226); every surface that prints the rot
171
+ // timer prints this instead of printing the bare number.
172
+ function describeRotTimer(resolved) {
173
+ const r = resolved || {};
174
+ const base = `${r.days}d (${r.source})`;
175
+ if (!r.invalid) return base;
176
+ return `${base} — ignoring ${r.invalid.where}="${r.invalid.raw}" (${r.invalid.reason})`;
177
+ }
178
+
179
+ // parseDays is exported for the tests only — the range check and the default are
180
+ // asserted against the SAME constants the resolver reads, because a test that
181
+ // re-typed 30 or 365 would be the second copy of the number this file exists to
182
+ // keep singular.
183
+ module.exports = {
184
+ SETTING_KEYS,
185
+ DEFAULT_ROT_DAYS,
186
+ MIN_ROT_DAYS,
187
+ MAX_ROT_DAYS,
188
+ parseDays,
189
+ getSetting,
190
+ setSetting,
191
+ resolveRotDays,
192
+ describeRotTimer,
193
+ };
package/src/module-api.js CHANGED
@@ -17,6 +17,17 @@ const { pool, instanceDbName } = require('./bongos/pool');
17
17
  const branding = require('./branding');
18
18
  const instanceConfig = require('./instance-config');
19
19
  const staleTimer = require('./stale-timer');
20
+ // Destructured, not namespaced, deliberately — the same form src/bongos/routes/
21
+ // builders.js uses for stale-timer.js. knip attributes a named export to a
22
+ // destructured binding but not to a property read off a required namespace, so the
23
+ // namespace form reports every export here as dead and regresses the ratchet.
24
+ const {
25
+ resolveRotDays,
26
+ SETTING_KEYS: PROJECT_SETTING_KEYS,
27
+ getSetting: getProjectSetting,
28
+ setSetting: setProjectSetting,
29
+ describeRotTimer,
30
+ } = require('./bongos/project-settings');
20
31
  const seams = require('./module-seams');
21
32
  const { buildInfo } = require('./build-info');
22
33
  const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePagination, pageMeta, PAGINATION } = require('./bongos/routes/_helpers');
@@ -34,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
34
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
35
46
  // the entry to that file. Look for a version's history there, not here.
36
47
  // ---------------------------------------------------------------------------
37
- const CORE_VERSION = '1.19.485'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.486'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
38
49
 
39
50
  // A namespaced logger so a module's log lines are attributable + consistent.
40
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -250,6 +261,23 @@ module.exports = {
250
261
  // a direct require in scripts/ rather than joining the published surface.
251
262
  resolveStaleClaimHours: staleTimer.resolveStaleClaimHours,
252
263
  staleClaimMinutes: staleTimer.staleClaimMinutes,
264
+ // --- the project's ROT TIMER + the knob store behind it (task 1003279, goal
265
+ // 1000072) — how long a GOAL or TASK may go untouched before the hall's
266
+ // needs-attention inbox raises a line about it. A DIFFERENT question from the
267
+ // stale-claim timer directly above, and the two must not be confused: that one
268
+ // counts hours, is read from the env, is about a builder who went quiet, and
269
+ // the sweeper ACTS on it alone. This one counts days, is read from the DB
270
+ // (task 1003280 puts it on a settings page, and "do not fork a second store"),
271
+ // is about work nobody picked up, and it only ever ASKS a human. The rot read
272
+ // deliberately skips tasks at 'active' so the two never report the same rot
273
+ // with two different numbers. See src/bongos/project-settings.js.
274
+ resolveRotDays,
275
+ projectSettings: {
276
+ KEYS: PROJECT_SETTING_KEYS,
277
+ get: getProjectSetting,
278
+ set: setProjectSetting,
279
+ describeRotTimer,
280
+ },
253
281
  readConfigFileSync: instanceConfig.readConfigFileSync,
254
282
  configHome: instanceConfig.configHome,
255
283
  configPath: instanceConfig.configPath,
@@ -34,6 +34,7 @@ const PUBLISHED_SURFACE = [
34
34
  'llmPricing', // BV1.R77: shared token→USD pricing (1.7.0; tracked domain leak — economy carve)
35
35
  'branding', 'clientBranding', 'userAgent', 'resolveEnv', 'readConfigFileSync', 'configHome', 'configPath', // userAgent added 1.12.0 (BV1.R86; github-push UA)
36
36
  'resolveStaleClaimHours', 'staleClaimMinutes', // added by task 1003476 (ADR 0226; goal 1000072): the PROJECT's stale-claim timer — the sweep that releases a silent claim and the Archon roster that reports one read the same number through here, after eighteen hours of disagreeing (6 vs 24)
37
+ 'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
37
38
  'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
38
39
  'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
39
40
  'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',