@bongos/core 1.24.6 → 1.24.7
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 +25 -25
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
- package/docs/api/openapi.json +4 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +17 -12
- package/docs/copy-registry.json +79 -33
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +9 -9
- package/modules/hall-ui/public/project-settings.html +4 -4
- package/modules/hall-ui/public/project-settings.js +51 -1
- package/modules/lifecycle/autopickup.js +26 -9
- package/modules/lifecycle/db-claims.js +1 -1
- package/modules/lifecycle/routes/project-settings.js +71 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/project-settings.js +59 -15
- package/src/module-api.js +1 -1
- package/tests/autopickup_cap.mjs +203 -11
- package/tests/project_settings_page.mjs +8 -5
package/docs/page-readings.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"adr": "0341 D3",
|
|
6
6
|
"reread": "node scripts/bongos/page-reader.js --stale",
|
|
7
7
|
"how": ["Generated by node scripts/bongos/page-reader.js (BV2.TW04, task 1004315; ADR 0341 D3). Do not hand-edit.","One entry per page of docs/page-inventory.json. Lines are in page order: the default state first, and a line only another state shows sits where that state shows it, with its state named.","placement: placed = a docs/copy-registry.json row renders it (file, line, string_id); shared = shell text every page of the surface carries (top bar, sidebar, footer), with its row when the registry has one; unplaced = the registry cannot place it (a rewrite becomes an engineer task, ADR 0341 D4).","text keeps {…} where the registry row has a hole, or where a value the page read from its API was filled in. A string that is only such data is counted under omitted.data, not listed.","reading_hash is over the non-shared lines (key, section, text); files_hash is over the page's inventory files. --check compares files_hash only and never opens a browser.","Rendered through the hall-preview harness (hall) and the ui-design kit stub (landing, status), at 1280x800 with the clock pinned, so a reading is the fixture world's page."],
|
|
8
|
-
"counts": {"pages":48,"lines":5464,"placed":
|
|
8
|
+
"counts": {"pages":48,"lines":5464,"placed":1687,"shared":427,"unplaced":3350,"omitted_data":3732},
|
|
9
9
|
"pages": [
|
|
10
10
|
{
|
|
11
11
|
"id": "builders:ai-studio",
|
|
@@ -2595,9 +2595,9 @@
|
|
|
2595
2595
|
"surface": "builders",
|
|
2596
2596
|
"title": "Project settings",
|
|
2597
2597
|
"states_read": ["default"],
|
|
2598
|
-
"files_hash": "
|
|
2599
|
-
"reading_hash": "
|
|
2600
|
-
"counts": {"lines":38,"placed":
|
|
2598
|
+
"files_hash": "39656ae681066cb4",
|
|
2599
|
+
"reading_hash": "4f7a29f2c2f0ad7c",
|
|
2600
|
+
"counts": {"lines":38,"placed":20,"shared":8,"unplaced":10},
|
|
2601
2601
|
"omitted": {"data":31},
|
|
2602
2602
|
"notes": [],
|
|
2603
2603
|
"lines": [
|
|
@@ -2609,7 +2609,7 @@
|
|
|
2609
2609
|
{"key":"L0006","section":"top bar","text":"Artistry","placement":"shared"},
|
|
2610
2610
|
{"key":"L0007","section":"top bar","text":"Ideating","placement":"shared"},
|
|
2611
2611
|
{"key":"L0008","section":"The fundamentals","text":"The fundamentals","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":39,"string_id":"cb7c7879eeba"},
|
|
2612
|
-
{"key":"L0009","section":"The fundamentals","text":"
|
|
2612
|
+
{"key":"L0009","section":"The fundamentals","text":"Seven things every project settles. Four of them are knobs you can turn here; the other three are decided in committed configuration, and each says which file decides it.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":40,"string_id":"f7da8748453d"},
|
|
2613
2613
|
{"key":"L0010","section":"The fundamentals","text":"{…} and {…}","placement":"placed","file":"modules/hall-ui/public/profile-nudge.js","line":77,"string_id":"56b00d773232","occurrences":9},
|
|
2614
2614
|
{"key":"L0011","section":"The fundamentals","text":"How tasks ship","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":136,"string_id":"76f344dd4727"},
|
|
2615
2615
|
{"key":"L0012","section":"The fundamentals","text":"Save","placement":"unplaced","occurrences":4},
|
|
@@ -2617,14 +2617,14 @@
|
|
|
2617
2617
|
{"key":"L0014","section":"The fundamentals","text":"Raise after","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":115,"string_id":"4de232a05888"},
|
|
2618
2618
|
{"key":"L0015","section":"The fundamentals","text":"days ({…}–{…})","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":115,"string_id":"0e2bef756977"},
|
|
2619
2619
|
{"key":"L0016","section":"The fundamentals","text":"offered","placement":"unplaced","occurrences":2},
|
|
2620
|
-
{"key":"L0017","section":"The fundamentals","text":"New category label","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":
|
|
2621
|
-
{"key":"L0018","section":"The fundamentals","text":"Add","placement":"
|
|
2620
|
+
{"key":"L0017","section":"The fundamentals","text":"New category label","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":193,"string_id":"87f09d983f6c","attr":"placeholder"},
|
|
2621
|
+
{"key":"L0018","section":"The fundamentals","text":"Add","placement":"placed","file":"modules/hall-ui/public/ai-studio.js","line":469,"string_id":"e4da002ae135"},
|
|
2622
2622
|
{"key":"L0019","section":"The fundamentals","text":"committed config","placement":"unplaced"},
|
|
2623
2623
|
{"key":"L0020","section":"The fundamentals","text":"not yet real","placement":"unplaced"},
|
|
2624
2624
|
{"key":"L0021","section":"The fundamentals","text":"not available ({…})","placement":"unplaced"},
|
|
2625
2625
|
{"key":"L0022","section":"Danger zone","text":"Danger zone","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":56,"string_id":"13b22e48d9fb"},
|
|
2626
|
-
{"key":"L0023","section":"Danger zone","text":"This project is hosted on {…}, and that is where it is taken offline or removed. Both links below open this project’s page there.","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":
|
|
2627
|
-
{"key":"L0024","section":"Danger zone","text":"Manage this project on {…}","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":
|
|
2626
|
+
{"key":"L0023","section":"Danger zone","text":"This project is hosted on {…}, and that is where it is taken offline or removed. Both links below open this project’s page there.","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":350,"string_id":"e97b2d549f10"},
|
|
2627
|
+
{"key":"L0024","section":"Danger zone","text":"Manage this project on {…}","placement":"placed","file":"modules/hall-ui/public/project-settings.js","line":351,"string_id":"4e261fd15bd5"},
|
|
2628
2628
|
{"key":"L0025","section":"Danger zone","text":"Take it offline","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":59,"string_id":"da9da6e866bd"},
|
|
2629
2629
|
{"key":"L0026","section":"Danger zone","text":"— stops the project and its nightly backups but keeps everything: its database, its backups and your repository on GitHub. You can bring it back.","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":59,"string_id":"61ebc5d684fd"},
|
|
2630
2630
|
{"key":"L0027","section":"Danger zone","text":"Disconnect from Bongos entirely","placement":"placed","file":"modules/hall-ui/public/project-settings.html","line":60,"string_id":"d3efd513694b"},
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Project settings — {{worldName}}</title>
|
|
7
|
-
<meta name="description" content="The fundamentals of this project in one place — how tasks ship, the stale timer, the category set, how credits are calculated, why credits are not equity, and whether a credit-weighted pool can pay out.">
|
|
7
|
+
<meta name="description" content="The fundamentals of this project in one place — how tasks ship, how many tweak rounds a month, the stale timer, the category set, how credits are calculated, why credits are not equity, and whether a credit-weighted pool can pay out.">
|
|
8
8
|
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
9
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
10
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
@@ -38,11 +38,11 @@
|
|
|
38
38
|
<section class="scroll" id="ps-body" aria-labelledby="ps-body-h" hidden>
|
|
39
39
|
<h2 class="scroll__h" id="ps-body-h">The fundamentals</h2>
|
|
40
40
|
<p class="scroll__lede">
|
|
41
|
-
|
|
41
|
+
Seven things every project settles. Four of them are knobs you can turn here;
|
|
42
42
|
the other three are decided in committed configuration, and each says which
|
|
43
43
|
file decides it.
|
|
44
44
|
</p>
|
|
45
|
-
<!-- One mount,
|
|
45
|
+
<!-- One mount, seven rows, written by project-settings.js. The rows are NOT
|
|
46
46
|
authored in this file: each carries a value plus the provenance the
|
|
47
47
|
server resolved it through, and markup that could drift from that is the
|
|
48
48
|
thing this page exists to end. -->
|
|
@@ -76,6 +76,6 @@
|
|
|
76
76
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
77
77
|
<script src="/builders/palette.js" defer></script>
|
|
78
78
|
<script src="/builders/hall-kit.js"></script>
|
|
79
|
-
<script src="/builders/project-settings.js?v=2026-10-
|
|
79
|
+
<script src="/builders/project-settings.js?v=2026-10-29-tweak-cap"></script>
|
|
80
80
|
</body>
|
|
81
81
|
</html>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// project-settings.js — the
|
|
1
|
+
// project-settings.js — the seven fundamentals of this project, each with the place
|
|
2
2
|
// its value came from (task 1003280, goal 1000072, criterion legibility-project-settings).
|
|
3
3
|
//
|
|
4
4
|
// WHAT THIS PAGE IS FOR. Goal 1000072's thesis: the project is legible to Claude and
|
|
@@ -148,6 +148,35 @@
|
|
|
148
148
|
</div>`;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
// The monthly tweak-round cap (task 1005003): the project's number, then one box
|
|
152
|
+
// per builder rank. The ranks and the bound come from the server; a blank rank box
|
|
153
|
+
// means that rank follows the project's number.
|
|
154
|
+
function tweakCapBodyHtml(row) {
|
|
155
|
+
const v = row.value || {};
|
|
156
|
+
const ranks = Array.isArray(v.ranks) ? v.ranks : [];
|
|
157
|
+
const max = escapeHtml(String(v.max));
|
|
158
|
+
const invalid = row.invalid
|
|
159
|
+
? `<p class="ps-invalid">A configured value could not be used: <code>${escapeHtml(String(row.invalid.raw))}</code>
|
|
160
|
+
— ${escapeHtml(String(row.invalid.reason))}. The limit above is the fallback that is actually in force.</p>`
|
|
161
|
+
: '';
|
|
162
|
+
return `
|
|
163
|
+
${invalid}
|
|
164
|
+
<div class="ps-controls">
|
|
165
|
+
<label for="ps-tweak-cap">Project limit</label>
|
|
166
|
+
<input type="number" id="ps-tweak-cap" min="1" max="${max}" step="1" value="${escapeHtml(String(v.cap))}">
|
|
167
|
+
<span>rounds a month (1–${max})</span>
|
|
168
|
+
</div>
|
|
169
|
+
${ranks.map((r) => `
|
|
170
|
+
<div class="ps-controls">
|
|
171
|
+
<label for="ps-tweak-rank-${escapeHtml(String(r.rank))}">${escapeHtml(String(r.rank))}</label>
|
|
172
|
+
<input type="number" class="ps-tweak-rank" id="ps-tweak-rank-${escapeHtml(String(r.rank))}" data-rank="${escapeHtml(String(r.rank))}"
|
|
173
|
+
min="1" max="${max}" step="1" value="${r.cap == null ? '' : escapeHtml(String(r.cap))}" placeholder="project limit">
|
|
174
|
+
</div>`).join('')}
|
|
175
|
+
<div class="ps-controls">
|
|
176
|
+
<button type="button" class="btn" id="ps-tweak-save">Save</button>
|
|
177
|
+
</div>`;
|
|
178
|
+
}
|
|
179
|
+
|
|
151
180
|
function catsBodyHtml(row) {
|
|
152
181
|
const v = row.value || {};
|
|
153
182
|
const cats = Array.isArray(v.categories) ? v.categories : [];
|
|
@@ -177,6 +206,7 @@
|
|
|
177
206
|
const mount = $('ps-rows');
|
|
178
207
|
mount.innerHTML = `<ul class="ov-rows">${rows.map((row) => {
|
|
179
208
|
if (row.key === 'ship_mode' && row.editable) return rowHtml(row, shipBodyHtml(row));
|
|
209
|
+
if (row.key === 'tweak_round_cap' && row.editable) return rowHtml(row, tweakCapBodyHtml(row));
|
|
180
210
|
if (row.key === 'rot_timer' && row.editable) return rowHtml(row, rotBodyHtml(row));
|
|
181
211
|
if (row.key === 'work_categories' && row.editable) return rowHtml(row, catsBodyHtml(row));
|
|
182
212
|
return rowHtml(row, '');
|
|
@@ -210,6 +240,26 @@
|
|
|
210
240
|
});
|
|
211
241
|
}
|
|
212
242
|
|
|
243
|
+
const tweakSave = $('ps-tweak-save');
|
|
244
|
+
if (tweakSave) {
|
|
245
|
+
tweakSave.addEventListener('click', async () => {
|
|
246
|
+
const rankCaps = {};
|
|
247
|
+
document.querySelectorAll('.ps-tweak-rank').forEach((el) => {
|
|
248
|
+
const raw = String(el.value || '').trim();
|
|
249
|
+
rankCaps[el.getAttribute('data-rank')] = raw === '' ? null : Number(raw);
|
|
250
|
+
});
|
|
251
|
+
setBusy(true);
|
|
252
|
+
try {
|
|
253
|
+
const out = await sendJson('PATCH', '/project-settings/tweak-autopickup', { cap: Number($('ps-tweak-cap').value), rank_caps: rankCaps });
|
|
254
|
+
toast(`Monthly tweak rounds: ${out.cap} for the project.`);
|
|
255
|
+
await load();
|
|
256
|
+
} catch (err) {
|
|
257
|
+
toast(err.message || 'Could not save the limit.');
|
|
258
|
+
setBusy(false);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
213
263
|
const rotSave = $('ps-rot-save');
|
|
214
264
|
if (rotSave) {
|
|
215
265
|
rotSave.addEventListener('click', async () => {
|
|
@@ -56,11 +56,26 @@ async function countForRound(exec, taskId) {
|
|
|
56
56
|
return Number(rows[0] && rows[0].n) || 0;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
59
|
+
// effectiveCap(settings, rank) — { cap, from: 'rank' | 'project' } (task 1005003).
|
|
60
|
+
// The claimer's rank's own cap when the project set one, else the project cap. The
|
|
61
|
+
// cap is measured against the project's monthly count either way: a rank cap says
|
|
62
|
+
// how far into the month a runner of that rank may still pick up, not a separate
|
|
63
|
+
// allowance per builder, so the ledger count stays one project-wide number. Pure.
|
|
64
|
+
function effectiveCap(settings, rank) {
|
|
65
|
+
const s = settings || {};
|
|
66
|
+
const caps = s.rank_caps || {};
|
|
67
|
+
if (rank && Object.prototype.hasOwnProperty.call(caps, rank) && Number.isInteger(caps[rank])) {
|
|
68
|
+
return { cap: caps[rank], from: 'rank' };
|
|
69
|
+
}
|
|
70
|
+
return { cap: s.cap, from: 'project' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// autoPickupRefusal(client, task, settings, rank) — null when the marked claim may
|
|
60
74
|
// proceed, else the refusal object claimTask throws. `settings` is the resolved
|
|
61
|
-
// { mode, cap }, read BEFORE the transaction opens (the
|
|
62
|
-
// precedent: a settings read is not part of the claim's
|
|
63
|
-
|
|
75
|
+
// { mode, cap, rank_caps }, read BEFORE the transaction opens (the
|
|
76
|
+
// claimPermissionsFor precedent: a settings read is not part of the claim's
|
|
77
|
+
// consistency). `rank` is the claimer's rank, read inside it.
|
|
78
|
+
async function autoPickupRefusal(client, task, settings, rank = null) {
|
|
64
79
|
if (!task || task.source !== PAGE_TWEAK_SOURCE) {
|
|
65
80
|
return { code: 'AUTO_PICKUP_NOT_A_TWEAK', message: 'Only a page tweak round can be claimed with the auto_pickup mark.' };
|
|
66
81
|
}
|
|
@@ -77,11 +92,12 @@ async function autoPickupRefusal(client, task, settings) {
|
|
|
77
92
|
};
|
|
78
93
|
}
|
|
79
94
|
const used = await countThisMonth(client);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
95
|
+
const { cap, from } = effectiveCap(s, rank);
|
|
96
|
+
if (used >= cap) {
|
|
97
|
+
const message = from === 'rank'
|
|
98
|
+
? `This month's auto-pickups have reached ${cap}, the limit this project sets for a ${rank} builder's runner, so submitted pages wait for a person until next month.`
|
|
99
|
+
: `Auto-pickup has used this month's ${cap} rounds, so submitted pages wait for a person until next month.`;
|
|
100
|
+
return { code: 'AUTO_PICKUP_CAP_REACHED', used, cap, cap_from: from, message };
|
|
85
101
|
}
|
|
86
102
|
return null;
|
|
87
103
|
}
|
|
@@ -90,5 +106,6 @@ module.exports = {
|
|
|
90
106
|
LOCK_KEY,
|
|
91
107
|
MAX_AUTO_APPLIES_PER_ROUND,
|
|
92
108
|
countThisMonth,
|
|
109
|
+
effectiveCap,
|
|
93
110
|
autoPickupRefusal,
|
|
94
111
|
};
|
|
@@ -374,7 +374,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
374
374
|
// limit are counted under a project-wide advisory lock that this transaction
|
|
375
375
|
// holds through the insert below, so two runners cannot both take the last slot.
|
|
376
376
|
if (autoPickup) {
|
|
377
|
-
const refusal = await autoPickupRefusal(client, task, autoPickupSettings);
|
|
377
|
+
const refusal = await autoPickupRefusal(client, task, autoPickupSettings, builderRank);
|
|
378
378
|
if (refusal) throw refusal;
|
|
379
379
|
}
|
|
380
380
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// modules/lifecycle/routes/project-settings.js — the
|
|
3
|
+
// modules/lifecycle/routes/project-settings.js — the seven fundamentals of a project,
|
|
4
4
|
// in one read, each carrying WHERE its value came from (task 1003280, goal 1000072,
|
|
5
5
|
// criterion legibility-project-settings).
|
|
6
6
|
//
|
|
@@ -60,6 +60,33 @@ function shipModeRow(resolved) {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// --- the monthly tweak-round cap (task 1005003) -----------------------------
|
|
64
|
+
//
|
|
65
|
+
// The project cap and one optional cap per builder rank, editable on the settings
|
|
66
|
+
// page. The ranks come from the kernel's live ladder, never re-typed here or on the
|
|
67
|
+
// page; a rank with no number follows the project cap.
|
|
68
|
+
function tweakCapRow(resolved) {
|
|
69
|
+
const r = resolved || {};
|
|
70
|
+
const rankCaps = r.rank_caps || {};
|
|
71
|
+
return {
|
|
72
|
+
key: 'tweak_round_cap',
|
|
73
|
+
title: 'Monthly tweak rounds',
|
|
74
|
+
editable: true,
|
|
75
|
+
state: 'live',
|
|
76
|
+
value: {
|
|
77
|
+
cap: r.cap,
|
|
78
|
+
max: api.projectSettings.MAX_AUTOPICKUP_CAP,
|
|
79
|
+
ranks: api.LIVE_RANK_LADDER.map((rank) => ({ rank, cap: Object.prototype.hasOwnProperty.call(rankCaps, rank) ? rankCaps[rank] : null })),
|
|
80
|
+
},
|
|
81
|
+
sentence: `Tweak auto-pickup may apply up to ${r.cap} submitted pages a month. A builder rank can have its own limit; `
|
|
82
|
+
+ 'a rank left blank follows the project limit. When the limit is reached, pages wait for a person.',
|
|
83
|
+
source: r.source ? r.source.cap : 'default',
|
|
84
|
+
// Only a cap value this row draws; an unusable on/off switch is not this row's.
|
|
85
|
+
invalid: (r.invalid || []).find((i) => /autopickup_(monthly_cap|rank_cap\.)/.test(String(i && i.where))) || null,
|
|
86
|
+
reads: { file: 'project_settings[\'tweak.autopickup_monthly_cap\', \'tweak.autopickup_rank_cap.<rank>\']', adr: 'ADR 0371 D5' },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
// callerHolds(builderId, atom) → true | false | null. null means authority could
|
|
64
91
|
// not be established (government port missing or the resolver threw); a reader
|
|
65
92
|
// treats that as unknown, never as a grant.
|
|
@@ -264,6 +291,7 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
264
291
|
// category the owner cannot see is one they cannot bring back.
|
|
265
292
|
const categories = await goalCategory.listCategories({ includeInactive: true });
|
|
266
293
|
const shipMode = await api.projectSettings.resolveShipMode();
|
|
294
|
+
const tweak = await api.projectSettings.resolveTweakAutopickup();
|
|
267
295
|
|
|
268
296
|
// A settings read is a configuration comparison — a cached one is wrong the
|
|
269
297
|
// moment somebody turns a knob (the rot.js precedent).
|
|
@@ -271,6 +299,7 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
271
299
|
res.json({
|
|
272
300
|
rows: [
|
|
273
301
|
shipModeRow(shipMode),
|
|
302
|
+
tweakCapRow(tweak),
|
|
274
303
|
{
|
|
275
304
|
key: 'rot_timer',
|
|
276
305
|
title: 'Stale timer',
|
|
@@ -443,14 +472,23 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
443
472
|
//
|
|
444
473
|
// rank: any signed-in builder. The runner reads it before it tries a round, and
|
|
445
474
|
// the studio shows the count; it carries no reward internals or file paths.
|
|
446
|
-
|
|
475
|
+
//
|
|
476
|
+
// cap is the project's; rank_caps the per-rank caps the project set (task
|
|
477
|
+
// 1005003). my_cap is the one that applies to the CALLER's rank, and
|
|
478
|
+
// remaining_this_month is measured against it, so a runner reads the number the
|
|
479
|
+
// claim gate will hold it to.
|
|
480
|
+
async function autopickupBody(rank = null) {
|
|
447
481
|
const resolved = await api.projectSettings.resolveTweakAutopickup();
|
|
448
482
|
const used = await autopickup.countThisMonth();
|
|
483
|
+
const mine = autopickup.effectiveCap(resolved, rank);
|
|
449
484
|
return {
|
|
450
485
|
mode: resolved.mode,
|
|
451
486
|
cap: resolved.cap,
|
|
487
|
+
rank_caps: resolved.rank_caps || {},
|
|
488
|
+
my_cap: mine.cap,
|
|
489
|
+
my_cap_from: mine.from,
|
|
452
490
|
used_this_month: used,
|
|
453
|
-
remaining_this_month: Math.max(0,
|
|
491
|
+
remaining_this_month: Math.max(0, mine.cap - used),
|
|
454
492
|
per_round_limit: autopickup.MAX_AUTO_APPLIES_PER_ROUND,
|
|
455
493
|
source: resolved.source,
|
|
456
494
|
invalid: resolved.invalid,
|
|
@@ -461,32 +499,54 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
461
499
|
router.get('/project-settings/tweak-autopickup', api.requireBuilder, async (req, res) => {
|
|
462
500
|
try {
|
|
463
501
|
res.set('Cache-Control', 'no-store');
|
|
464
|
-
res.json(await autopickupBody());
|
|
502
|
+
res.json(await autopickupBody(req.builder && req.builder.rank));
|
|
465
503
|
} catch (err) {
|
|
466
504
|
log.error(`GET /project-settings/tweak-autopickup: ${err && err.message}`);
|
|
467
505
|
res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
|
|
468
506
|
}
|
|
469
507
|
});
|
|
470
508
|
|
|
471
|
-
// PATCH /project-settings/tweak-autopickup — body { mode?: 'off' | 'on', cap?: n
|
|
509
|
+
// PATCH /project-settings/tweak-autopickup — body { mode?: 'off' | 'on', cap?: n,
|
|
510
|
+
// rank_caps?: { <rank>: n | null } }.
|
|
472
511
|
//
|
|
473
512
|
// rank: project.settings.manage (metic+, government_010), the same atom as the
|
|
474
513
|
// other writes on this page. Each field is validated by the resolver's own parser,
|
|
475
|
-
// and
|
|
514
|
+
// and all are checked before any is written, so a half-valid body writes nothing.
|
|
515
|
+
//
|
|
516
|
+
// rank_caps (task 1005003) is a PARTIAL update: a rank named with a number gets
|
|
517
|
+
// that cap, a rank named with null loses its own cap (and follows the project cap
|
|
518
|
+
// again), and a rank not named is left as it was. A rank must be on the live
|
|
519
|
+
// ladder; each number goes through parseAutopickupCap, the project cap's parser.
|
|
476
520
|
router.patch('/project-settings/tweak-autopickup', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
|
|
477
|
-
if (api.validateOrRespond(req, res, { mode: { type: 'string' }, cap: { type: 'number' } })) return;
|
|
521
|
+
if (api.validateOrRespond(req, res, { mode: { type: 'string' }, cap: { type: 'number' }, rank_caps: { type: 'object' } })) return;
|
|
478
522
|
const body = req.body || {};
|
|
479
|
-
if (body.mode == null && body.cap == null) {
|
|
480
|
-
return res.fail('bad_tweak_autopickup', { status: 400, message: 'send mode, cap, or
|
|
523
|
+
if (body.mode == null && body.cap == null && body.rank_caps == null) {
|
|
524
|
+
return res.fail('bad_tweak_autopickup', { status: 400, message: 'send mode, cap, rank_caps, or any of them' });
|
|
481
525
|
}
|
|
482
526
|
const mode = body.mode == null ? null : api.projectSettings.parseAutopickup(body.mode);
|
|
483
527
|
if (mode && !mode.mode) return res.fail('bad_tweak_autopickup', { status: 400, message: `mode: ${mode.reason}` });
|
|
484
528
|
const cap = body.cap == null ? null : api.projectSettings.parseAutopickupCap(body.cap);
|
|
485
529
|
if (cap && cap.cap == null) return res.fail('bad_tweak_autopickup', { status: 400, message: `cap: ${cap.reason}` });
|
|
530
|
+
const rankEdits = [];
|
|
531
|
+
for (const [rank, value] of Object.entries(body.rank_caps || {})) {
|
|
532
|
+
if (!api.LIVE_RANK_LADDER.includes(rank)) {
|
|
533
|
+
return res.fail('bad_tweak_autopickup', { status: 400, message: `rank_caps: unknown rank '${rank}' (one of ${api.LIVE_RANK_LADDER.join(' | ')})` });
|
|
534
|
+
}
|
|
535
|
+
if (value === null) { rankEdits.push([rank, null]); continue; }
|
|
536
|
+
const got = api.projectSettings.parseAutopickupCap(value);
|
|
537
|
+
if (got.cap == null) return res.fail('bad_tweak_autopickup', { status: 400, message: `rank_caps.${rank}: ${got.reason}` });
|
|
538
|
+
rankEdits.push([rank, got.cap]);
|
|
539
|
+
}
|
|
486
540
|
try {
|
|
487
541
|
if (mode) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP, mode.mode, req.builder.id);
|
|
488
542
|
if (cap) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP_CAP, cap.cap, req.builder.id);
|
|
489
|
-
|
|
543
|
+
// One row per rank, each a single upsert: no read-modify-write, so two people
|
|
544
|
+
// editing two ranks at once cannot drop each other's edit. '' clears a rank
|
|
545
|
+
// (the resolver reads an empty value as "no cap of its own").
|
|
546
|
+
for (const [rank, n] of rankEdits) {
|
|
547
|
+
await api.projectSettings.set(`${api.projectSettings.KEYS.TWEAK_AUTOPICKUP_RANK_CAP_PREFIX}${rank}`, n === null ? '' : n, req.builder.id);
|
|
548
|
+
}
|
|
549
|
+
res.json({ ok: true, ...(await autopickupBody(req.builder && req.builder.rank)) });
|
|
490
550
|
} catch (err) {
|
|
491
551
|
log.error(`PATCH /project-settings/tweak-autopickup: ${err && err.message}`);
|
|
492
552
|
res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
|
|
@@ -498,3 +558,4 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
498
558
|
|
|
499
559
|
module.exports.settingsDeskRows = settingsDeskRows;
|
|
500
560
|
module.exports.shipModeRow = shipModeRow;
|
|
561
|
+
module.exports.tweakCapRow = tweakCapRow;
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
3
|
+
"version": "1.24.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.7",
|
|
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.24.
|
|
3
|
+
"version": "1.24.7",
|
|
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
|
@@ -10871,5 +10871,11 @@
|
|
|
10871
10871
|
"id": "1004995",
|
|
10872
10872
|
"text": "On the account page, people can now link GitHub and Google to one account, both ways, so either one opens the same account with the same credits, ranks and history. They can remove one too, but never their last way in. Two exi"
|
|
10873
10873
|
}
|
|
10874
|
+
],
|
|
10875
|
+
"1.24.7": [
|
|
10876
|
+
{
|
|
10877
|
+
"id": "1005003",
|
|
10878
|
+
"text": "The automatic tweak limit is now 200 rounds a month, and a project owner can change it, for the whole project and separately for each builder rank, on the project settings page."
|
|
10879
|
+
}
|
|
10874
10880
|
]
|
|
10875
10881
|
}
|
|
@@ -52,6 +52,9 @@ const SETTING_KEYS = {
|
|
|
52
52
|
SHIP_MODE: 'ship.mode',
|
|
53
53
|
TWEAK_AUTOPICKUP: 'tweak.autopickup',
|
|
54
54
|
TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
|
|
55
|
+
// Per-rank caps (task 1005003): one row per live rank, this prefix + the rank
|
|
56
|
+
// (rankCapKey). An empty value means the rank follows the project cap.
|
|
57
|
+
TWEAK_AUTOPICKUP_RANK_CAP_PREFIX: 'tweak.autopickup_rank_cap.',
|
|
55
58
|
SPEND_CAP: 'treasury.spend_cap_usd',
|
|
56
59
|
// Testing new module versions first (task 1003802, ADR 0344 D1): module-testing.js.
|
|
57
60
|
MODULE_TESTER: 'modules.tester',
|
|
@@ -292,9 +295,10 @@ function describeShipMode(resolved) {
|
|
|
292
295
|
// (modules/lifecycle/autopickup.js); this file only resolves the numbers.
|
|
293
296
|
const AUTOPICKUP_MODES = Object.freeze(['off', 'on']);
|
|
294
297
|
const DEFAULT_AUTOPICKUP = 'off';
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
+
// 200 rounds: the owner's decision on task 1004971 (2026-10-29, Q7), raising ADR
|
|
299
|
+
// 0371 D5's first figure of 20. A project can set its own number, and a cap per
|
|
300
|
+
// builder rank (task 1005003); this is only the answer for a project that has not.
|
|
301
|
+
const DEFAULT_AUTOPICKUP_CAP = 200;
|
|
298
302
|
// Past 1000 the cap is an off switch typed as a big number; that should be asked
|
|
299
303
|
// for out loud, the same reasoning as MAX_ROT_DAYS.
|
|
300
304
|
const MAX_AUTOPICKUP_CAP = 1000;
|
|
@@ -318,6 +322,16 @@ function parseAutopickupCap(raw) {
|
|
|
318
322
|
|
|
319
323
|
// One knob through its parser: { value, source, invalid }. Never throws; an
|
|
320
324
|
// unreadable database answers the default, the resolveShipMode posture.
|
|
325
|
+
// `row` is the already-read row (or undefined); parseRow never touches the DB.
|
|
326
|
+
function parseRow(key, row, parse, pick, fallback) {
|
|
327
|
+
if (row && row.value != null && row.value !== '') {
|
|
328
|
+
const got = parse(row.value);
|
|
329
|
+
if (pick(got) != null) return { value: pick(got), source: 'project_settings', invalid: null };
|
|
330
|
+
return { value: fallback, source: 'default', invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
|
|
331
|
+
}
|
|
332
|
+
return { value: fallback, source: 'default', invalid: null };
|
|
333
|
+
}
|
|
334
|
+
|
|
321
335
|
async function resolveOne(key, parse, pick, fallback) {
|
|
322
336
|
let row = null;
|
|
323
337
|
try {
|
|
@@ -326,31 +340,60 @@ async function resolveOne(key, parse, pick, fallback) {
|
|
|
326
340
|
logger.error(`project_settings read failed; falling back to the default ${key}: ${err && err.message}`);
|
|
327
341
|
return { value: fallback, source: 'default', invalid: null };
|
|
328
342
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
343
|
+
return parseRow(key, row, parse, pick, fallback);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// rankCapKey(rank) — the row holding one rank's own cap (task 1005003). ONE ROW
|
|
347
|
+
// PER RANK, deliberately: setting or clearing a rank is a single upsert, so two
|
|
348
|
+
// people editing two different ranks at once can never overwrite each other (a
|
|
349
|
+
// shared JSON object would be read-modify-write and lose one edit). An empty
|
|
350
|
+
// value means "no cap of its own" (the null-is-meaningful rule above).
|
|
351
|
+
function rankCapKey(rank) {
|
|
352
|
+
return `${SETTING_KEYS.TWEAK_AUTOPICKUP_RANK_CAP_PREFIX}${rank}`;
|
|
335
353
|
}
|
|
336
354
|
|
|
337
|
-
// resolveTweakAutopickup() — { mode, cap, source: { mode, cap },
|
|
338
|
-
// mode is always 'off' or 'on', cap always a usable positive
|
|
355
|
+
// resolveTweakAutopickup() — { mode, cap, rank_caps, source: { mode, cap },
|
|
356
|
+
// invalid: [...] }. mode is always 'off' or 'on', cap always a usable positive
|
|
357
|
+
// integer, rank_caps always an object of live-ladder rank → cap (empty when no rank
|
|
358
|
+
// has its own). Every row is read in ONE query; each rank cap goes through
|
|
359
|
+
// parseAutopickupCap, the project cap's own parser, so the two cannot disagree.
|
|
339
360
|
async function resolveTweakAutopickup() {
|
|
340
|
-
|
|
341
|
-
const
|
|
361
|
+
// Lazy: the ladder is the kernel's one copy (tests/rank_tier_single_source.mjs).
|
|
362
|
+
const ladder = require('./db-kernel').LIVE_RANK_LADDER;
|
|
363
|
+
const keys = [SETTING_KEYS.TWEAK_AUTOPICKUP, SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, ...ladder.map(rankCapKey)];
|
|
364
|
+
let byKey = new Map();
|
|
365
|
+
try {
|
|
366
|
+
const { rows } = await pool.query('SELECT key, value FROM project_settings WHERE key = ANY($1::text[])', [keys]);
|
|
367
|
+
byKey = new Map(rows.map((row) => [row.key, row]));
|
|
368
|
+
} catch (err) {
|
|
369
|
+
logger.error(`project_settings read failed; falling back to the default tweak auto-pickup settings: ${err && err.message}`);
|
|
370
|
+
}
|
|
371
|
+
const m = parseRow(SETTING_KEYS.TWEAK_AUTOPICKUP, byKey.get(SETTING_KEYS.TWEAK_AUTOPICKUP), parseAutopickup, (g) => g.mode, DEFAULT_AUTOPICKUP);
|
|
372
|
+
const c = parseRow(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, byKey.get(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP), parseAutopickupCap, (g) => g.cap, DEFAULT_AUTOPICKUP_CAP);
|
|
373
|
+
const rankCaps = {};
|
|
374
|
+
const invalid = [m.invalid, c.invalid];
|
|
375
|
+
for (const rank of ladder) {
|
|
376
|
+
const r = parseRow(rankCapKey(rank), byKey.get(rankCapKey(rank)), parseAutopickupCap, (g) => g.cap, null);
|
|
377
|
+
if (r.value != null) rankCaps[rank] = r.value;
|
|
378
|
+
invalid.push(r.invalid);
|
|
379
|
+
}
|
|
342
380
|
return {
|
|
343
381
|
mode: m.value,
|
|
344
382
|
cap: c.value,
|
|
383
|
+
rank_caps: rankCaps,
|
|
345
384
|
source: { mode: m.source, cap: c.source },
|
|
346
|
-
invalid:
|
|
385
|
+
invalid: invalid.filter(Boolean),
|
|
347
386
|
};
|
|
348
387
|
}
|
|
349
388
|
|
|
350
389
|
function describeTweakAutopickup(resolved) {
|
|
351
390
|
const r = resolved || {};
|
|
352
391
|
if (r.mode !== 'on') return 'Off: a submitted page waits until a person runs /tweak for it.';
|
|
353
|
-
|
|
392
|
+
const ranks = Object.entries(r.rank_caps || {});
|
|
393
|
+
const byRank = ranks.length
|
|
394
|
+
? ` Some builder ranks have their own limit: ${ranks.map(([rank, n]) => `${rank} ${n}`).join(', ')}.`
|
|
395
|
+
: '';
|
|
396
|
+
return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month.${byRank} The artist still approves every one.`;
|
|
354
397
|
}
|
|
355
398
|
|
|
356
399
|
// THE SPEND CAP (task 1004712, ADR 0379). The owner's ruling: there is NO spend
|
|
@@ -416,6 +459,7 @@ module.exports = {
|
|
|
416
459
|
MAX_AUTOPICKUP_CAP,
|
|
417
460
|
parseAutopickup,
|
|
418
461
|
parseAutopickupCap,
|
|
462
|
+
rankCapKey,
|
|
419
463
|
resolveTweakAutopickup,
|
|
420
464
|
describeTweakAutopickup,
|
|
421
465
|
MAX_SPEND_CAP_USD,
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.24.
|
|
84
|
+
const CORE_VERSION = '1.24.7'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|