@bongos/core 1.19.252 → 1.19.254
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 +30 -20
- package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +11 -1
- package/docs/copy-inventory.md +37 -37
- package/docs/copy-registry.json +38 -38
- package/migrations/core_221_claims_requested_worktree_name.sql +144 -0
- package/modules/government/board.js +6 -1
- package/modules/hall-ui/public/government.js +34 -4
- package/modules/lifecycle/db.js +38 -10
- package/modules/lifecycle/routes/claims.js +20 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +8 -1
- package/src/module-api.js +5 -1
- package/tests/claims_requested_worktree_db.mjs +196 -0
- package/tests/government_charter_library.mjs +107 -1
- package/tests/one_active_claim_per_session.mjs +125 -0
|
@@ -90,13 +90,31 @@ function shouldRefuseSessionClaim({ activeClaimsForSession, creatorSessionId, wo
|
|
|
90
90
|
// Two claims share a WORKING TREE iff their folded worktree_names match — which
|
|
91
91
|
// includes both being worktree-less (a plain clone, whose single branch
|
|
92
92
|
// genuinely commingles).
|
|
93
|
-
|
|
93
|
+
//
|
|
94
|
+
// task 1002709: compare against the REQUESTED name, not the stored one. V3.R51
|
|
95
|
+
// silently renames a colliding worktree_name ("overnight" → "overnight-2"), and
|
|
96
|
+
// the CLI keeps sending the real directory name, which is never renamed — so
|
|
97
|
+
// keying on the stored name compared "overnight" against "overnight-2", read
|
|
98
|
+
// them as different trees, and let a SECOND claim into the SAME tree. One tree,
|
|
99
|
+
// one branch, two active claims: exactly the commingled ship this guard exists
|
|
100
|
+
// to stop. See core_221 for the three-step repro.
|
|
101
|
+
//
|
|
102
|
+
// requested_worktree_name is NULL on rows written before core_221's backfill
|
|
103
|
+
// ran, so fall back to worktree_name — which for any row disambiguation never
|
|
104
|
+
// touched IS the requested name, and is the honest best answer for the rest. A
|
|
105
|
+
// deploy applies code and migration at different instants; this fallback is
|
|
106
|
+
// what keeps the guard correct in that window rather than reading every old
|
|
107
|
+
// row as worktree-less.
|
|
108
|
+
const treeOf = (c) => db.foldWorktreeName(
|
|
109
|
+
c.requested_worktree_name ?? c.worktree_name ?? null
|
|
110
|
+
);
|
|
111
|
+
const sameWorkingTree = (claim) => treeOf(claim) === incomingTree;
|
|
94
112
|
// Defence in depth: only count claims that belong to THIS session, are genuinely
|
|
95
113
|
// session-bound (a NULL-session row must never trip the block), AND would land in
|
|
96
114
|
// the SAME working tree as the incoming claim.
|
|
97
115
|
const inFlight = claims.find(
|
|
98
116
|
(c) => c && c.creator_session_id != null && c.creator_session_id === creatorSessionId
|
|
99
|
-
&& sameWorkingTree(c
|
|
117
|
+
&& sameWorkingTree(c)
|
|
100
118
|
);
|
|
101
119
|
if (inFlight) {
|
|
102
120
|
return {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.254",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.254",
|
|
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.19.
|
|
3
|
+
"version": "1.19.254",
|
|
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",
|
|
@@ -73,7 +73,14 @@ const INTEGRATION = new Set([
|
|
|
73
73
|
// platform-server — network + package build + a spawned server, run by its own
|
|
74
74
|
// .github/workflows/consumer-layout-boot.yml, not this DB-free gate (task 2142).
|
|
75
75
|
'consumer_layout_boot',
|
|
76
|
-
'claim_gate_open_blocker', 'claim_gate_rebase',
|
|
76
|
+
'claim_gate_open_blocker', 'claim_gate_rebase',
|
|
77
|
+
// task 1002709: core_221's re-keyed backstop index against a real Postgres.
|
|
78
|
+
// The DB-free sibling pins the guard and the migration's text; this proves the
|
|
79
|
+
// index actually refuses the commingling INSERT and still allows the parallel
|
|
80
|
+
// -worktree case. It self-skips without a DB, and refuses any database that
|
|
81
|
+
// has a `builders` table, since it TRUNCATEs claims.
|
|
82
|
+
'claims_requested_worktree_db',
|
|
83
|
+
'clawback_confirmed_backlog',
|
|
77
84
|
// task 1003113: the copy_desk_flags schema half. The module's own tests are
|
|
78
85
|
// DB-free and cover the pure layer; the rules that MATTER here live in the
|
|
79
86
|
// schema — a CHECK-constraint reason floor whose whole argument is that no
|
package/src/module-api.js
CHANGED
|
@@ -931,8 +931,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
931
931
|
// landed since 1.19.250 with no explicit bump. run 32204008770. (task 1002620)
|
|
932
932
|
// 1.19.252 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
933
933
|
// landed since 1.19.251 with no explicit bump. run 32205191350. (task 1002620)
|
|
934
|
+
// 1.19.253 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
935
|
+
// landed since 1.19.252 with no explicit bump. run 32205833181. (task 1002620)
|
|
936
|
+
// 1.19.254 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
937
|
+
// landed since 1.19.253 with no explicit bump. run 32207504942. (task 1002620)
|
|
934
938
|
// ---------------------------------------------------------------------------
|
|
935
|
-
const CORE_VERSION = '1.19.
|
|
939
|
+
const CORE_VERSION = '1.19.254'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
936
940
|
|
|
937
941
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
938
942
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// tests/claims_requested_worktree_db.mjs — core_221's re-keyed backstop index
|
|
2
|
+
// against a REAL Postgres (task 1002709).
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS BESIDE tests/one_active_claim_per_session.mjs. That file is
|
|
5
|
+
// DB-free and covers the app GUARD plus text tripwires on the migration's shape.
|
|
6
|
+
// It cannot cover the half that is a claim about the database: whether the
|
|
7
|
+
// re-keyed unique index actually refuses the commingling INSERT, whether it
|
|
8
|
+
// still ALLOWS the legitimate parallel-worktree case, and whether the migration
|
|
9
|
+
// survives being applied over the very row shape the bug produced.
|
|
10
|
+
//
|
|
11
|
+
// The guard and the index are defence in depth for the same rule, and this
|
|
12
|
+
// family of bugs (1002570, 1002589, 1002522, and now 1002709) has been almost
|
|
13
|
+
// entirely about the two disagreeing. So both halves get their own proof.
|
|
14
|
+
//
|
|
15
|
+
// It SELF-SKIPS (exit 0) with no reachable Postgres, and is listed in
|
|
16
|
+
// run-unit-tests.js's INTEGRATION set so the DB-free lane never runs it.
|
|
17
|
+
//
|
|
18
|
+
// Run: DATABASE_URL=postgres://... node tests/claims_requested_worktree_db.mjs
|
|
19
|
+
//
|
|
20
|
+
// It builds its own throwaway `claims` table when one is absent, and truncates
|
|
21
|
+
// only that table — so point it at a scratch database, never a real one.
|
|
22
|
+
|
|
23
|
+
import pg from 'pg';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import url from 'node:url';
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
|
|
29
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await pool.query('SELECT 1');
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.log(`claims_requested_worktree_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
|
|
35
|
+
await pool.end().catch(() => {});
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// This test TRUNCATEs `claims`. Refuse to run against anything that looks like a
|
|
40
|
+
// real database — a scratch container is the only sane target, and an accidental
|
|
41
|
+
// DATABASE_URL pointing at prod would otherwise wipe the claims ledger.
|
|
42
|
+
const { rows: realRows } = await pool.query(
|
|
43
|
+
`SELECT to_regclass('public.builders') IS NOT NULL AS has_builders`);
|
|
44
|
+
if (realRows[0].has_builders) {
|
|
45
|
+
console.log('claims_requested_worktree_db.mjs SKIPPED — this database has a `builders` table, so it is not a scratch DB. It TRUNCATEs `claims`; point it at a throwaway Postgres.');
|
|
46
|
+
await pool.end().catch(() => {});
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
let passed = 0, failed = 0;
|
|
50
|
+
async function t(name, fn) {
|
|
51
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
52
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await pool.query(`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
58
|
+
id bigserial PRIMARY KEY,
|
|
59
|
+
task_id bigint, builder_id bigint,
|
|
60
|
+
worktree_name text,
|
|
61
|
+
creator_session_id text,
|
|
62
|
+
claimed_at timestamptz DEFAULT now(),
|
|
63
|
+
released_at timestamptz,
|
|
64
|
+
outcome text);
|
|
65
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_worktree
|
|
66
|
+
ON claims (creator_session_id, (COALESCE(worktree_name, '')))
|
|
67
|
+
WHERE released_at IS NULL AND creator_session_id IS NOT NULL;
|
|
68
|
+
`);
|
|
69
|
+
|
|
70
|
+
// Seed the PRE-migration commingled state: one session, one real tree, two
|
|
71
|
+
// active claims that were only ever distinguished by the suffix. This is the row
|
|
72
|
+
// shape the bug produced, and the migration has to heal it before it can build
|
|
73
|
+
// the unique index.
|
|
74
|
+
await pool.query(`TRUNCATE claims`);
|
|
75
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, worktree_name, creator_session_id, claimed_at) VALUES
|
|
76
|
+
(1, 1, 'overnight', 'sess-A', now() - interval '2 hour'),
|
|
77
|
+
(2, 1, 'overnight-2', 'sess-A', now() - interval '1 hour')`);
|
|
78
|
+
|
|
79
|
+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
|
80
|
+
const MIG = fs.readFileSync(
|
|
81
|
+
path.join(HERE, '..', 'migrations', 'core_221_claims_requested_worktree_name.sql'), 'utf8');
|
|
82
|
+
|
|
83
|
+
console.log('core_221 against real Postgres');
|
|
84
|
+
|
|
85
|
+
await t('the migration applies over the commingled state (heals, then indexes)', async () => {
|
|
86
|
+
await pool.query(MIG);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await t('it deliberately UNDER-heals a pre-existing commingled pair, and does not guess', async () => {
|
|
90
|
+
// The honest limitation, asserted rather than hoped for. After the backfill,
|
|
91
|
+
// a commingled pair (stored "overnight" + "overnight-2", ONE real tree) and a
|
|
92
|
+
// legitimate pair (stored "foo" + "foo-2", TWO real trees) are
|
|
93
|
+
// INDISTINGUISHABLE — the requested name was never recorded, which is the
|
|
94
|
+
// whole bug. Suffix-stripping could close the first but would also silently
|
|
95
|
+
// kill the second, so the migration closes neither.
|
|
96
|
+
const { rows } = await pool.query(
|
|
97
|
+
`SELECT task_id, released_at FROM claims ORDER BY claimed_at`);
|
|
98
|
+
assert.equal(rows.length, 2);
|
|
99
|
+
assert.equal(rows[0].released_at, null);
|
|
100
|
+
assert.equal(rows[1].released_at, null,
|
|
101
|
+
'both stay active: a migration must not close a builder\'s live claim on a guess');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await t('but it DOES heal a genuine collision on the requested name', async () => {
|
|
105
|
+
// Rows written by the new code carry a real requested name, and two of those
|
|
106
|
+
// CAN collide — that is the case the heal exists for, and the case that would
|
|
107
|
+
// otherwise block CREATE UNIQUE INDEX on deploy.
|
|
108
|
+
await pool.query(`TRUNCATE claims`);
|
|
109
|
+
// Drop the index FIRST — the point is to recreate the pre-migration state in
|
|
110
|
+
// which these two rows can coexist, then prove the migration heals them.
|
|
111
|
+
await pool.query('DROP INDEX IF EXISTS uniq_active_claim_per_session_requested_worktree');
|
|
112
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, creator_session_id, claimed_at) VALUES
|
|
113
|
+
(60, 1, 'wt', 'wt', 'sess-H', now() - interval '2 hour'),
|
|
114
|
+
(61, 1, 'wt-2', 'wt', 'sess-H', now() - interval '1 hour')`);
|
|
115
|
+
await pool.query(MIG);
|
|
116
|
+
const { rows } = await pool.query(
|
|
117
|
+
`SELECT task_id, released_at, outcome FROM claims ORDER BY claimed_at`);
|
|
118
|
+
assert.equal(rows[0].released_at, null, 'the earliest survives');
|
|
119
|
+
assert.ok(rows[1].released_at, 'the later is closed');
|
|
120
|
+
assert.equal(rows[1].outcome, 'abandoned');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await t('the backfill copies worktree_name across, and never overwrites a real value', async () => {
|
|
124
|
+
await pool.query(`TRUNCATE claims`);
|
|
125
|
+
await pool.query(`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, creator_session_id) VALUES
|
|
126
|
+
(70, 1, 'legacy', NULL, 'sess-I'),
|
|
127
|
+
(71, 1, 'newer', 'asked-for','sess-J')`);
|
|
128
|
+
await pool.query(MIG);
|
|
129
|
+
const { rows } = await pool.query(
|
|
130
|
+
`SELECT task_id, worktree_name, requested_worktree_name FROM claims ORDER BY task_id`);
|
|
131
|
+
assert.equal(rows[0].requested_worktree_name, 'legacy', 'a NULL historical row is filled from the stored name');
|
|
132
|
+
assert.equal(rows[1].requested_worktree_name, 'asked-for',
|
|
133
|
+
'a row that already carries a real requested name is left alone — the guard on the UPDATE');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await t('re-running the migration is idempotent', async () => {
|
|
137
|
+
await pool.query(MIG);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await t('the old index is gone and the new one exists', async () => {
|
|
141
|
+
const { rows } = await pool.query(
|
|
142
|
+
`SELECT indexname FROM pg_indexes WHERE tablename = 'claims'`);
|
|
143
|
+
const names = rows.map((r) => r.indexname);
|
|
144
|
+
assert.ok(!names.includes('uniq_active_claim_per_session_worktree'),
|
|
145
|
+
'leaving it would re-refuse the legitimate foo / foo-2 case');
|
|
146
|
+
assert.ok(names.includes('uniq_active_claim_per_session_requested_worktree'));
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await pool.query(`TRUNCATE claims`);
|
|
150
|
+
const ins = (o) => pool.query(
|
|
151
|
+
`INSERT INTO claims (task_id, builder_id, worktree_name, requested_worktree_name, creator_session_id)
|
|
152
|
+
VALUES ($1,1,$2,$3,$4) RETURNING id`,
|
|
153
|
+
[o.task, o.stored, o.requested, o.session]);
|
|
154
|
+
|
|
155
|
+
console.log('\nthe index now catches what the guard catches');
|
|
156
|
+
|
|
157
|
+
await t('THE HOLE IS CLOSED: same session, same REQUESTED tree, second claim → 23505', async () => {
|
|
158
|
+
await ins({ task: 10, stored: 'overnight-2', requested: 'overnight', session: 'sess-B' });
|
|
159
|
+
await assert.rejects(
|
|
160
|
+
() => ins({ task: 11, stored: 'overnight-3', requested: 'overnight', session: 'sess-B' }),
|
|
161
|
+
(e) => e.code === '23505' && e.constraint === 'uniq_active_claim_per_session_requested_worktree');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await t('two genuinely different trees in one session still BOTH insert', async () => {
|
|
165
|
+
await pool.query(`TRUNCATE claims`);
|
|
166
|
+
await ins({ task: 20, stored: 'foo', requested: 'foo', session: 'sess-C' });
|
|
167
|
+
const r = await ins({ task: 21, stored: 'foo-2', requested: 'foo-2', session: 'sess-C' });
|
|
168
|
+
assert.ok(r.rows[0].id, 'the parallel-worktree workflow must keep working');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await t('two worktree-less claims in one session still self-collide', async () => {
|
|
172
|
+
await pool.query(`TRUNCATE claims`);
|
|
173
|
+
await ins({ task: 30, stored: null, requested: null, session: 'sess-D' });
|
|
174
|
+
await assert.rejects(
|
|
175
|
+
() => ins({ task: 31, stored: null, requested: null, session: 'sess-D' }),
|
|
176
|
+
(e) => e.code === '23505', 'one shared clone, one branch — NULLs must fold via COALESCE');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await t('a released claim frees the name for reuse (the overnight builder)', async () => {
|
|
180
|
+
await pool.query(`TRUNCATE claims`);
|
|
181
|
+
await ins({ task: 40, stored: 'overnight', requested: 'overnight', session: 'sess-E' });
|
|
182
|
+
await pool.query(`UPDATE claims SET released_at = now() WHERE task_id = 40`);
|
|
183
|
+
const r = await ins({ task: 41, stored: 'overnight', requested: 'overnight', session: 'sess-E' });
|
|
184
|
+
assert.ok(r.rows[0].id, 'sequential reuse is load-bearing for the overnight builder');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await t('unbound (NULL-session) claims are still exempt', async () => {
|
|
188
|
+
await pool.query(`TRUNCATE claims`);
|
|
189
|
+
await ins({ task: 50, stored: 'shared', requested: 'shared', session: null });
|
|
190
|
+
const r = await ins({ task: 51, stored: 'shared', requested: 'shared', session: null });
|
|
191
|
+
assert.ok(r.rows[0].id, 'web/unbound claims may coexist — the partial predicate excludes them');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
195
|
+
await pool.end();
|
|
196
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -67,7 +67,12 @@ test('the library presents all seven structures with honest selectable marks (cl
|
|
|
67
67
|
assert.equal(selectableTrue, 4, 'monarchy, timocratic republic, direct democracy, foundation — the config-expressible four');
|
|
68
68
|
const trueBlocks = lib.split('selectable: true').slice(1);
|
|
69
69
|
for (const b of trueBlocks) {
|
|
70
|
-
|
|
70
|
+
// Window widened from 200 (task 1003095): a selectable entry now also carries
|
|
71
|
+
// `predicate` + `seatsRanks` ahead of `how`, which the test below checks
|
|
72
|
+
// against the real parser. Cut to the next entry rather than a magic length,
|
|
73
|
+
// so adding another field cannot silently push `how` out of view again.
|
|
74
|
+
const entry = b.split('{ key:')[0];
|
|
75
|
+
assert.ok(entry.includes('how:'), 'a selectable structure names its configuration');
|
|
71
76
|
}
|
|
72
77
|
assert.match(client, /recommended at your headcount/, 'the one live datum is rendered');
|
|
73
78
|
assert.match(client, /c\.key === recommendedKey/, "the pill is driven by the SERVER's recommendation, never re-derived client-side");
|
|
@@ -75,3 +80,104 @@ test('the library presents all seven structures with honest selectable marks (cl
|
|
|
75
80
|
assert.ok(client.includes(`name: '${t}'`), `trajectory ${t} present`);
|
|
76
81
|
}
|
|
77
82
|
});
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
// ── R26's ask, made mechanical (task 1003095) ───────────────────────────────
|
|
86
|
+
//
|
|
87
|
+
// The library is the ONLY place in the product that tells an owner WHAT TO TYPE.
|
|
88
|
+
// That makes a wrong `how` line worse than no library: an owner who followed
|
|
89
|
+
// `membership rank:xenos (everyone)` under the old exact-match grammar handed
|
|
90
|
+
// the board to newcomers and locked themselves out. R26's done-when asks for
|
|
91
|
+
// "selectable-today is honestly marked" — prose cannot be honest by assertion,
|
|
92
|
+
// so this checks every selectable charter's predicate against the REAL parser.
|
|
93
|
+
//
|
|
94
|
+
// Deliberately NOT a copy of the intent: it imports
|
|
95
|
+
// modules/government/membership-predicate.js, the same module the resolver and
|
|
96
|
+
// the config sanitizer use. If the grammar moves underneath this page again,
|
|
97
|
+
// this test reds instead of the page quietly lying.
|
|
98
|
+
|
|
99
|
+
function charterEntries() {
|
|
100
|
+
const client = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/government.js'), 'utf8');
|
|
101
|
+
const lib = client.slice(client.indexOf('const CHARTERS'), client.indexOf('const TRAJECTORIES'));
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const block of lib.split('{ key:').slice(1)) {
|
|
104
|
+
const key = /^\s*'([a-z-]+)'/.exec(block);
|
|
105
|
+
if (!key) continue;
|
|
106
|
+
const selectable = /selectable: (true|false)/.exec(block);
|
|
107
|
+
const predicate = /predicate: (?:'([^']*)'|null)/.exec(block);
|
|
108
|
+
const seats = /seatsRanks: (?:\[([^\]]*)\]|null)/.exec(block);
|
|
109
|
+
const how = /how: '([^']*)'/.exec(block) || /how: "([^"]*)"/.exec(block);
|
|
110
|
+
out.push({
|
|
111
|
+
key: key[1],
|
|
112
|
+
selectable: selectable ? selectable[1] === 'true' : null,
|
|
113
|
+
predicate: predicate ? (predicate[1] ?? null) : undefined,
|
|
114
|
+
seatsRanks: seats ? (seats[1] ? seats[1].split(',').map((x) => x.trim().replace(/'/g, '')) : null) : undefined,
|
|
115
|
+
how: how ? how[1] : null,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test('every SELECTABLE charter names a predicate the real resolver would actually seat as described', () => {
|
|
122
|
+
const { parseMembershipPredicate } = require(path.join(GOV, 'membership-predicate.js'));
|
|
123
|
+
const entries = charterEntries();
|
|
124
|
+
assert.equal(entries.length, 7, 'sanity: parsed all seven structures');
|
|
125
|
+
|
|
126
|
+
for (const c of entries.filter((e) => e.selectable)) {
|
|
127
|
+
assert.notEqual(c.predicate, undefined, `${c.key}: a selectable charter must declare a predicate field (null = owner-chosen)`);
|
|
128
|
+
assert.notEqual(c.seatsRanks, undefined, `${c.key}: a selectable charter must declare seatsRanks`);
|
|
129
|
+
|
|
130
|
+
if (c.predicate === null) {
|
|
131
|
+
// Owner-chosen (Foundation's custom trustee rank). Honest rather than
|
|
132
|
+
// unchecked: it must SAY the predicate is theirs to name.
|
|
133
|
+
assert.equal(c.seatsRanks, null, `${c.key}: an owner-chosen predicate cannot claim a fixed seat list`);
|
|
134
|
+
assert.match(c.how, /custom/i, `${c.key}: must say the rank is one the owner creates`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const parsed = parseMembershipPredicate(c.predicate);
|
|
139
|
+
assert.ok(parsed, `${c.key}: "${c.predicate}" does not parse — this page would tell an owner to type something that seats NOBODY`);
|
|
140
|
+
assert.deepEqual([...parsed.rankKeys], c.seatsRanks,
|
|
141
|
+
`${c.key}: "${c.predicate}" seats ${JSON.stringify([...parsed.rankKeys])}, but the library claims ${JSON.stringify(c.seatsRanks)}`);
|
|
142
|
+
assert.ok(c.how.includes(c.predicate),
|
|
143
|
+
`${c.key}: the how-line must contain the exact predicate an owner types (expected "${c.predicate}" in "${c.how}")`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('THE REGRESSION: the two lines that were untrue now seat what they say', () => {
|
|
148
|
+
const { parseMembershipPredicate } = require(path.join(GOV, 'membership-predicate.js'));
|
|
149
|
+
const by = Object.fromEntries(charterEntries().map((c) => [c.key, c]));
|
|
150
|
+
|
|
151
|
+
// Direct Democracy said "rank:xenos (everyone)" and seated NEWCOMERS ONLY.
|
|
152
|
+
const dd = by['direct-democracy'];
|
|
153
|
+
assert.equal(dd.predicate, 'rank:xenos+');
|
|
154
|
+
assert.deepEqual([...parseMembershipPredicate(dd.predicate).rankKeys], ['xenos', 'thetes', 'metic', 'archon'],
|
|
155
|
+
'every builder, which is what "everyone" has to mean');
|
|
156
|
+
assert.ok(!/rank:xenos[^+]/.test(dd.how), 'the how-line must not show the bare form that caused the bug');
|
|
157
|
+
|
|
158
|
+
// Timocratic Republic was true as far as it went and silent about Archons.
|
|
159
|
+
const tr = by['timocratic-republic'];
|
|
160
|
+
assert.equal(tr.predicate, 'rank:metic+');
|
|
161
|
+
assert.deepEqual([...parseMembershipPredicate(tr.predicate).rankKeys], ['metic', 'archon']);
|
|
162
|
+
assert.match(tr.how, /Archons included/i, 'the omission that made it quietly surprising is now stated');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the recommendation names a charter the config can actually express', () => {
|
|
166
|
+
// The library recommended "a consent circle of all trusted members" at
|
|
167
|
+
// headcount 3–8 — a phrase with no predicate behind it under exact-match. The
|
|
168
|
+
// recommendation must point at a charter this page can tell an owner to type.
|
|
169
|
+
const by = Object.fromEntries(charterEntries().map((c) => [c.key, c]));
|
|
170
|
+
for (const n of [1, 3, 8, 9, 50, 150]) {
|
|
171
|
+
const rec = recommendedCharter(n);
|
|
172
|
+
const charter = by[rec.key];
|
|
173
|
+
assert.ok(charter, `headcount ${n} recommends "${rec.key}", which is not in the library`);
|
|
174
|
+
if (charter.selectable) {
|
|
175
|
+
assert.ok(charter.predicate !== undefined,
|
|
176
|
+
`headcount ${n} recommends selectable "${rec.key}" — it must carry a checkable predicate`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const circle = recommendedCharter(3);
|
|
180
|
+
assert.equal(circle.key, 'direct-democracy');
|
|
181
|
+
assert.match(circle.why, /rank:xenos\+/,
|
|
182
|
+
'the why must name the predicate that delivers it, not a phrase with nothing behind it');
|
|
183
|
+
});
|
|
@@ -337,5 +337,130 @@ t('core_204 keeps the 180 predicate (active + bound rows only) and heals first',
|
|
|
337
337
|
'the pre-CREATE heal must group by the NEW key');
|
|
338
338
|
});
|
|
339
339
|
|
|
340
|
+
// =========================================================================
|
|
341
|
+
// task 1002709 — the disambiguation hole. V3.R51 silently RENAMES a colliding
|
|
342
|
+
// worktree_name ("overnight" -> "overnight-2") while the CLI keeps sending the
|
|
343
|
+
// real directory name, which is never renamed. Keying the guard on the STORED
|
|
344
|
+
// name therefore compared two different strings for ONE tree and let a second
|
|
345
|
+
// claim through: one tree, one branch, two active claims — the commingled ship
|
|
346
|
+
// this guard exists to stop. The fix stores the REQUESTED name too and keys on
|
|
347
|
+
// that (core_221).
|
|
348
|
+
// =========================================================================
|
|
349
|
+
|
|
350
|
+
t('THE REPRO: a second claim from the same session into a RENAMED tree is refused', () => {
|
|
351
|
+
// Step 1-2 already happened: this session's first claim asked for "overnight",
|
|
352
|
+
// collided with another builder's active "overnight", and was STORED as
|
|
353
|
+
// "overnight-2". Step 3 is this call — same session, same real directory.
|
|
354
|
+
const held = {
|
|
355
|
+
claim_id: 90, task_id: 800, creator_session_id: 'sess-9',
|
|
356
|
+
worktree_name: 'overnight-2', // what disambiguation stored
|
|
357
|
+
requested_worktree_name: 'overnight', // what the caller actually asked for
|
|
358
|
+
title: 'First',
|
|
359
|
+
};
|
|
360
|
+
const out = shouldRefuseSessionClaim({
|
|
361
|
+
activeClaimsForSession: [held],
|
|
362
|
+
creatorSessionId: 'sess-9',
|
|
363
|
+
worktreeName: 'overnight', // the CLI still sends the real dir
|
|
364
|
+
});
|
|
365
|
+
assert.equal(out.refuse, true,
|
|
366
|
+
'before task 1002709 this ALLOWED — "overnight" vs the stored "overnight-2" read as two trees');
|
|
367
|
+
assert.equal(out.reason, 'session_already_has_active_claim');
|
|
368
|
+
assert.equal(out.inFlightTaskId, 800);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
t('a builder genuinely running "foo" and "foo-2" side by side is still allowed', () => {
|
|
372
|
+
// The false-refusal this whole family of bugs is about. Matching
|
|
373
|
+
// /^<requested>-\d+$/ in the guard would have broken exactly this, which is
|
|
374
|
+
// why the requested name is STORED rather than pattern-matched.
|
|
375
|
+
const held = {
|
|
376
|
+
claim_id: 91, task_id: 801, creator_session_id: 'sess-9',
|
|
377
|
+
worktree_name: 'foo', requested_worktree_name: 'foo', title: 'In foo',
|
|
378
|
+
};
|
|
379
|
+
const out = shouldRefuseSessionClaim({
|
|
380
|
+
activeClaimsForSession: [held],
|
|
381
|
+
creatorSessionId: 'sess-9',
|
|
382
|
+
worktreeName: 'foo-2',
|
|
383
|
+
});
|
|
384
|
+
assert.equal(out.refuse, false, 'two real, separate trees — two branches, no commingling');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
t('rows written before the backfill fall back to worktree_name', () => {
|
|
388
|
+
// A deploy applies code and migration at different instants. In that window an
|
|
389
|
+
// active row has requested_worktree_name NULL, and for any row disambiguation
|
|
390
|
+
// never touched the stored name IS the requested one — so the fallback keeps
|
|
391
|
+
// the guard correct instead of reading every old row as worktree-less.
|
|
392
|
+
const legacy = {
|
|
393
|
+
claim_id: 92, task_id: 802, creator_session_id: 'sess-9',
|
|
394
|
+
worktree_name: 'legacy-tree', title: 'Legacy',
|
|
395
|
+
// requested_worktree_name deliberately absent
|
|
396
|
+
};
|
|
397
|
+
assert.equal(shouldRefuseSessionClaim({
|
|
398
|
+
activeClaimsForSession: [legacy], creatorSessionId: 'sess-9', worktreeName: 'legacy-tree',
|
|
399
|
+
}).refuse, true, 'same tree by the fallback → refuse');
|
|
400
|
+
assert.equal(shouldRefuseSessionClaim({
|
|
401
|
+
activeClaimsForSession: [legacy], creatorSessionId: 'sess-9', worktreeName: 'other-tree',
|
|
402
|
+
}).refuse, false, 'different tree → still allowed');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
t('an explicit NULL requested name does NOT silently become the stored name', () => {
|
|
406
|
+
// `??` and not `||`: a row whose requested name is genuinely null (a
|
|
407
|
+
// worktree-less claim, backfilled) must fold to the shared-clone group, and a
|
|
408
|
+
// row whose stored name is null must not resurrect anything either.
|
|
409
|
+
const worktreeless = {
|
|
410
|
+
claim_id: 93, task_id: 803, creator_session_id: 'sess-9',
|
|
411
|
+
worktree_name: null, requested_worktree_name: null, title: 'Shared clone',
|
|
412
|
+
};
|
|
413
|
+
assert.equal(shouldRefuseSessionClaim({
|
|
414
|
+
activeClaimsForSession: [worktreeless], creatorSessionId: 'sess-9', worktreeName: null,
|
|
415
|
+
}).refuse, true, 'one shared clone, one branch — genuinely commingles');
|
|
416
|
+
assert.equal(shouldRefuseSessionClaim({
|
|
417
|
+
activeClaimsForSession: [worktreeless], creatorSessionId: 'sess-9', worktreeName: 'wt-real',
|
|
418
|
+
}).refuse, false);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const mig221 = () => readFileSync(
|
|
422
|
+
join(dirname(fileURLToPath(import.meta.url)),
|
|
423
|
+
'../migrations/core_221_claims_requested_worktree_name.sql'),
|
|
424
|
+
'utf8');
|
|
425
|
+
|
|
426
|
+
t('core_221 adds the column additively and backfills without overwriting', () => {
|
|
427
|
+
assert.match(mig221(), /ADD COLUMN IF NOT EXISTS requested_worktree_name text/i);
|
|
428
|
+
assert.match(mig221(), /WHERE requested_worktree_name IS NULL/i,
|
|
429
|
+
'the backfill must be guarded so a re-run cannot overwrite a real value');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
t('core_221 re-keys the index onto the REQUESTED name, and drops the old one', () => {
|
|
433
|
+
assert.match(mig221(), /DROP INDEX IF EXISTS uniq_active_claim_per_session_worktree\b/,
|
|
434
|
+
'leaving core_204 index alongside would re-refuse the legitimate foo / foo-2 case');
|
|
435
|
+
assert.match(mig221(), /CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_requested_worktree/);
|
|
436
|
+
assert.match(mig221(), /COALESCE\(requested_worktree_name,\s*''\)/i,
|
|
437
|
+
'NULLs are DISTINCT in a plain unique index — the fold is what makes a shared clone self-collide');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
t('core_221 keeps the partial predicate and heals on the NEW key first', () => {
|
|
441
|
+
assert.match(mig221(), /WHERE released_at IS NULL AND creator_session_id IS NOT NULL/,
|
|
442
|
+
'web/unbound claims stay excluded, as in 180 and core_204');
|
|
443
|
+
assert.match(mig221(), /PARTITION BY c\.creator_session_id,\s*COALESCE\(c\.requested_worktree_name,\s*''\)/i,
|
|
444
|
+
'a unique index refuses to create over violating rows — re-keying can newly collide exactly where this bug lived');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
t('all three index generations stay recognised by claimUniqueViolationError', () => {
|
|
448
|
+
// An unrecognised constraint falls closed onto the generic ALREADY_CLAIMED,
|
|
449
|
+
// which for this case is the phantom-rival answer task 1002589 removed.
|
|
450
|
+
for (const name of [
|
|
451
|
+
'uniq_active_claim_per_session',
|
|
452
|
+
'uniq_active_claim_per_session_worktree',
|
|
453
|
+
'uniq_active_claim_per_session_requested_worktree',
|
|
454
|
+
]) {
|
|
455
|
+
assert.equal(
|
|
456
|
+
claimUniqueViolationError({ constraint: name }).code,
|
|
457
|
+
'session_already_has_bound_claim',
|
|
458
|
+
`${name} must render as the honest session answer`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
assert.equal(claimUniqueViolationError({ constraint: 'uniq_active_claim_per_task' }).code,
|
|
462
|
+
'ALREADY_CLAIMED', 'a genuine per-task race still reads as a rival claim');
|
|
463
|
+
});
|
|
464
|
+
|
|
340
465
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
341
466
|
process.exit(failed ? 1 : 0);
|