@bongos/core 1.19.1029 → 1.19.1031
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 +19 -19
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/activity-snapshots.js +44 -28
- package/modules/platform-identity/platform-identity.js +20 -25
- package/modules/platform-identity/tests/activity-snapshots.mjs +69 -30
- package/modules/platform-identity/tests/platform-identity.mjs +44 -24
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/autobongos-run.js +51 -3
- package/scripts/gds/autobongos-service.ps1 +7 -2
- package/src/module-api.js +1 -1
- package/tests/activity_snapshots_db.mjs +106 -37
- package/tests/autobongos_loop.mjs +91 -0
- package/tests/hub_recent_ships.mjs +7 -4
- package/tests/sso_checkin_identity_spoof.mjs +1 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.1031",
|
|
6
|
+
"core_contract": "1.19.1031",
|
|
7
|
+
"source_commit": "04f5126869533ad9e3a29fa072dd288ba6e63f3b",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-25T21:
|
|
9
|
+
"built_at": "2026-09-25T21:32:54.328Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 529,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 3014,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "05efd2e30efe440d0892c97889cdea81127c2544ef93366c2b054750750fceb3",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2757,7 +2757,7 @@
|
|
|
2757
2757
|
{
|
|
2758
2758
|
"path": "docs/module-api-changelog.md",
|
|
2759
2759
|
"mode": "0000644",
|
|
2760
|
-
"sha256": "
|
|
2760
|
+
"sha256": "6f37eb2ce46310219bfc4f0c4820e67f6afda57cfe76cb2a0e8cf75d3bd6577d"
|
|
2761
2761
|
},
|
|
2762
2762
|
{
|
|
2763
2763
|
"path": "docs/modules-contract.md",
|
|
@@ -6452,7 +6452,7 @@
|
|
|
6452
6452
|
{
|
|
6453
6453
|
"path": "modules/platform-identity/activity-snapshots.js",
|
|
6454
6454
|
"mode": "0000644",
|
|
6455
|
-
"sha256": "
|
|
6455
|
+
"sha256": "c483a9ef792ad0ac3600dc1eb227323c6f851e7cd33189cc418a4e0ef3443ed6"
|
|
6456
6456
|
},
|
|
6457
6457
|
{
|
|
6458
6458
|
"path": "modules/platform-identity/applicant-profile.js",
|
|
@@ -6652,7 +6652,7 @@
|
|
|
6652
6652
|
{
|
|
6653
6653
|
"path": "modules/platform-identity/platform-identity.js",
|
|
6654
6654
|
"mode": "0000644",
|
|
6655
|
-
"sha256": "
|
|
6655
|
+
"sha256": "0f60239ec8ae79e1cb87870d7454f6b03de84170d557a06fae93cc556757004a"
|
|
6656
6656
|
},
|
|
6657
6657
|
{
|
|
6658
6658
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -6767,12 +6767,12 @@
|
|
|
6767
6767
|
{
|
|
6768
6768
|
"path": "modules/platform-identity/tests/activity-snapshots.mjs",
|
|
6769
6769
|
"mode": "0000644",
|
|
6770
|
-
"sha256": "
|
|
6770
|
+
"sha256": "47e4970496e303b66007c3c8cee9ecf1b1a10fe8805d28c8ef44d545ed7f1b4f"
|
|
6771
6771
|
},
|
|
6772
6772
|
{
|
|
6773
6773
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
6774
6774
|
"mode": "0000644",
|
|
6775
|
-
"sha256": "
|
|
6775
|
+
"sha256": "6bbddd8d1b85f159ba1bade8df61552979e85e50dace2e1c9128de4b9ee8beb9"
|
|
6776
6776
|
},
|
|
6777
6777
|
{
|
|
6778
6778
|
"path": "modules/platform-identity/ui/.gitkeep",
|
|
@@ -8362,12 +8362,12 @@
|
|
|
8362
8362
|
{
|
|
8363
8363
|
"path": "package-lock.json",
|
|
8364
8364
|
"mode": "0000644",
|
|
8365
|
-
"sha256": "
|
|
8365
|
+
"sha256": "eb24a6596bec145cc1e850a0c17f5f9a5a05bf4b5762785c063ac9711788417e"
|
|
8366
8366
|
},
|
|
8367
8367
|
{
|
|
8368
8368
|
"path": "package.json",
|
|
8369
8369
|
"mode": "0000644",
|
|
8370
|
-
"sha256": "
|
|
8370
|
+
"sha256": "84bad55379e5b9c61129a8d4cae7b074f22ba99984543dd1ac990688ac73efcb"
|
|
8371
8371
|
},
|
|
8372
8372
|
{
|
|
8373
8373
|
"path": "public-docs/index.html",
|
|
@@ -8542,7 +8542,7 @@
|
|
|
8542
8542
|
{
|
|
8543
8543
|
"path": "scripts/gds/autobongos-run.js",
|
|
8544
8544
|
"mode": "0000644",
|
|
8545
|
-
"sha256": "
|
|
8545
|
+
"sha256": "9fe8be8bc38039f700b6aab8b52508c162252513cc6b5b87aacb7dc48648937a"
|
|
8546
8546
|
},
|
|
8547
8547
|
{
|
|
8548
8548
|
"path": "scripts/gds/autobongos-service.cmd",
|
|
@@ -8552,7 +8552,7 @@
|
|
|
8552
8552
|
{
|
|
8553
8553
|
"path": "scripts/gds/autobongos-service.ps1",
|
|
8554
8554
|
"mode": "0000644",
|
|
8555
|
-
"sha256": "
|
|
8555
|
+
"sha256": "ed95f836655535827c14a32caf197fee7c238e15d8e8bf7a9e987b78ab266887"
|
|
8556
8556
|
},
|
|
8557
8557
|
{
|
|
8558
8558
|
"path": "scripts/gds/autobongos-verify.js",
|
|
@@ -10412,7 +10412,7 @@
|
|
|
10412
10412
|
{
|
|
10413
10413
|
"path": "src/module-api.js",
|
|
10414
10414
|
"mode": "0000644",
|
|
10415
|
-
"sha256": "
|
|
10415
|
+
"sha256": "320ce97feaea53ab38c025dbba20001ef89643b0519151e010c7456e0dd727eb"
|
|
10416
10416
|
},
|
|
10417
10417
|
{
|
|
10418
10418
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10517,7 +10517,7 @@
|
|
|
10517
10517
|
{
|
|
10518
10518
|
"path": "tests/activity_snapshots_db.mjs",
|
|
10519
10519
|
"mode": "0000644",
|
|
10520
|
-
"sha256": "
|
|
10520
|
+
"sha256": "219569f3160eb22a89e92a217b854a194afba6ebc7032dc07eff2534efc4390a"
|
|
10521
10521
|
},
|
|
10522
10522
|
{
|
|
10523
10523
|
"path": "tests/admission_ceiling_gate.mjs",
|
|
@@ -10847,7 +10847,7 @@
|
|
|
10847
10847
|
{
|
|
10848
10848
|
"path": "tests/autobongos_loop.mjs",
|
|
10849
10849
|
"mode": "0000644",
|
|
10850
|
-
"sha256": "
|
|
10850
|
+
"sha256": "8ba5a6b492ae5afabb13f9e44d2f82cb873a094a659425554f0d105bd979aff4"
|
|
10851
10851
|
},
|
|
10852
10852
|
{
|
|
10853
10853
|
"path": "tests/autobongos_verify.mjs",
|
|
@@ -12562,7 +12562,7 @@
|
|
|
12562
12562
|
{
|
|
12563
12563
|
"path": "tests/hub_recent_ships.mjs",
|
|
12564
12564
|
"mode": "0000644",
|
|
12565
|
-
"sha256": "
|
|
12565
|
+
"sha256": "ad183edeb3b24bdd70de80aad370c5411d32257907baa36c65b70a8143d8ec53"
|
|
12566
12566
|
},
|
|
12567
12567
|
{
|
|
12568
12568
|
"path": "tests/idea_brightness.mjs",
|
|
@@ -14597,7 +14597,7 @@
|
|
|
14597
14597
|
{
|
|
14598
14598
|
"path": "tests/sso_checkin_identity_spoof.mjs",
|
|
14599
14599
|
"mode": "0000644",
|
|
14600
|
-
"sha256": "
|
|
14600
|
+
"sha256": "628db25b4f0c5dc157e4aa9bc9d136efd474e88769641061dfbeca6d3e013cc2"
|
|
14601
14601
|
},
|
|
14602
14602
|
{
|
|
14603
14603
|
"path": "tests/sso_checkin_legacy_field_gone.mjs",
|
|
@@ -2537,5 +2537,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
2537
2537
|
landed since 1.19.1027 with no explicit bump. run 36188894725. (task 1002620)
|
|
2538
2538
|
1.19.1029 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2539
2539
|
landed since 1.19.1028 with no explicit bump. run 36189693139. (task 1002620)
|
|
2540
|
+
1.19.1030 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2541
|
+
landed since 1.19.1029 with no explicit bump. run 36190779629. (task 1002620)
|
|
2542
|
+
1.19.1031 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2543
|
+
landed since 1.19.1030 with no explicit bump. run 36192093201. (task 1002620)
|
|
2540
2544
|
---------------------------------------------------------------------------
|
|
2541
2545
|
```
|
|
@@ -11,37 +11,53 @@
|
|
|
11
11
|
// Compaction (daily granularity after 90 days) is a later, volume-triggered task
|
|
12
12
|
// — nothing here prunes. A disconnected/re-registered project's snapshots are
|
|
13
13
|
// likewise not purged here — platform_identity_builder_project_activity (the
|
|
14
|
-
// table this mirrors) has the identical gap today; the
|
|
15
|
-
// is where consent (rollupVisibleSql) is enforced
|
|
14
|
+
// table this mirrors) has the identical gap today; the leaderboard reader
|
|
15
|
+
// (community-leaderboard.js) is where consent (rollupVisibleSql) is enforced.
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
|
-
const
|
|
18
|
+
const api = require('../../src/module-api');
|
|
19
19
|
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
20
|
+
// Snapshot the LIVE totals of one (project, builder) iff they differ from the
|
|
21
|
+
// last snapshot. Must run on the transaction that just upserted the live row:
|
|
22
|
+
// that upsert's row lock queues racing reports, so this sees every earlier
|
|
23
|
+
// snapshot and a live row no one else can change (task 1004244).
|
|
24
|
+
// Best-effort: a fault rolls back to the savepoint, is logged, and returns null,
|
|
25
|
+
// so the upsert still commits. If even that rollback fails the transaction is
|
|
26
|
+
// gone, and the error propagates rather than let COMMIT silently drop the upsert.
|
|
27
|
+
// reported_at is the write's own moment (clock_timestamp(); now() would be the
|
|
28
|
+
// transaction start, before any wait on the lock), never earlier than the last
|
|
29
|
+
// row's, so the index order (reported_at, id) always ends at the last write.
|
|
30
|
+
async function recordActivitySnapshot({ clientId, githubId }, { client }) {
|
|
31
|
+
await client.query('SAVEPOINT activity_snapshot');
|
|
32
|
+
try {
|
|
33
|
+
const { rows } = await client.query(
|
|
34
|
+
`WITH live AS (
|
|
35
|
+
SELECT credits, tasks_shipped, karma FROM platform_identity_builder_project_activity
|
|
36
|
+
WHERE client_id = $1 AND github_id = $2
|
|
37
|
+
), last AS (
|
|
38
|
+
SELECT credits, tasks_shipped, karma, reported_at FROM platform_identity_activity_snapshots
|
|
39
|
+
WHERE client_id = $1 AND github_id = $2
|
|
40
|
+
ORDER BY reported_at DESC, id DESC LIMIT 1
|
|
41
|
+
)
|
|
42
|
+
INSERT INTO platform_identity_activity_snapshots (client_id, github_id, credits, tasks_shipped, karma, reported_at)
|
|
43
|
+
SELECT $1, $2, live.credits, live.tasks_shipped, live.karma,
|
|
44
|
+
GREATEST(clock_timestamp(), (SELECT reported_at FROM last))
|
|
45
|
+
FROM live
|
|
46
|
+
WHERE NOT EXISTS (
|
|
47
|
+
SELECT 1 FROM last
|
|
48
|
+
WHERE last.credits = live.credits
|
|
49
|
+
AND last.tasks_shipped = live.tasks_shipped
|
|
50
|
+
AND last.karma = live.karma)
|
|
51
|
+
RETURNING id, reported_at`,
|
|
52
|
+
[clientId, githubId],
|
|
53
|
+
);
|
|
54
|
+
await client.query('RELEASE SAVEPOINT activity_snapshot');
|
|
55
|
+
return rows[0] || null;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
await client.query('ROLLBACK TO SAVEPOINT activity_snapshot');
|
|
58
|
+
api.logger('platform-identity').error({ err }, 'activity snapshot write failed (non-fatal)');
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
module.exports = { recordActivitySnapshot };
|
|
@@ -666,31 +666,26 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
|
|
|
666
666
|
.filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
|
|
667
667
|
.slice(0, MAX_ROLLUP_DISCIPLINES);
|
|
668
668
|
const ships = sanitizeRecentShips(recentShips, clientOrigin);
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
await recordActivitySnapshot({ clientId, githubId, credits: credits | 0, tasksShipped: tasksShipped | 0, karma: karma | 0 }, { pool });
|
|
690
|
-
} catch (err) {
|
|
691
|
-
api.logger('platform-identity').error(`activity snapshot write failed (non-fatal): ${err && err.message}`);
|
|
692
|
-
}
|
|
693
|
-
return rows[0];
|
|
669
|
+
// One transaction with the snapshot: the upsert's row lock queues racing reports (task 1004244).
|
|
670
|
+
return api.withTx(async (client) => {
|
|
671
|
+
const { rows } = await client.query(
|
|
672
|
+
`INSERT INTO platform_identity_builder_project_activity
|
|
673
|
+
(client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at)
|
|
674
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now())
|
|
675
|
+
ON CONFLICT (client_id, github_id) DO UPDATE SET
|
|
676
|
+
credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
|
|
677
|
+
rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
|
|
678
|
+
recent_ships = EXCLUDED.recent_ships,
|
|
679
|
+
last_active = now(), updated_at = now()
|
|
680
|
+
RETURNING id, client_id, github_id`,
|
|
681
|
+
// jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
|
|
682
|
+
// Postgres ARRAY literal, which the jsonb column would reject.
|
|
683
|
+
[clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships)],
|
|
684
|
+
);
|
|
685
|
+
// Windowed data plane (task 1002977) — best-effort, see activity-snapshots.js.
|
|
686
|
+
await recordActivitySnapshot({ clientId, githubId }, { client });
|
|
687
|
+
return rows[0];
|
|
688
|
+
}, { pool });
|
|
694
689
|
}
|
|
695
690
|
|
|
696
691
|
// The scouting rollup: one row PER BUILDER, aggregated across every project they
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// modules/platform-identity/tests/activity-snapshots.mjs
|
|
2
2
|
//
|
|
3
3
|
// DB-free unit suite for the windowed data plane's append-only snapshot history
|
|
4
|
-
// (community spec D2, task 1002977
|
|
5
|
-
// tests/platform-identity.mjs: proves the
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// (community spec D2, task 1002977; the live-row read, task 1004244).
|
|
5
|
+
// Recording-fake-client style, matching tests/platform-identity.mjs: proves the
|
|
6
|
+
// SHAPE — a SAVEPOINT on the caller's transaction, ONE INSERT that reads the
|
|
7
|
+
// totals from the live row (never from the caller) guarded by the last snapshot,
|
|
8
|
+
// RELEASE; on a fault, ROLLBACK TO SAVEPOINT, log the Error, return null. A fake
|
|
9
|
+
// cannot execute `WHERE NOT EXISTS` or a lock, so WHICH reports insert and how
|
|
10
|
+
// racing reports settle are proved against real Postgres in
|
|
11
|
+
// tests/activity_snapshots_db.mjs.
|
|
9
12
|
//
|
|
10
13
|
// Run standalone: node modules/platform-identity/tests/activity-snapshots.mjs
|
|
11
14
|
|
|
@@ -13,6 +16,14 @@ import { strict as assert } from 'node:assert';
|
|
|
13
16
|
import { createRequire } from 'node:module';
|
|
14
17
|
|
|
15
18
|
const require = createRequire(import.meta.url);
|
|
19
|
+
|
|
20
|
+
// Pin the doorway's logger so the test can read what a fault logs.
|
|
21
|
+
const logged = [];
|
|
22
|
+
const moduleApiPath = require.resolve('../../../src/module-api.js');
|
|
23
|
+
require.cache[moduleApiPath] = {
|
|
24
|
+
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
25
|
+
exports: { logger: () => ({ error: (obj, msg) => logged.push({ obj, msg }) }) },
|
|
26
|
+
};
|
|
16
27
|
const { recordActivitySnapshot } = require('../activity-snapshots.js');
|
|
17
28
|
|
|
18
29
|
let passed = 0, failed = 0;
|
|
@@ -21,47 +32,75 @@ async function ta(name, fn) {
|
|
|
21
32
|
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
// A recording fake
|
|
25
|
-
|
|
35
|
+
// A recording fake client: answers the INSERT with `insertRows`, or fails it;
|
|
36
|
+
// `failRollback` also fails the savepoint rollback (a dead connection).
|
|
37
|
+
function mockClient({ insertRows = [], failInsert = null, failRollback = null } = {}) {
|
|
26
38
|
const calls = [];
|
|
27
|
-
let i = 0;
|
|
28
39
|
return {
|
|
29
40
|
calls,
|
|
30
41
|
query: async (text, params) => {
|
|
31
42
|
calls.push({ text: String(text), params: params || [] });
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
if (/INSERT INTO platform_identity_activity_snapshots/.test(text)) {
|
|
44
|
+
if (failInsert) throw failInsert;
|
|
45
|
+
return { rows: insertRows };
|
|
46
|
+
}
|
|
47
|
+
if (/^ROLLBACK TO SAVEPOINT/.test(text) && failRollback) throw failRollback;
|
|
48
|
+
return { rows: [] };
|
|
34
49
|
},
|
|
35
50
|
};
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
|
|
53
|
+
const texts = (client) => client.calls.map((c) => c.text.replace(/\s+/g, ' ').trim());
|
|
54
|
+
|
|
55
|
+
console.log('recordActivitySnapshot (community spec D2, tasks 1002977 + 1004244):');
|
|
39
56
|
|
|
40
|
-
await ta('
|
|
41
|
-
const
|
|
42
|
-
const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
assert.
|
|
46
|
-
assert.match(
|
|
47
|
-
assert.
|
|
48
|
-
assert.match(c.text, /INSERT INTO platform_identity_activity_snapshots/);
|
|
49
|
-
assert.match(c.text, /WHERE NOT EXISTS \(SELECT 1 FROM last WHERE credits = \$3 AND tasks_shipped = \$4 AND karma = \$5\)/);
|
|
50
|
-
assert.deepEqual(c.params, ['c1', 42, 100, 5, 3]);
|
|
57
|
+
await ta('on the caller\'s transaction: SAVEPOINT, one conditional INSERT, RELEASE', async () => {
|
|
58
|
+
const client = mockClient({ insertRows: [{ id: '1', reported_at: 'T1' }] });
|
|
59
|
+
const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { client });
|
|
60
|
+
const t = texts(client);
|
|
61
|
+
assert.equal(t.length, 3);
|
|
62
|
+
assert.equal(t[0], 'SAVEPOINT activity_snapshot');
|
|
63
|
+
assert.match(t[1], /^WITH live AS/);
|
|
64
|
+
assert.equal(t[2], 'RELEASE SAVEPOINT activity_snapshot');
|
|
51
65
|
assert.deepEqual(out, { id: '1', reported_at: 'T1' });
|
|
52
66
|
});
|
|
53
67
|
|
|
54
|
-
await ta('
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
assert.
|
|
68
|
+
await ta('the INSERT reads the totals from the LIVE row, never from the caller', async () => {
|
|
69
|
+
const client = mockClient();
|
|
70
|
+
await recordActivitySnapshot({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { client });
|
|
71
|
+
assert.deepEqual(client.calls[1].params, ['c1', 42], 'only the pair is bound — a captured total has nowhere to go');
|
|
72
|
+
const sql = texts(client)[1];
|
|
73
|
+
assert.match(sql, /WITH live AS \( SELECT credits, tasks_shipped, karma FROM platform_identity_builder_project_activity WHERE client_id = \$1 AND github_id = \$2 \)/);
|
|
74
|
+
assert.match(sql, /SELECT \$1, \$2, live\.credits, live\.tasks_shipped, live\.karma, GREATEST\(clock_timestamp\(\), \(SELECT reported_at FROM last\)\) FROM live/,
|
|
75
|
+
'stamped at the write, and never earlier than the last row');
|
|
76
|
+
assert.ok(!/\$3/.test(sql), 'no third parameter exists to carry a caller value');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await ta('skipped when the live totals equal the last snapshot, found in migration 022\'s index order', async () => {
|
|
80
|
+
const client = mockClient();
|
|
81
|
+
const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { client });
|
|
82
|
+
const sql = texts(client)[1];
|
|
83
|
+
assert.match(sql, /last AS \( SELECT credits, tasks_shipped, karma, reported_at FROM platform_identity_activity_snapshots WHERE client_id = \$1 AND github_id = \$2 ORDER BY reported_at DESC, id DESC LIMIT 1 \)/,
|
|
84
|
+
'(client_id, github_id, reported_at DESC, id DESC) serves this as an index-order LIMIT 1');
|
|
85
|
+
assert.match(sql, /WHERE NOT EXISTS \( SELECT 1 FROM last WHERE last\.credits = live\.credits AND last\.tasks_shipped = live\.tasks_shipped AND last\.karma = live\.karma\)/);
|
|
86
|
+
assert.equal(out, null, 'no row returned → null');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await ta('a fault rolls back to the savepoint, logs the Error itself, and returns null — the caller\'s upsert still commits', async () => {
|
|
90
|
+
logged.length = 0;
|
|
91
|
+
const fault = Object.assign(new Error('relation "platform_identity_activity_snapshots" does not exist'), { code: '42P01' });
|
|
92
|
+
const client = mockClient({ failInsert: fault });
|
|
93
|
+
const out = await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { client });
|
|
58
94
|
assert.equal(out, null);
|
|
95
|
+
const t = texts(client);
|
|
96
|
+
assert.equal(t[t.length - 1], 'ROLLBACK TO SAVEPOINT activity_snapshot');
|
|
97
|
+
assert.equal(logged.length, 1);
|
|
98
|
+
assert.equal(logged[0].obj.err, fault, 'the Error object itself, so its stack and SQLSTATE reach the log');
|
|
59
99
|
});
|
|
60
100
|
|
|
61
|
-
await ta('
|
|
62
|
-
const
|
|
63
|
-
await recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, {
|
|
64
|
-
assert.deepEqual(pool.calls[0].params, ['c1', 42, 0, 0, 0]);
|
|
101
|
+
await ta('if the savepoint rollback itself fails, the error propagates — COMMIT must not silently drop the upsert', async () => {
|
|
102
|
+
const client = mockClient({ failInsert: new Error('boom'), failRollback: new Error('connection terminated') });
|
|
103
|
+
await assert.rejects(() => recordActivitySnapshot({ clientId: 'c1', githubId: 42 }, { client }), /connection terminated/);
|
|
65
104
|
});
|
|
66
105
|
|
|
67
106
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
@@ -558,11 +558,26 @@ await ta('getAccountByLogin resolves case-insensitively, newest first', async ()
|
|
|
558
558
|
|
|
559
559
|
console.log('\ncross-project activity (§4 scouting data plane):');
|
|
560
560
|
|
|
561
|
+
// upsertActivity runs in a transaction, so a positional answer queue would be
|
|
562
|
+
// spent by BEGIN: answer by statement shape instead, and find the upsert by text.
|
|
563
|
+
const activityPool = ({ failSnapshot = false } = {}) => {
|
|
564
|
+
const calls = [];
|
|
565
|
+
const query = async (text, params) => {
|
|
566
|
+
calls.push({ text: String(text), params: params || [] });
|
|
567
|
+
if (failSnapshot && /INSERT INTO platform_identity_activity_snapshots/.test(text)) {
|
|
568
|
+
throw new Error('relation "platform_identity_activity_snapshots" does not exist');
|
|
569
|
+
}
|
|
570
|
+
if (/INSERT INTO platform_identity_builder_project_activity/.test(text)) return { rows: [{ id: '1' }] };
|
|
571
|
+
return { rows: [] };
|
|
572
|
+
};
|
|
573
|
+
return { calls, query, connect: async () => ({ query, release: () => {} }) };
|
|
574
|
+
};
|
|
575
|
+
const upsertCall = (pool) => pool.calls.find((c) => /INSERT INTO platform_identity_builder_project_activity/.test(c.text));
|
|
576
|
+
|
|
561
577
|
await ta('upsertActivity upserts per (client_id, github_id) with the talent snapshot', async () => {
|
|
562
|
-
const pool =
|
|
578
|
+
const pool = activityPool();
|
|
563
579
|
await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3, rank: 'metic', disciplines: ['engineer'] }, { pool });
|
|
564
|
-
const c = pool
|
|
565
|
-
assert.match(c.text, /INSERT INTO platform_identity_builder_project_activity/);
|
|
580
|
+
const c = upsertCall(pool);
|
|
566
581
|
assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
|
|
567
582
|
// recent_ships (task 1002803) rides the same row as a jsonb STRING param —
|
|
568
583
|
// an empty list when the sender reported none, never NULL. Its own cap,
|
|
@@ -571,7 +586,7 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
|
|
|
571
586
|
});
|
|
572
587
|
|
|
573
588
|
await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {
|
|
574
|
-
const pool =
|
|
589
|
+
const pool = activityPool();
|
|
575
590
|
await P.upsertActivity({
|
|
576
591
|
clientId: 'c1', githubId: 42,
|
|
577
592
|
rank: 'Supreme Overlord', // not one of the four core rank keys → stored null
|
|
@@ -581,7 +596,7 @@ await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-la
|
|
|
581
596
|
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', // valid tail — capped to 8 total
|
|
582
597
|
],
|
|
583
598
|
}, { pool });
|
|
584
|
-
const c = pool
|
|
599
|
+
const c = upsertCall(pool);
|
|
585
600
|
assert.equal(c.params[5], null, 'an invented rank label is never stored');
|
|
586
601
|
const disc = c.params[6];
|
|
587
602
|
assert.ok(disc.length <= 8, `disciplines capped at 8 (got ${disc.length})`);
|
|
@@ -591,30 +606,35 @@ await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-la
|
|
|
591
606
|
});
|
|
592
607
|
|
|
593
608
|
await ta('upsertActivity keeps a legitimate core rank key', async () => {
|
|
594
|
-
const pool =
|
|
609
|
+
const pool = activityPool();
|
|
595
610
|
await P.upsertActivity({ clientId: 'c1', githubId: 42, rank: 'archon', disciplines: [] }, { pool });
|
|
596
|
-
assert.equal(pool.
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
await ta('upsertActivity also appends to the snapshot history (task 1002977, community spec D2) — deleting that call would leave this red', async () => {
|
|
600
|
-
const pool = mockPool([{ rows: [{ id: '1' }] }, { rows: [{ id: 's1', reported_at: 'T' }] }]);
|
|
601
|
-
await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
|
|
602
|
-
assert.equal(pool.calls.length, 2, 'the rollup upsert, then the snapshot statement');
|
|
603
|
-
assert.match(pool.calls[1].text, /INSERT INTO platform_identity_activity_snapshots/);
|
|
604
|
-
assert.deepEqual(pool.calls[1].params, ['c1', 42, 100, 5, 3]);
|
|
611
|
+
assert.equal(upsertCall(pool).params[5], 'archon');
|
|
605
612
|
});
|
|
606
613
|
|
|
607
|
-
await ta('upsertActivity
|
|
608
|
-
const pool =
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
614
|
+
await ta('upsertActivity writes the upsert and its snapshot in ONE transaction, the snapshot reading the live row (tasks 1002977 + 1004244)', async () => {
|
|
615
|
+
const pool = activityPool();
|
|
616
|
+
const out = await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
|
|
617
|
+
const t = pool.calls.map((c) => c.text.replace(/\s+/g, ' ').trim().split(' ').slice(0, 4).join(' '));
|
|
618
|
+
assert.deepEqual(t, [
|
|
619
|
+
'BEGIN',
|
|
620
|
+
'INSERT INTO platform_identity_builder_project_activity (client_id,',
|
|
621
|
+
'SAVEPOINT activity_snapshot',
|
|
622
|
+
'WITH live AS (',
|
|
623
|
+
'RELEASE SAVEPOINT activity_snapshot',
|
|
624
|
+
'COMMIT',
|
|
625
|
+
], 'the upsert holds the row lock until its snapshot is written; deleting the snapshot call leaves this red');
|
|
626
|
+
const snap = pool.calls.find((c) => /INSERT INTO platform_identity_activity_snapshots/.test(c.text));
|
|
627
|
+
assert.deepEqual(snap.params, ['c1', 42], 'the report\'s own totals are never handed to the snapshot');
|
|
628
|
+
assert.equal(out.id, '1');
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
await ta('upsertActivity survives a snapshot-write failure (best-effort — the application-echo.js precedent): the savepoint rolls back alone and the upsert commits', async () => {
|
|
632
|
+
const pool = activityPool({ failSnapshot: true });
|
|
616
633
|
const out = await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3 }, { pool });
|
|
617
634
|
assert.equal(out.id, '1', 'the rollup upsert result still returns despite the snapshot write throwing');
|
|
635
|
+
const t = pool.calls.map((c) => c.text.trim());
|
|
636
|
+
assert.ok(t.includes('ROLLBACK TO SAVEPOINT activity_snapshot'));
|
|
637
|
+
assert.equal(t[t.length - 1], 'COMMIT', 'the transaction still commits the upsert');
|
|
618
638
|
});
|
|
619
639
|
|
|
620
640
|
await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1031",
|
|
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.1031",
|
|
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.1031",
|
|
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",
|
|
@@ -162,6 +162,20 @@ const WORKER_ENV_ALLOW = [
|
|
|
162
162
|
'BONGOS_API_BASE', 'CLOUDBONGOS_API_BASE',
|
|
163
163
|
'OTB_CLAUDE_BIN', 'CLAUDE_BIN',
|
|
164
164
|
'XDG_CONFIG_HOME',
|
|
165
|
+
// THE WORKER'S OWN CREDENTIAL, and it has to be here (task 1003907, found by
|
|
166
|
+
// running it). On a desktop, the claude CLI authenticates from the credential
|
|
167
|
+
// store under USERPROFILE/HOME, which are already allowed - so this list looked
|
|
168
|
+
// complete. On a HEADLESS host there is no interactive login to have written
|
|
169
|
+
// one, and setup.js's own preflight says to set CLAUDE_CODE_OAUTH_TOKEN there
|
|
170
|
+
// instead. Stripping it meant every worker on the machine this was built for
|
|
171
|
+
// started unauthenticated, exited in ~2s, and the run recorded only "worker
|
|
172
|
+
// output was not JSON".
|
|
173
|
+
//
|
|
174
|
+
// This is not the leak the list exists to prevent. The worker IS a Claude
|
|
175
|
+
// session; its own auth is the one credential it cannot do its job without.
|
|
176
|
+
// Everything the comment above refuses - DB URLs, git and npm tokens - stays
|
|
177
|
+
// refused.
|
|
178
|
+
'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY',
|
|
165
179
|
];
|
|
166
180
|
|
|
167
181
|
function workerEnv(source = process.env, extra = {}) {
|
|
@@ -175,12 +189,46 @@ function workerEnv(source = process.env, extra = {}) {
|
|
|
175
189
|
|
|
176
190
|
const MAX_WORKER_OUTPUT = 8 * 1024 * 1024; // a misbehaving worker must not grow memory until the timeout
|
|
177
191
|
|
|
192
|
+
// resolveWorkerBin — WHICH `claude`, and whether it needs a shell.
|
|
193
|
+
//
|
|
194
|
+
// THE BUG THIS EXISTS FOR (task 1003907, found by running it on the real Windows
|
|
195
|
+
// host). On Windows `claude` on PATH is a .cmd shim, and Node has refused to
|
|
196
|
+
// spawn .cmd/.bat without a shell since CVE-2024-27980 — so this used to pass
|
|
197
|
+
// `shell: process.platform === 'win32'`. But `shell: true` does not pass argv
|
|
198
|
+
// through: it CONCATENATES it into one command line that cmd.exe re-parses,
|
|
199
|
+
// which destroys every embedded quote. `--json-schema` is a JSON blob made
|
|
200
|
+
// almost entirely of quotes, so claude received a mangled argument and exited in
|
|
201
|
+
// ~2s with `--json-schema is not valid JSON`. Every worker. Every run. Forever —
|
|
202
|
+
// and the cadence classes that as `quiet`, so it never trips the failure ladder.
|
|
203
|
+
//
|
|
204
|
+
// The npm package ships a REAL claude.exe beside the shim, so prefer it: a true
|
|
205
|
+
// executable spawns with shell:false and exact argv, and nothing re-parses.
|
|
206
|
+
// Falling back to the old behaviour is deliberate — a host with a differently
|
|
207
|
+
// laid-out install should still run, and a corrupted schema surfaces as a failed
|
|
208
|
+
// worker rather than as a refusal to start at all.
|
|
209
|
+
function resolveWorkerBin(env = process.env, platform = process.platform) {
|
|
210
|
+
const override = env.OTB_CLAUDE_BIN || env.CLAUDE_BIN;
|
|
211
|
+
if (override) return { bin: override, shell: platform === 'win32' && /\.(cmd|bat)$/i.test(override) };
|
|
212
|
+
if (platform !== 'win32') return { bin: 'claude', shell: false };
|
|
213
|
+
for (const dir of String(env.PATH || env.Path || '').split(path.delimiter)) {
|
|
214
|
+
if (!dir) continue;
|
|
215
|
+
const candidates = [
|
|
216
|
+
path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
|
|
217
|
+
path.join(dir, 'claude.exe'),
|
|
218
|
+
];
|
|
219
|
+
for (const c of candidates) {
|
|
220
|
+
try { if (fs.existsSync(c)) return { bin: c, shell: false }; } catch (_) {}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { bin: 'claude', shell: true };
|
|
224
|
+
}
|
|
225
|
+
|
|
178
226
|
// spawnWorker — one headless session, bounded by WALL CLOCK, in the task's own
|
|
179
227
|
// working tree.
|
|
180
228
|
async function spawnWorker({ task, model, timeoutMs, cwd, deps = {} }) {
|
|
181
229
|
const spawnFn = deps.spawn || spawn;
|
|
182
230
|
const sessionId = deps.sessionId || crypto.randomUUID(); // pinned, so the session can be resumed
|
|
183
|
-
const bin =
|
|
231
|
+
const { bin, shell: workerShell } = deps.resolveBin ? deps.resolveBin() : resolveWorkerBin();
|
|
184
232
|
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
185
233
|
const args = buildWorkerArgs({ sessionId, model, isRoot });
|
|
186
234
|
const prompt = buildWorkerPrompt({ task });
|
|
@@ -194,7 +242,7 @@ async function spawnWorker({ task, model, timeoutMs, cwd, deps = {} }) {
|
|
|
194
242
|
// `claude -p` with an open inherited stdin waits seconds for input that
|
|
195
243
|
// never comes, on every single spawn.
|
|
196
244
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
197
|
-
shell:
|
|
245
|
+
shell: workerShell,
|
|
198
246
|
windowsHide: true,
|
|
199
247
|
env: workerEnv(),
|
|
200
248
|
});
|
|
@@ -749,7 +797,7 @@ if (require.main === module) {
|
|
|
749
797
|
}
|
|
750
798
|
|
|
751
799
|
module.exports = {
|
|
752
|
-
readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW,
|
|
800
|
+
readArgv, pickTask, iteration, record, logPath, spawnWorker, workerEnv, WORKER_ENV_ALLOW, resolveWorkerBin,
|
|
753
801
|
worktreeName, readFence, reconcileLeftoverClaims, pollSignals, waitOrJump, forever, CLAIM_PREFIX,
|
|
754
802
|
heartbeatPath, writeHeartbeat, readHeartbeat, pidAlive, anotherRunnerIsAlive, HEARTBEAT_STALE_MS,
|
|
755
803
|
isRunnerTree, RUNNER_TREE_RE, postHeartbeat, RUNNER_STARTED_AT,
|
|
@@ -89,7 +89,12 @@ switch ($Action) {
|
|
|
89
89
|
Assert-Prereqs
|
|
90
90
|
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
|
91
91
|
|
|
92
|
-
$action
|
|
92
|
+
# NOT $action: PowerShell variable names are CASE-INSENSITIVE, so $action IS the
|
|
93
|
+
# $Action parameter above, and its [ValidateSet]/[string] attributes stay attached
|
|
94
|
+
# to the variable. Assigning a task-action object to it throws "the value
|
|
95
|
+
# MSFT_TaskExecAction is not a valid value for the Action variable" and install
|
|
96
|
+
# fails every time, on every machine. Same trap as the `run` shadow in task 1003908.
|
|
97
|
+
$taskAction = New-ScheduledTaskAction -Execute $Wrapper -WorkingDirectory $RepoRoot
|
|
93
98
|
# AtStartup covers the cold reboot the done-when actually names. AtLogOn is
|
|
94
99
|
# NOT used: it would wait for someone to sign in, which is the one thing that
|
|
95
100
|
# must not be required.
|
|
@@ -107,7 +112,7 @@ switch ($Action) {
|
|
|
107
112
|
# MultipleInstances IgnoreNew = the scheduler will not start a second copy
|
|
108
113
|
# over a live one; the heartbeat file is the second guard, inside the runner.
|
|
109
114
|
|
|
110
|
-
Register-ScheduledTask -TaskName $TaskName -Action $
|
|
115
|
+
Register-ScheduledTask -TaskName $TaskName -Action $taskAction -Trigger $trigger `
|
|
111
116
|
-Principal $principal -Settings $settings -Force | Out-Null
|
|
112
117
|
|
|
113
118
|
Write-Host "installed '$TaskName' -- starts at boot, restarts on crash, logs to $LogFile"
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1031'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
// tests/activity_snapshots_db.mjs —
|
|
1
|
+
// tests/activity_snapshots_db.mjs — tasks 1002977 + 1004244 (community spec D2; real DB).
|
|
2
2
|
//
|
|
3
3
|
// Proves the windowed data plane's append-only history against real SQL. The
|
|
4
|
-
// skip-on-unchanged rule lives
|
|
5
|
-
//
|
|
6
|
-
// SHAPE (modules/platform-identity/tests/activity-snapshots.mjs); only
|
|
7
|
-
// Postgres can prove WHICH reports
|
|
8
|
-
//
|
|
4
|
+
// skip-on-unchanged rule lives inside one INSERT ... WHERE NOT EXISTS, and racing
|
|
5
|
+
// reports are serialised by the upsert's own row lock, so a fake pool can only
|
|
6
|
+
// prove the SHAPE (modules/platform-identity/tests/activity-snapshots.mjs); only
|
|
7
|
+
// a real Postgres can prove WHICH reports insert, that upsertActivity (the
|
|
8
|
+
// POST /sso/activity/rollup path) writes the upsert and its snapshot together,
|
|
9
|
+
// and that the newest snapshot always agrees with the live row.
|
|
9
10
|
//
|
|
10
11
|
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
11
12
|
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
|
|
@@ -37,58 +38,126 @@ try {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
// High-numbered fixture ids so a shared DB can never collide with real data.
|
|
40
|
-
const BUILDER = 90002977, OTHER_BUILDER = 90002978;
|
|
41
|
-
const IDS = [BUILDER, OTHER_BUILDER];
|
|
41
|
+
const BUILDER = 90002977, OTHER_BUILDER = 90002978, RACE_BUILDER = 90002979, FLIGHT_BUILDER = 90002980;
|
|
42
|
+
const IDS = [BUILDER, OTHER_BUILDER, RACE_BUILDER, FLIGHT_BUILDER];
|
|
42
43
|
const CLIENT = 't1002977-client';
|
|
43
44
|
const q = (sql, params) => pool.query(sql, params);
|
|
45
|
+
const report = (githubId, credits) =>
|
|
46
|
+
pi.upsertActivity({ clientId: CLIENT, githubId, credits, tasksShipped: 1, karma: 0 }, { pool });
|
|
44
47
|
|
|
45
48
|
async function cleanup() {
|
|
46
49
|
await q(`DELETE FROM platform_identity_activity_snapshots WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
47
50
|
await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [IDS]).catch(() => {});
|
|
48
51
|
}
|
|
52
|
+
// The writer runs on its caller's transaction, right after the live row is set.
|
|
53
|
+
async function setLiveAndSnapshot(githubId, credits) {
|
|
54
|
+
const client = await pool.connect();
|
|
55
|
+
try {
|
|
56
|
+
await client.query('BEGIN');
|
|
57
|
+
await client.query(
|
|
58
|
+
`INSERT INTO platform_identity_builder_project_activity (client_id, github_id, credits, tasks_shipped, karma)
|
|
59
|
+
VALUES ($1, $2, $3, 5, 3)
|
|
60
|
+
ON CONFLICT (client_id, github_id) DO UPDATE SET credits = EXCLUDED.credits`,
|
|
61
|
+
[CLIENT, githubId, credits],
|
|
62
|
+
);
|
|
63
|
+
const row = await recordActivitySnapshot({ clientId: CLIENT, githubId }, { client });
|
|
64
|
+
await client.query('COMMIT');
|
|
65
|
+
return row;
|
|
66
|
+
} finally {
|
|
67
|
+
client.release();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function history(githubId) {
|
|
71
|
+
const { rows } = await q(
|
|
72
|
+
`SELECT credits FROM platform_identity_activity_snapshots
|
|
73
|
+
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at, id`,
|
|
74
|
+
[CLIENT, githubId],
|
|
75
|
+
);
|
|
76
|
+
return rows.map((r) => r.credits);
|
|
77
|
+
}
|
|
78
|
+
// The newest snapshot (in the index order the writer and the leaderboard use) against the live row.
|
|
79
|
+
async function newestVsLive(githubId) {
|
|
80
|
+
const { rows } = await q(
|
|
81
|
+
`SELECT (SELECT credits FROM platform_identity_activity_snapshots
|
|
82
|
+
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at DESC, id DESC LIMIT 1) AS newest,
|
|
83
|
+
(SELECT credits FROM platform_identity_builder_project_activity
|
|
84
|
+
WHERE client_id = $1 AND github_id = $2) AS live`,
|
|
85
|
+
[CLIENT, githubId],
|
|
86
|
+
);
|
|
87
|
+
return [rows[0].newest, rows[0].live];
|
|
88
|
+
}
|
|
49
89
|
|
|
50
90
|
let pass = 0;
|
|
51
91
|
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
92
|
+
const same = (actual, expected, msg) => { assert.deepEqual(actual, expected, msg); pass++; console.log(` ok - ${msg}`); };
|
|
52
93
|
|
|
53
94
|
try {
|
|
54
95
|
await q(migrationSql('platform_identity_005_builder_activity.sql'));
|
|
55
96
|
// upsertActivity's INSERT always names recent_ships (012_recent_ships adds the
|
|
56
|
-
// column); without it here,
|
|
97
|
+
// column); without it here, the upsertActivity steps fail 42703 on a fresh database.
|
|
57
98
|
await q(migrationSql('platform_identity_012_recent_ships.sql'));
|
|
58
99
|
await q(migrationSql('platform_identity_022_activity_snapshots.sql'));
|
|
59
100
|
await cleanup();
|
|
60
101
|
|
|
61
|
-
// 1 —
|
|
62
|
-
|
|
63
|
-
ok(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const { rows: history } = await q(
|
|
72
|
-
`SELECT credits FROM platform_identity_activity_snapshots
|
|
73
|
-
WHERE client_id = $1 AND github_id = $2 ORDER BY reported_at, id`,
|
|
102
|
+
// 1 — the writer snapshots the LIVE row, and only when it changed.
|
|
103
|
+
ok(await setLiveAndSnapshot(BUILDER, 100) !== null, 'a first-ever report inserts — nothing to be unchanged from');
|
|
104
|
+
ok(await setLiveAndSnapshot(BUILDER, 100) === null, 'an unchanged live row inserts nothing');
|
|
105
|
+
ok(await setLiveAndSnapshot(BUILDER, 150) !== null, 'a changed live row (credits differ) inserts a new row');
|
|
106
|
+
same(await history(BUILDER), [100, 150], 'exactly two rows, in write order — the unchanged report left no trace');
|
|
107
|
+
// A clock that stepped back: the last row carries a later stamp than "now".
|
|
108
|
+
await q(
|
|
109
|
+
`UPDATE platform_identity_activity_snapshots SET reported_at = clock_timestamp() + interval '1 hour'
|
|
110
|
+
WHERE client_id = $1 AND github_id = $2 AND credits = 150`,
|
|
74
111
|
[CLIENT, BUILDER],
|
|
75
112
|
);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
113
|
+
const later = await setLiveAndSnapshot(BUILDER, 175);
|
|
114
|
+
same((await newestVsLive(BUILDER))[0], 175, 'a write after the clock stepped back is still the newest in index order');
|
|
115
|
+
ok(later !== null, 'and it was written, stamped no earlier than the row before it');
|
|
79
116
|
|
|
80
|
-
// 2 —
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
117
|
+
// 2 — a report whose snapshot is still pending holds the row: its upsert is
|
|
118
|
+
// not visible yet, and a second report queues behind it. The prior state is the
|
|
119
|
+
// hard case the live-row read alone left open — the last snapshot (110) already
|
|
120
|
+
// equals the fresh report, while a stale report (100) is mid-flight. A table
|
|
121
|
+
// lock parks the stale report at its snapshot INSERT, deterministically.
|
|
122
|
+
await report(FLIGHT_BUILDER, 110);
|
|
123
|
+
const parker = await pool.connect();
|
|
124
|
+
let stale, fresh;
|
|
125
|
+
const settled = { stale: false, fresh: false };
|
|
126
|
+
try {
|
|
127
|
+
await parker.query('BEGIN');
|
|
128
|
+
await parker.query('LOCK TABLE platform_identity_activity_snapshots IN SHARE ROW EXCLUSIVE MODE');
|
|
129
|
+
stale = report(FLIGHT_BUILDER, 100).then(() => { settled.stale = true; });
|
|
130
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
131
|
+
same((await newestVsLive(FLIGHT_BUILDER))[1], 110,
|
|
132
|
+
"the stale report's upsert is invisible while its snapshot is pending — the two commit together");
|
|
133
|
+
fresh = report(FLIGHT_BUILDER, 110).then(() => { settled.fresh = true; });
|
|
134
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
135
|
+
ok(!settled.stale && !settled.fresh, 'and the fresh report queues behind it on the row lock');
|
|
136
|
+
await parker.query('COMMIT');
|
|
137
|
+
} finally {
|
|
138
|
+
parker.release();
|
|
139
|
+
}
|
|
140
|
+
await Promise.all([stale, fresh]);
|
|
141
|
+
same(await newestVsLive(FLIGHT_BUILDER), [110, 110],
|
|
142
|
+
'once both land, the newest snapshot agrees with the live row (the stale value is recorded, then superseded)');
|
|
143
|
+
same(await history(FLIGHT_BUILDER), [110, 100, 110], 'every change the live row went through is in the history');
|
|
144
|
+
|
|
145
|
+
// 3 — racing reports, end to end: however they interleave, once they settle
|
|
146
|
+
// the newest snapshot equals the live row.
|
|
147
|
+
for (let round = 0; round < 15; round++) {
|
|
148
|
+
const credits = [0, 1, 2, 3, 4].map((i) => 1000 + round * 10 + ((i * 7 + round) % 5));
|
|
149
|
+
await Promise.all(credits.map((c) => report(RACE_BUILDER, c)));
|
|
150
|
+
const [newest, live] = await newestVsLive(RACE_BUILDER);
|
|
151
|
+
assert.equal(newest, live, `round ${round}: newest snapshot ${newest} disagrees with the live row ${live}`);
|
|
152
|
+
}
|
|
153
|
+
pass++; console.log(' ok - 15 rounds of five racing reports: the newest snapshot always equals the live row');
|
|
154
|
+
|
|
155
|
+
// 4 — upsertActivity (the real POST /sso/activity/rollup path) really snapshots.
|
|
156
|
+
await report(OTHER_BUILDER, 10);
|
|
157
|
+
await report(OTHER_BUILDER, 10);
|
|
158
|
+
await report(OTHER_BUILDER, 20);
|
|
159
|
+
same(await history(OTHER_BUILDER), [10, 20],
|
|
160
|
+
'upsertActivity appends a snapshot per changed report — deleting the call inside it would leave this red');
|
|
92
161
|
|
|
93
162
|
console.log(`\nactivity_snapshots_db.mjs: ${pass} passed`);
|
|
94
163
|
} finally {
|
|
@@ -563,3 +563,94 @@ test('the module-level exec helper is reachable from inside iteration()', async
|
|
|
563
563
|
assert.equal(picked, true, 'iteration must reach the picker without touching an un-injected binding');
|
|
564
564
|
assert.equal(row.event, 'nothing_claimable');
|
|
565
565
|
});
|
|
566
|
+
|
|
567
|
+
// --- resolveWorkerBin: the Windows spawn that corrupted every worker ---------
|
|
568
|
+
//
|
|
569
|
+
// Found by task 1003907 running the thing rather than testing it. `shell: true`
|
|
570
|
+
// does not pass argv through on Windows: it CONCATENATES it into one command
|
|
571
|
+
// line that cmd.exe re-parses, destroying the quotes in --json-schema. claude
|
|
572
|
+
// then exited in ~2s with "--json-schema is not valid JSON" on EVERY worker, and
|
|
573
|
+
// cadence.js classes that outcome as `quiet`, which neither trips nor forgives
|
|
574
|
+
// the failure count — so a totally broken runner never escalated and looked
|
|
575
|
+
// healthy in the hall. The invariant: on win32, never ask for a shell when a
|
|
576
|
+
// real executable is available.
|
|
577
|
+
//
|
|
578
|
+
// Paths here use forward slashes deliberately. The resolver only string-matches
|
|
579
|
+
// the extension on an override, and backslash-heavy literals are their own
|
|
580
|
+
// source of bugs in a test that exists because of quoting.
|
|
581
|
+
|
|
582
|
+
test('resolveWorkerBin: a .cmd override still gets its shell', () => {
|
|
583
|
+
const got = runner.resolveWorkerBin({ CLAUDE_BIN: 'C:/x/claude.cmd' }, 'win32');
|
|
584
|
+
assert.equal(got.bin, 'C:/x/claude.cmd');
|
|
585
|
+
assert.equal(got.shell, true, 'Node cannot spawn a .cmd without a shell (CVE-2024-27980)');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('resolveWorkerBin: a real .exe override is never re-parsed by cmd.exe', () => {
|
|
589
|
+
const got = runner.resolveWorkerBin({ OTB_CLAUDE_BIN: 'C:/x/claude.exe' }, 'win32');
|
|
590
|
+
assert.equal(got.shell, false);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test('resolveWorkerBin: win32 with nothing on PATH falls back rather than refusing', () => {
|
|
594
|
+
const got = runner.resolveWorkerBin({ PATH: 'C:/nowhere-at-all' }, 'win32');
|
|
595
|
+
assert.deepEqual(got, { bin: 'claude', shell: true },
|
|
596
|
+
'a differently-laid-out install must still start; a mangled schema surfaces as a failed worker');
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test('resolveWorkerBin: non-windows never asks for a shell', () => {
|
|
600
|
+
assert.deepEqual(runner.resolveWorkerBin({ PATH: '/usr/bin' }, 'linux'), { bin: 'claude', shell: false });
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('the schema argument is the one that cannot survive concatenation', () => {
|
|
604
|
+
const args = buildWorkerArgs({ sessionId: 'abc' });
|
|
605
|
+
const schema = args[args.indexOf('--json-schema') + 1];
|
|
606
|
+
assert.ok(schema.includes('"'), 'the regression only bites because the schema carries quotes');
|
|
607
|
+
assert.ok(schema.length > 50, 'and because it must arrive as one intact argv slot');
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
// The test that would actually have caught the shipped bug: the resolver's
|
|
611
|
+
// answer has to REACH the spawn options. Testing resolveWorkerBin alone leaves
|
|
612
|
+
// `shell: process.platform === 'win32'` free to reappear at the call site.
|
|
613
|
+
test('spawnWorker passes the resolver’s shell verdict straight to spawn', async () => {
|
|
614
|
+
const { EventEmitter } = require('node:events');
|
|
615
|
+
let opts = null; let binSeen = null; let argvSeen = null;
|
|
616
|
+
const fakeSpawn = (bin, args, o) => {
|
|
617
|
+
binSeen = bin; argvSeen = args; opts = o;
|
|
618
|
+
const child = new EventEmitter();
|
|
619
|
+
child.stdout = new EventEmitter();
|
|
620
|
+
child.stderr = new EventEmitter();
|
|
621
|
+
child.stdin = { end: () => setImmediate(() => child.emit('close', 0)) };
|
|
622
|
+
child.kill = () => {};
|
|
623
|
+
return child;
|
|
624
|
+
};
|
|
625
|
+
await runner.spawnWorker({
|
|
626
|
+
task: { id: 7, title: 't', description: 'd' },
|
|
627
|
+
timeoutMs: 1000,
|
|
628
|
+
cwd: '.',
|
|
629
|
+
deps: { spawn: fakeSpawn, resolveBin: () => ({ bin: 'C:/x/claude.exe', shell: false }) },
|
|
630
|
+
});
|
|
631
|
+
assert.equal(opts.shell, false, 'a resolved .exe must spawn WITHOUT a shell — this is the whole fix');
|
|
632
|
+
assert.equal(binSeen, 'C:/x/claude.exe');
|
|
633
|
+
const schema = argvSeen[argvSeen.indexOf('--json-schema') + 1];
|
|
634
|
+
assert.equal(schema, JSON.stringify(WORKER_VERDICT_SCHEMA), 'the schema must arrive as one intact argv slot');
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// The worker's own credential must survive the env allowlist (task 1003907).
|
|
638
|
+
// Stripping it meant every worker on a headless host started unauthenticated and
|
|
639
|
+
// exited in ~2s with no usable output — while the supervisor recorded only
|
|
640
|
+
// "worker output was not JSON" and the cadence called that quiet.
|
|
641
|
+
test('workerEnv passes Claude auth through, and still refuses the rest', () => {
|
|
642
|
+
const src = {
|
|
643
|
+
PATH: '/usr/bin',
|
|
644
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'tok',
|
|
645
|
+
ANTHROPIC_API_KEY: 'key',
|
|
646
|
+
DATABASE_URL: 'postgres://nope',
|
|
647
|
+
GITHUB_TOKEN: 'ghp_nope',
|
|
648
|
+
NPM_TOKEN: 'npm_nope',
|
|
649
|
+
};
|
|
650
|
+
const env = runner.workerEnv(src);
|
|
651
|
+
assert.equal(env.CLAUDE_CODE_OAUTH_TOKEN, 'tok', 'a worker that cannot authenticate cannot work');
|
|
652
|
+
assert.equal(env.ANTHROPIC_API_KEY, 'key');
|
|
653
|
+
for (const leak of ['DATABASE_URL', 'GITHUB_TOKEN', 'NPM_TOKEN']) {
|
|
654
|
+
assert.equal(env[leak], undefined, `${leak} must never reach a bypassPermissions worker`);
|
|
655
|
+
}
|
|
656
|
+
});
|
|
@@ -50,12 +50,15 @@ async function t(name, fn) {
|
|
|
50
50
|
// asserts the params the real query actually bound.
|
|
51
51
|
function mockPool(answer = () => ({ rows: [] })) {
|
|
52
52
|
const calls = [];
|
|
53
|
+
const query = async (text, params = []) => {
|
|
54
|
+
calls.push({ text: String(text), params });
|
|
55
|
+
return answer(String(text), params) || { rows: [] };
|
|
56
|
+
};
|
|
53
57
|
return {
|
|
54
58
|
calls,
|
|
55
|
-
query
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
},
|
|
59
|
+
query,
|
|
60
|
+
// upsertActivity runs its upsert + snapshot as one transaction on a pooled client.
|
|
61
|
+
connect: async () => ({ query, release: () => {} }),
|
|
59
62
|
find: (re) => calls.find((c) => re.test(c.text)),
|
|
60
63
|
};
|
|
61
64
|
}
|
|
@@ -73,6 +73,7 @@ require.cache[moduleApiPath] = {
|
|
|
73
73
|
id: moduleApiPath, filename: moduleApiPath, loaded: true,
|
|
74
74
|
exports: {
|
|
75
75
|
pool,
|
|
76
|
+
withTx: (fn) => fn(pool), // transactions are not under test; keep the queue positional
|
|
76
77
|
validateOrRespond: helpers.validateOrRespond,
|
|
77
78
|
resolveSession: async () => null, // the browser leg is not under test
|
|
78
79
|
logger: () => noopLog,
|