@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.
@@ -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.106",
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.106",
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.106",
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",
@@ -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.106'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
+ });