@bongos/core 1.21.106 → 1.21.107
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 -27
- 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 +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +91 -2
- package/docs/api-reference.md +5 -3
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_275_claims_auto_pickup.sql +24 -0
- package/modules/government/decision-rules.js +1 -0
- package/modules/lifecycle/autopickup.js +94 -0
- package/modules/lifecycle/db-claims.js +18 -4
- package/modules/lifecycle/routes/claims.js +12 -1
- package/modules/lifecycle/routes/project-settings.js +59 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/scripts/gds/government-walk.js +1 -0
- package/scripts/gds/run-unit-tests.js +3 -0
- package/src/bongos/project-settings.js +79 -0
- package/src/module-api.js +8 -1
- package/tests/autopickup_cap.mjs +236 -0
- package/tests/autopickup_cap_db.mjs +115 -0
- package/tests/government_act_alone_buildings.mjs +1 -0
|
@@ -37,6 +37,7 @@ const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeNa
|
|
|
37
37
|
const { sealSessionLog } = require('./db-shared.js');
|
|
38
38
|
const { gatingRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
|
|
39
39
|
const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
|
|
40
|
+
const { autoPickupRefusal } = require('./autopickup.js');
|
|
40
41
|
|
|
41
42
|
// -------------------------------------------------------------------------
|
|
42
43
|
// Claims (atomic)
|
|
@@ -99,8 +100,11 @@ function isRebaseGateExempt(task) {
|
|
|
99
100
|
return Boolean(task) && task.kind === 'infra' && Number(task.priority) === 1;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSessionId, enforceOverlap = false }) {
|
|
103
|
+
async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSessionId, enforceOverlap = false, autoPickup = false }) {
|
|
103
104
|
const heldPermissions = await claimPermissionsFor(builderId); // BV1.R106 — before the tx opens
|
|
105
|
+
// ADR 0371 D5: a marked claim needs the project's switch and cap, read before the
|
|
106
|
+
// tx like the permissions above; the COUNT runs inside it, under the lock.
|
|
107
|
+
const autoPickupSettings = autoPickup ? await api.projectSettings.resolveTweakAutopickup() : null;
|
|
104
108
|
const client = await pool.connect();
|
|
105
109
|
try {
|
|
106
110
|
await client.query('BEGIN');
|
|
@@ -142,7 +146,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
const { rows: taskRows } = await client.query(
|
|
145
|
-
`SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, credits_reward, reward_gate_exempt, est_minutes, kind, goal_id FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
149
|
+
`SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, credits_reward, reward_gate_exempt, est_minutes, kind, goal_id, source FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
146
150
|
[taskId]
|
|
147
151
|
);
|
|
148
152
|
if (taskRows.length === 0) {
|
|
@@ -350,6 +354,14 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
350
354
|
}
|
|
351
355
|
}
|
|
352
356
|
|
|
357
|
+
// ADR 0371 D5/D6 (task 1004677): the auto_pickup mark. The cap and the per-round
|
|
358
|
+
// limit are counted under a project-wide advisory lock that this transaction
|
|
359
|
+
// holds through the insert below, so two runners cannot both take the last slot.
|
|
360
|
+
if (autoPickup) {
|
|
361
|
+
const refusal = await autoPickupRefusal(client, task, autoPickupSettings);
|
|
362
|
+
if (refusal) throw refusal;
|
|
363
|
+
}
|
|
364
|
+
|
|
353
365
|
// Pre-allocate a migration number if the task signals it'll add one.
|
|
354
366
|
// Trigger: the tasks.needs_migration boolean (migration 081, touches[]
|
|
355
367
|
// cleanup [1/8] / task 876). Replaced the old touches[]-contains-'migrations/'
|
|
@@ -396,9 +408,11 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
396
408
|
finalWorktreeName = disambiguateWorktreeName(finalWorktreeName, taken);
|
|
397
409
|
}
|
|
398
410
|
|
|
411
|
+
// The column is named only on a marked claim, so an ordinary claim's insert is
|
|
412
|
+
// byte-identical to before core_275.
|
|
399
413
|
const insertClaim = await client.query(
|
|
400
|
-
`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id)
|
|
401
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
414
|
+
`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, head_sha, reserved_migration_number, creator_session_id${autoPickup ? ', auto_pickup' : ''})
|
|
415
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7${autoPickup ? ', TRUE' : ''})
|
|
402
416
|
RETURNING id, task_id, builder_id, worktree_name, requested_worktree_name, claimed_at, head_sha, reserved_migration_number`,
|
|
403
417
|
[taskId, builderId, finalWorktreeName, requestedWorktreeName, headSha ?? null, reservedMigrationNumber, creatorSessionId ?? null]
|
|
404
418
|
);
|
|
@@ -276,8 +276,12 @@ module.exports = function buildClaimsRouter() {
|
|
|
276
276
|
// an interactive single-human claim only WARNS (git merge is the real
|
|
277
277
|
// collision detector — ADR 0017).
|
|
278
278
|
enforce_touches: { type: 'boolean' },
|
|
279
|
+
// ADR 0371 D5 (task 1004677): the Autobongos tweak lane's mark. The server
|
|
280
|
+
// counts marked claims against the project's monthly cap and the per-round
|
|
281
|
+
// limit; see modules/lifecycle/autopickup.js.
|
|
282
|
+
auto_pickup: { type: 'boolean' },
|
|
279
283
|
})) return;
|
|
280
|
-
const { task_id: taskId, worktree_name: worktreeName, head_sha: headSha, bind_session: bindSession, enforce_touches: enforceTouches } = req.body || {};
|
|
284
|
+
const { task_id: taskId, worktree_name: worktreeName, head_sha: headSha, bind_session: bindSession, enforce_touches: enforceTouches, auto_pickup: autoPickup } = req.body || {};
|
|
281
285
|
// Belt-and-braces — task_id must coerce to a finite number. Older calls
|
|
282
286
|
// sent string ints (Number.isFinite passes both); reject NaN/garbage.
|
|
283
287
|
if (!Number.isFinite(Number(taskId))) {
|
|
@@ -291,6 +295,7 @@ module.exports = function buildClaimsRouter() {
|
|
|
291
295
|
headSha: headSha ?? null,
|
|
292
296
|
creatorSessionId: resolveCreatorSessionId(bindSession, req.gdsSession?.session_id),
|
|
293
297
|
enforceOverlap: enforceTouches === true,
|
|
298
|
+
autoPickup: autoPickup === true,
|
|
294
299
|
});
|
|
295
300
|
const { just_unlocked: justUnlocked, overlap_warning: overlapWarning, sibling_overlap_warning: siblingOverlapWarning, ...claim } = claimResult;
|
|
296
301
|
const task = await db.getTask(Number(taskId));
|
|
@@ -375,6 +380,12 @@ module.exports = function buildClaimsRouter() {
|
|
|
375
380
|
// block, not an authorization refusal). The refusal object already
|
|
376
381
|
// carries `message` + `hint`, so no extra hint-building step needed.
|
|
377
382
|
err.code === 'RELEVANCE_REVIEW_PENDING' ? 409 :
|
|
383
|
+
// ADR 0371 D5/D6: a marked auto-pickup claim the project's switch, monthly
|
|
384
|
+
// cap or per-round limit refuses. The round waits for a person.
|
|
385
|
+
err.code === 'AUTO_PICKUP_NOT_A_TWEAK' ? 409 :
|
|
386
|
+
err.code === 'AUTO_PICKUP_OFF' ? 409 :
|
|
387
|
+
err.code === 'AUTO_PICKUP_CAP_REACHED' ? 409 :
|
|
388
|
+
err.code === 'AUTO_PICKUP_ROUND_LIMIT' ? 409 :
|
|
378
389
|
500;
|
|
379
390
|
// V4.R14 (#952): a TASK_NOT_READY 409 names the way forward instead of
|
|
380
391
|
// leaving the caller to discover the promote route by spelunking (#506).
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
const express = require('express');
|
|
32
32
|
const api = require('../../../src/module-api');
|
|
33
33
|
const goalCategory = require('../goal-category');
|
|
34
|
+
const autopickup = require('../autopickup');
|
|
34
35
|
|
|
35
36
|
const log = api.logger('lifecycle');
|
|
36
37
|
|
|
@@ -380,6 +381,64 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
380
381
|
}
|
|
381
382
|
});
|
|
382
383
|
|
|
384
|
+
// --- tweak auto-pickup (task 1004677, ADR 0371 D3/D5) ----------------------
|
|
385
|
+
//
|
|
386
|
+
// GET /project-settings/tweak-autopickup — the switch, the monthly cap, and how
|
|
387
|
+
// many rounds the lane has picked up this calendar month (counted from marked
|
|
388
|
+
// claims, never a stored counter).
|
|
389
|
+
//
|
|
390
|
+
// rank: any signed-in builder. The runner reads it before it tries a round, and
|
|
391
|
+
// the studio shows the count; it carries no reward internals or file paths.
|
|
392
|
+
async function autopickupBody() {
|
|
393
|
+
const resolved = await api.projectSettings.resolveTweakAutopickup();
|
|
394
|
+
const used = await autopickup.countThisMonth();
|
|
395
|
+
return {
|
|
396
|
+
mode: resolved.mode,
|
|
397
|
+
cap: resolved.cap,
|
|
398
|
+
used_this_month: used,
|
|
399
|
+
remaining_this_month: Math.max(0, resolved.cap - used),
|
|
400
|
+
per_round_limit: autopickup.MAX_AUTO_APPLIES_PER_ROUND,
|
|
401
|
+
source: resolved.source,
|
|
402
|
+
invalid: resolved.invalid,
|
|
403
|
+
sentence: api.projectSettings.describeTweakAutopickup(resolved),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
router.get('/project-settings/tweak-autopickup', api.requireBuilder, async (req, res) => {
|
|
408
|
+
try {
|
|
409
|
+
res.set('Cache-Control', 'no-store');
|
|
410
|
+
res.json(await autopickupBody());
|
|
411
|
+
} catch (err) {
|
|
412
|
+
log.error(`GET /project-settings/tweak-autopickup: ${err && err.message}`);
|
|
413
|
+
res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// PATCH /project-settings/tweak-autopickup — body { mode?: 'off' | 'on', cap?: n }.
|
|
418
|
+
//
|
|
419
|
+
// rank: project.settings.manage (metic+, government_010), the same atom as the
|
|
420
|
+
// other writes on this page. Each field is validated by the resolver's own parser,
|
|
421
|
+
// and both are checked before either is written, so a half-valid body writes nothing.
|
|
422
|
+
router.patch('/project-settings/tweak-autopickup', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
|
|
423
|
+
if (api.validateOrRespond(req, res, { mode: { type: 'string' }, cap: { type: 'number' } })) return;
|
|
424
|
+
const body = req.body || {};
|
|
425
|
+
if (body.mode == null && body.cap == null) {
|
|
426
|
+
return res.fail('bad_tweak_autopickup', { status: 400, message: 'send mode, cap, or both' });
|
|
427
|
+
}
|
|
428
|
+
const mode = body.mode == null ? null : api.projectSettings.parseAutopickup(body.mode);
|
|
429
|
+
if (mode && !mode.mode) return res.fail('bad_tweak_autopickup', { status: 400, message: `mode: ${mode.reason}` });
|
|
430
|
+
const cap = body.cap == null ? null : api.projectSettings.parseAutopickupCap(body.cap);
|
|
431
|
+
if (cap && cap.cap == null) return res.fail('bad_tweak_autopickup', { status: 400, message: `cap: ${cap.reason}` });
|
|
432
|
+
try {
|
|
433
|
+
if (mode) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP, mode.mode, req.builder.id);
|
|
434
|
+
if (cap) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP_CAP, cap.cap, req.builder.id);
|
|
435
|
+
res.json({ ok: true, ...(await autopickupBody()) });
|
|
436
|
+
} catch (err) {
|
|
437
|
+
log.error(`PATCH /project-settings/tweak-autopickup: ${err && err.message}`);
|
|
438
|
+
res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
383
442
|
return router;
|
|
384
443
|
};
|
|
385
444
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.107",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.107",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.107",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9482,5 +9482,11 @@
|
|
|
9482
9482
|
"id": "1004683",
|
|
9483
9483
|
"text": "After pressing Create, a founder now sees \"Getting <name> ready\" with a plain count (\"4 of 6 done\", or \"ready\" with a tick) and one sentence saying what is happening and whether it is their turn. Below it are two short lists,"
|
|
9484
9484
|
}
|
|
9485
|
+
],
|
|
9486
|
+
"1.21.107": [
|
|
9487
|
+
{
|
|
9488
|
+
"id": "1004677",
|
|
9489
|
+
"text": "A project now has a switch for letting a runner pick up submitted page tweaks on its own, off by default, with a monthly limit of 20 rounds. The server enforces the limit when the work is claimed, so two runners can never both"
|
|
9490
|
+
}
|
|
9485
9491
|
]
|
|
9486
9492
|
}
|
|
@@ -70,6 +70,7 @@ const BAD = 'x';
|
|
|
70
70
|
const REQUESTS = Object.freeze({
|
|
71
71
|
'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
|
|
72
72
|
'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
|
|
73
|
+
'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
|
|
73
74
|
'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
|
|
74
75
|
'POST /security/reports/:id/confirm': ['POST', `/security/reports/${BAD}/confirm`, {}],
|
|
75
76
|
'POST /security/reports/:id/dispute': ['POST', `/security/reports/${BAD}/dispute`, {}],
|
|
@@ -121,6 +121,9 @@ const INTEGRATION = new Set([
|
|
|
121
121
|
// index actually refuses the commingling INSERT and still allows the parallel
|
|
122
122
|
// -worktree case. It self-skips without a DB, and refuses any database that
|
|
123
123
|
// has a `builders` table, since it TRUNCATEs claims.
|
|
124
|
+
// task 1004677: two marked auto-pickup claims racing for the last slot of the
|
|
125
|
+
// monthly cap; only real connections prove the advisory lock. Scratch DB only.
|
|
126
|
+
'autopickup_cap_db',
|
|
124
127
|
'claims_requested_worktree_db',
|
|
125
128
|
'clawback_confirmed_backlog',
|
|
126
129
|
// task 1003113: the copy_desk_flags schema half. The module's own tests are
|
|
@@ -50,6 +50,8 @@ const SETTING_KEYS = {
|
|
|
50
50
|
ROT_DAYS: 'attention.rot_days',
|
|
51
51
|
FOCUS_VERSION: 'roadmap.focus_version',
|
|
52
52
|
SHIP_MODE: 'ship.mode',
|
|
53
|
+
TWEAK_AUTOPICKUP: 'tweak.autopickup',
|
|
54
|
+
TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
|
|
53
55
|
};
|
|
54
56
|
|
|
55
57
|
// SHIP MODE (task 1004628, ADR 0369) — WHO starts a ship, never HOW it lands.
|
|
@@ -278,6 +280,75 @@ function describeShipMode(resolved) {
|
|
|
278
280
|
return `${what}${how}`;
|
|
279
281
|
}
|
|
280
282
|
|
|
283
|
+
|
|
284
|
+
// TWEAK AUTO-PICKUP (task 1004677, ADR 0371 D3/D5). Whether the Autobongos runner's
|
|
285
|
+
// tweak lane may pick up a submitted page round on its own, and how many rounds it
|
|
286
|
+
// may pick up per calendar month. Off by default: a project that never turns it on
|
|
287
|
+
// keeps today's queue exactly. The cap is ENFORCED by the server at claim time
|
|
288
|
+
// (modules/lifecycle/autopickup.js); this file only resolves the numbers.
|
|
289
|
+
const AUTOPICKUP_MODES = Object.freeze(['off', 'on']);
|
|
290
|
+
const DEFAULT_AUTOPICKUP = 'off';
|
|
291
|
+
// 20 rounds: a grade costs the project a few dollars, so 20 is a bounded, legible
|
|
292
|
+
// monthly spend at about the level of one active artist (ADR 0371 D5).
|
|
293
|
+
const DEFAULT_AUTOPICKUP_CAP = 20;
|
|
294
|
+
// Past 1000 the cap is an off switch typed as a big number; that should be asked
|
|
295
|
+
// for out loud, the same reasoning as MAX_ROT_DAYS.
|
|
296
|
+
const MAX_AUTOPICKUP_CAP = 1000;
|
|
297
|
+
|
|
298
|
+
function parseAutopickup(raw) {
|
|
299
|
+
if (raw == null || String(raw).trim() === '') return { reason: 'empty' };
|
|
300
|
+
const v = String(raw).trim().toLowerCase();
|
|
301
|
+
if (!AUTOPICKUP_MODES.includes(v)) return { reason: `must be one of ${AUTOPICKUP_MODES.join(' | ')}` };
|
|
302
|
+
return { mode: v };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parseAutopickupCap(raw) {
|
|
306
|
+
if (raw == null || raw === '') return { reason: 'empty' };
|
|
307
|
+
const n = Number(raw);
|
|
308
|
+
if (!Number.isFinite(n)) return { reason: 'not a number' };
|
|
309
|
+
if (!Number.isInteger(n)) return { reason: 'must be a whole number of rounds' };
|
|
310
|
+
if (n <= 0) return { reason: 'must be positive' };
|
|
311
|
+
if (n > MAX_AUTOPICKUP_CAP) return { reason: `out of range (1..${MAX_AUTOPICKUP_CAP})` };
|
|
312
|
+
return { cap: n };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// One knob through its parser: { value, source, invalid }. Never throws; an
|
|
316
|
+
// unreadable database answers the default, the resolveShipMode posture.
|
|
317
|
+
async function resolveOne(key, parse, pick, fallback) {
|
|
318
|
+
let row = null;
|
|
319
|
+
try {
|
|
320
|
+
row = await getSetting(key);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
logger.error(`project_settings read failed; falling back to the default ${key}: ${err && err.message}`);
|
|
323
|
+
return { value: fallback, source: 'default', invalid: null };
|
|
324
|
+
}
|
|
325
|
+
if (row && row.value != null && row.value !== '') {
|
|
326
|
+
const got = parse(row.value);
|
|
327
|
+
if (pick(got) != null) return { value: pick(got), source: 'project_settings', invalid: null };
|
|
328
|
+
return { value: fallback, source: 'default', invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
|
|
329
|
+
}
|
|
330
|
+
return { value: fallback, source: 'default', invalid: null };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// resolveTweakAutopickup() — { mode, cap, source: { mode, cap }, invalid: [...] }.
|
|
334
|
+
// mode is always 'off' or 'on', cap always a usable positive integer.
|
|
335
|
+
async function resolveTweakAutopickup() {
|
|
336
|
+
const m = await resolveOne(SETTING_KEYS.TWEAK_AUTOPICKUP, parseAutopickup, (g) => g.mode, DEFAULT_AUTOPICKUP);
|
|
337
|
+
const c = await resolveOne(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, parseAutopickupCap, (g) => g.cap, DEFAULT_AUTOPICKUP_CAP);
|
|
338
|
+
return {
|
|
339
|
+
mode: m.value,
|
|
340
|
+
cap: c.value,
|
|
341
|
+
source: { mode: m.source, cap: c.source },
|
|
342
|
+
invalid: [m.invalid, c.invalid].filter(Boolean),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function describeTweakAutopickup(resolved) {
|
|
347
|
+
const r = resolved || {};
|
|
348
|
+
if (r.mode !== 'on') return 'Off: a submitted page waits until a person runs /tweak for it.';
|
|
349
|
+
return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month. The artist still approves every one.`;
|
|
350
|
+
}
|
|
351
|
+
|
|
281
352
|
// parseDays is exported for the tests only — the range check and the default are
|
|
282
353
|
// asserted against the SAME constants the resolver reads, because a test that
|
|
283
354
|
// re-typed 30 or 365 would be the second copy of the number this file exists to
|
|
@@ -299,4 +370,12 @@ module.exports = {
|
|
|
299
370
|
parseShipMode,
|
|
300
371
|
resolveShipMode,
|
|
301
372
|
describeShipMode,
|
|
373
|
+
AUTOPICKUP_MODES,
|
|
374
|
+
DEFAULT_AUTOPICKUP,
|
|
375
|
+
DEFAULT_AUTOPICKUP_CAP,
|
|
376
|
+
MAX_AUTOPICKUP_CAP,
|
|
377
|
+
parseAutopickup,
|
|
378
|
+
parseAutopickupCap,
|
|
379
|
+
resolveTweakAutopickup,
|
|
380
|
+
describeTweakAutopickup,
|
|
302
381
|
};
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.107'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -474,6 +474,13 @@ module.exports = {
|
|
|
474
474
|
get parseShipMode() { return require('./bongos/project-settings').parseShipMode; },
|
|
475
475
|
get resolveShipMode() { return require('./bongos/project-settings').resolveShipMode; },
|
|
476
476
|
get describeShipMode() { return require('./bongos/project-settings').describeShipMode; },
|
|
477
|
+
// Tweak auto-pickup (task 1004677, ADR 0371 D3/D5): the switch and the monthly cap.
|
|
478
|
+
get AUTOPICKUP_MODES() { return require('./bongos/project-settings').AUTOPICKUP_MODES; },
|
|
479
|
+
get MAX_AUTOPICKUP_CAP() { return require('./bongos/project-settings').MAX_AUTOPICKUP_CAP; },
|
|
480
|
+
get parseAutopickup() { return require('./bongos/project-settings').parseAutopickup; },
|
|
481
|
+
get parseAutopickupCap() { return require('./bongos/project-settings').parseAutopickupCap; },
|
|
482
|
+
get resolveTweakAutopickup() { return require('./bongos/project-settings').resolveTweakAutopickup; },
|
|
483
|
+
get describeTweakAutopickup() { return require('./bongos/project-settings').describeTweakAutopickup; },
|
|
477
484
|
},
|
|
478
485
|
readConfigFileSync: instanceConfig.readConfigFileSync,
|
|
479
486
|
configHome: instanceConfig.configHome,
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// tests/autopickup_cap.mjs — tweak auto-pickup's setting, monthly cap and claim mark
|
|
2
|
+
// (task 1004677 / BV2.TW19a, ADR 0371 D3, D5, D6).
|
|
3
|
+
//
|
|
4
|
+
// Pinned here (DB-free; the race lives in tests/autopickup_cap_db.mjs):
|
|
5
|
+
// 1. the two parsers and the resolver: off by default, cap 20 by default, an
|
|
6
|
+
// unusable stored value reported and never thrown, a dead DB answers defaults;
|
|
7
|
+
// 2. the refusal ladder: not a tweak, switch off, round limit, monthly cap, and the
|
|
8
|
+
// lock is taken BEFORE either count;
|
|
9
|
+
// 3. claimTask: a marked claim runs the gate before the insert and names the
|
|
10
|
+
// column; an unmarked claim's insert is unchanged;
|
|
11
|
+
// 4. the routes: any builder reads (with this month's count), only
|
|
12
|
+
// project.settings.manage writes, and a half-valid body writes nothing.
|
|
13
|
+
//
|
|
14
|
+
// Run: node --test tests/autopickup_cap.mjs
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const poolMod = require(path.join(ROOT, 'src', 'bongos', 'pool.js'));
|
|
25
|
+
const {
|
|
26
|
+
DEFAULT_AUTOPICKUP, DEFAULT_AUTOPICKUP_CAP, parseAutopickup, parseAutopickupCap,
|
|
27
|
+
resolveTweakAutopickup, describeTweakAutopickup, SETTING_KEYS, AUTOPICKUP_MODES, MAX_AUTOPICKUP_CAP,
|
|
28
|
+
} = require('../src/bongos/project-settings.js');
|
|
29
|
+
const { autoPickupRefusal, LOCK_KEY, MAX_AUTO_APPLIES_PER_ROUND } = require('../modules/lifecycle/autopickup.js');
|
|
30
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
31
|
+
|
|
32
|
+
async function withPool(query, fn) {
|
|
33
|
+
const pool = poolMod.pool;
|
|
34
|
+
const saved = pool.query;
|
|
35
|
+
pool.query = query;
|
|
36
|
+
try { return await fn(); } finally { pool.query = saved; }
|
|
37
|
+
}
|
|
38
|
+
const settingsRows = (map) => async (_sql, [key]) => ({ rows: key in map ? [{ key, value: map[key] }] : [] });
|
|
39
|
+
|
|
40
|
+
// ---- 1. the setting ---------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
test('the parsers accept exactly off|on and a whole positive cap', () => {
|
|
43
|
+
assert.deepEqual([...AUTOPICKUP_MODES], ['off', 'on']);
|
|
44
|
+
assert.equal(api.projectSettings.MAX_AUTOPICKUP_CAP, MAX_AUTOPICKUP_CAP, 'the doorway hands out the same bound');
|
|
45
|
+
assert.deepEqual(parseAutopickup(' On '), { mode: 'on' });
|
|
46
|
+
assert.match(parseAutopickup('maybe').reason, /off \| on/);
|
|
47
|
+
assert.deepEqual(parseAutopickupCap('5'), { cap: 5 });
|
|
48
|
+
assert.match(parseAutopickupCap('0').reason, /positive/);
|
|
49
|
+
assert.match(parseAutopickupCap('2.5').reason, /whole/);
|
|
50
|
+
assert.match(parseAutopickupCap('100000').reason, /out of range/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('an untouched project is off with a cap of 20', async () => {
|
|
54
|
+
assert.equal(DEFAULT_AUTOPICKUP, 'off');
|
|
55
|
+
assert.equal(DEFAULT_AUTOPICKUP_CAP, 20);
|
|
56
|
+
const r = await withPool(settingsRows({}), () => resolveTweakAutopickup());
|
|
57
|
+
assert.deepEqual(r, { mode: 'off', cap: 20, source: { mode: 'default', cap: 'default' }, invalid: [] });
|
|
58
|
+
assert.match(describeTweakAutopickup(r), /waits until a person/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('stored values are read; an unusable one is reported and falls back', async () => {
|
|
62
|
+
const r = await withPool(settingsRows({ [SETTING_KEYS.TWEAK_AUTOPICKUP]: 'on', [SETTING_KEYS.TWEAK_AUTOPICKUP_CAP]: 'lots' }),
|
|
63
|
+
() => resolveTweakAutopickup());
|
|
64
|
+
assert.equal(r.mode, 'on');
|
|
65
|
+
assert.equal(r.source.mode, 'project_settings');
|
|
66
|
+
assert.equal(r.cap, 20);
|
|
67
|
+
assert.equal(r.invalid.length, 1);
|
|
68
|
+
assert.equal(r.invalid[0].raw, 'lots');
|
|
69
|
+
assert.match(describeTweakAutopickup({ mode: 'on', cap: 7 }), /up to 7 a month/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('a database that cannot be read answers the defaults', async () => {
|
|
73
|
+
const r = await withPool(async () => { throw new Error('db down'); }, () => resolveTweakAutopickup());
|
|
74
|
+
assert.equal(r.mode, 'off');
|
|
75
|
+
assert.equal(r.cap, 20);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ---- 2. the refusal ladder --------------------------------------------------
|
|
79
|
+
|
|
80
|
+
function fakeClient({ round = 0, month = 0 } = {}) {
|
|
81
|
+
const calls = [];
|
|
82
|
+
return {
|
|
83
|
+
calls,
|
|
84
|
+
async query(sql, params) {
|
|
85
|
+
calls.push(sql.includes('pg_advisory_xact_lock') ? `lock:${params[0]}` : sql.includes('date_trunc') ? 'month' : 'round');
|
|
86
|
+
if (sql.includes('pg_advisory_xact_lock')) return { rows: [] };
|
|
87
|
+
return { rows: [{ n: sql.includes('date_trunc') ? month : round }] };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const tweak = { id: 9, source: 'page-tweak' };
|
|
92
|
+
const on = { mode: 'on', cap: 3 };
|
|
93
|
+
|
|
94
|
+
test('a marked claim on anything but a page tweak is refused, before any lock', async () => {
|
|
95
|
+
const c = fakeClient();
|
|
96
|
+
assert.equal((await autoPickupRefusal(c, { id: 9, source: 'manual' }, on)).code, 'AUTO_PICKUP_NOT_A_TWEAK');
|
|
97
|
+
assert.deepEqual(c.calls, []);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the switch off refuses a marked claim', async () => {
|
|
101
|
+
assert.equal((await autoPickupRefusal(fakeClient(), tweak, { mode: 'off', cap: 3 })).code, 'AUTO_PICKUP_OFF');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the lock is taken before either count, on the one project-wide key', async () => {
|
|
105
|
+
const c = fakeClient();
|
|
106
|
+
assert.equal(await autoPickupRefusal(c, tweak, on), null);
|
|
107
|
+
assert.equal(LOCK_KEY, 'page-tweak/autopickup');
|
|
108
|
+
assert.deepEqual(c.calls, ['lock:page-tweak/autopickup', 'round', 'month']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a third automatic apply of one round is refused', async () => {
|
|
112
|
+
assert.equal(MAX_AUTO_APPLIES_PER_ROUND, 2);
|
|
113
|
+
assert.equal(await autoPickupRefusal(fakeClient({ round: 1 }), tweak, on), null);
|
|
114
|
+
const r = await autoPickupRefusal(fakeClient({ round: 2 }), tweak, on);
|
|
115
|
+
assert.equal(r.code, 'AUTO_PICKUP_ROUND_LIMIT');
|
|
116
|
+
assert.match(r.message, /waits for a person/);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the monthly cap refuses at the cap and admits below it', async () => {
|
|
120
|
+
assert.equal(await autoPickupRefusal(fakeClient({ month: 2 }), tweak, on), null);
|
|
121
|
+
const r = await autoPickupRefusal(fakeClient({ month: 3 }), tweak, on);
|
|
122
|
+
assert.equal(r.code, 'AUTO_PICKUP_CAP_REACHED');
|
|
123
|
+
assert.equal(r.cap, 3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- 3. claimTask wiring ----------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test('claimTask runs the gate before the insert, and names the column only on a marked claim', () => {
|
|
129
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
|
|
130
|
+
const body = src.slice(src.indexOf('async function claimTask('), src.indexOf('async function claimTasksBatch'));
|
|
131
|
+
const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings)');
|
|
132
|
+
const insert = body.indexOf('INSERT INTO claims');
|
|
133
|
+
assert.ok(gate > 0 && insert > gate, 'the gate runs inside the transaction, before the insert');
|
|
134
|
+
assert.match(body.slice(gate, insert), /if \(refusal\) throw refusal;/, 'a refusal stops the claim');
|
|
135
|
+
assert.match(body, /\$\{autoPickup \? ', auto_pickup' : ''\}/);
|
|
136
|
+
assert.match(body, /goal_id, source FROM tasks WHERE id = \$1 FOR UPDATE/, 'the task row carries its source');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('POST /claims accepts auto_pickup and maps every refusal to a 409', () => {
|
|
140
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'claims.js'), 'utf8');
|
|
141
|
+
assert.match(src, /auto_pickup: \{ type: 'boolean' \}/);
|
|
142
|
+
assert.match(src, /autoPickup: autoPickup === true/);
|
|
143
|
+
for (const code of ['AUTO_PICKUP_NOT_A_TWEAK', 'AUTO_PICKUP_OFF', 'AUTO_PICKUP_CAP_REACHED', 'AUTO_PICKUP_ROUND_LIMIT']) {
|
|
144
|
+
assert.match(src, new RegExp(`err.code === '${code}' \\? 409`));
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- 4. the routes ----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
const fakeRes = () => ({
|
|
151
|
+
code: null, statusCode: 200, payload: null, headers: {},
|
|
152
|
+
fail(code, opts) { this.code = code; this.statusCode = opts?.status ?? 400; this.payload = { error: code, ...(opts || {}) }; return this; },
|
|
153
|
+
status(c) { this.statusCode = c; return this; },
|
|
154
|
+
set(k, v) { this.headers[k] = v; return this; },
|
|
155
|
+
json(p) { this.payload = p; return this; },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function buildRouter({ used = 4 } = {}) {
|
|
159
|
+
const ps = api.projectSettings;
|
|
160
|
+
const saved = { resolve: ps.resolveTweakAutopickup, set: ps.set };
|
|
161
|
+
const writes = [];
|
|
162
|
+
const state = { mode: 'off', cap: 20 };
|
|
163
|
+
ps.resolveTweakAutopickup = async () => ({ ...state, source: { mode: 'default', cap: 'default' }, invalid: [] });
|
|
164
|
+
ps.set = async (key, value, by) => {
|
|
165
|
+
writes.push({ key, value, by });
|
|
166
|
+
if (key === 'tweak.autopickup') state.mode = value; else state.cap = value;
|
|
167
|
+
};
|
|
168
|
+
const autopickup = require('../modules/lifecycle/autopickup.js');
|
|
169
|
+
const savedCount = autopickup.countThisMonth;
|
|
170
|
+
autopickup.countThisMonth = async () => used;
|
|
171
|
+
const file = path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js');
|
|
172
|
+
delete require.cache[require.resolve(file)];
|
|
173
|
+
const router = require(file)();
|
|
174
|
+
const route = (method, p) => router.stack.find((l) => l.route && l.route.path === p && l.route.methods[method]).route;
|
|
175
|
+
const restore = () => { ps.resolveTweakAutopickup = saved.resolve; ps.set = saved.set; autopickup.countThisMonth = savedCount; };
|
|
176
|
+
return { route, writes, restore };
|
|
177
|
+
}
|
|
178
|
+
const handler = (r) => r.stack[r.stack.length - 1].handle;
|
|
179
|
+
|
|
180
|
+
test('the read is open to any builder and reports this month\'s count against the cap', async () => {
|
|
181
|
+
const { route, restore } = buildRouter({ used: 4 });
|
|
182
|
+
try {
|
|
183
|
+
const get = route('get', '/project-settings/tweak-autopickup');
|
|
184
|
+
assert.equal(get.stack.length, 2, 'requireBuilder + the handler');
|
|
185
|
+
const res = fakeRes();
|
|
186
|
+
await handler(get)({}, res);
|
|
187
|
+
assert.equal(res.payload.mode, 'off');
|
|
188
|
+
assert.equal(res.payload.used_this_month, 4);
|
|
189
|
+
assert.equal(res.payload.remaining_this_month, 16);
|
|
190
|
+
assert.equal(res.payload.per_round_limit, 2);
|
|
191
|
+
assert.equal(res.headers['Cache-Control'], 'no-store');
|
|
192
|
+
} finally { restore(); }
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('the write is gated on project.settings.manage and the Rules decision rule', () => {
|
|
196
|
+
const { route, restore } = buildRouter();
|
|
197
|
+
try {
|
|
198
|
+
const write = route('patch', '/project-settings/tweak-autopickup').stack;
|
|
199
|
+
assert.equal(write.length, 4);
|
|
200
|
+
assert.equal(write[2].handle.name, 'actAloneGate');
|
|
201
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'), 'utf8');
|
|
202
|
+
assert.match(src, /router\.patch\('\/project-settings\/tweak-autopickup', api\.requireBuilder, api\.requirePermission\('project\.settings\.manage'\)/);
|
|
203
|
+
} finally { restore(); }
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('a half-valid body writes nothing; a good one writes both and echoes the new read', async () => {
|
|
207
|
+
const { route, writes, restore } = buildRouter({ used: 0 });
|
|
208
|
+
try {
|
|
209
|
+
const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
|
|
210
|
+
const bad = fakeRes();
|
|
211
|
+
await patch({ body: { mode: 'on', cap: 0 }, builder: { id: 7 } }, bad);
|
|
212
|
+
assert.equal(bad.code, 'bad_tweak_autopickup');
|
|
213
|
+
assert.equal(writes.length, 0);
|
|
214
|
+
|
|
215
|
+
const empty = fakeRes();
|
|
216
|
+
await patch({ body: {}, builder: { id: 7 } }, empty);
|
|
217
|
+
assert.equal(empty.code, 'bad_tweak_autopickup');
|
|
218
|
+
|
|
219
|
+
const good = fakeRes();
|
|
220
|
+
await patch({ body: { mode: 'ON', cap: 5 }, builder: { id: 7 } }, good);
|
|
221
|
+
assert.equal(good.code, null);
|
|
222
|
+
assert.deepEqual(writes, [
|
|
223
|
+
{ key: 'tweak.autopickup', value: 'on', by: 7 },
|
|
224
|
+
{ key: 'tweak.autopickup_monthly_cap', value: 5, by: 7 },
|
|
225
|
+
]);
|
|
226
|
+
assert.equal(good.payload.mode, 'on');
|
|
227
|
+
assert.equal(good.payload.cap, 5);
|
|
228
|
+
} finally { restore(); }
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('core_275 is additive: one nullable column, recorded in schema_migrations', () => {
|
|
232
|
+
const sql = fs.readFileSync(path.join(ROOT, 'migrations', 'core_275_claims_auto_pickup.sql'), 'utf8');
|
|
233
|
+
assert.match(sql, /ALTER TABLE claims ADD COLUMN IF NOT EXISTS auto_pickup boolean;/);
|
|
234
|
+
assert.doesNotMatch(sql, /NOT NULL|DROP /);
|
|
235
|
+
assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('core_275_claims_auto_pickup'\)/);
|
|
236
|
+
});
|