@bongos/core 1.19.699 → 1.19.701
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 +44 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +127 -3
- package/docs/api-reference.md +8 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/agents/lib/validate.js +6 -0
- package/modules/agents/module.json +1 -1
- package/modules/agents/routes/agents.js +177 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/lifecycle/conflict-resolve.js +52 -2
- package/modules/lifecycle/executed-closure.js +183 -0
- package/modules/lifecycle/publish-reconciler.js +28 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/agents_routes.mjs +194 -0
- package/tests/executed_closure.mjs +257 -0
- package/tests/publish_reconciler.mjs +58 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// modules/agents/routes/agents.js — the agent registry's READ surface (task
|
|
2
|
+
// 1002488, BONGOS-V1 goal 1000038 Phase 1).
|
|
3
|
+
//
|
|
4
|
+
// WHY. agents-sync reconciles .claude/agents/*.md into agents_definitions at
|
|
5
|
+
// deploy, and the spawn path fires from that table — so the table is the only
|
|
6
|
+
// honest answer to "what agents does this instance actually have, and which of
|
|
7
|
+
// them are armed?". Until now nothing could ask. A builder wondering why an
|
|
8
|
+
// agent did or did not fire had to read the deploy log or the DB directly, and
|
|
9
|
+
// an instance author had no way to see that the definition they committed was
|
|
10
|
+
// flagged for a scope violation and therefore silently disarmed.
|
|
11
|
+
//
|
|
12
|
+
// GET /agents — the registry, newest-synced first.
|
|
13
|
+
// GET /agents/:name — one definition, by its stable name.
|
|
14
|
+
//
|
|
15
|
+
// SELF-GATED, and that is the whole gate: `requireBuilder` and nothing else.
|
|
16
|
+
// These are reads of the instance's own configuration by the people who build
|
|
17
|
+
// it, the same audience and posture as GET /tasks. There is deliberately NO rank
|
|
18
|
+
// floor — a xenos who can see WHICH agents are armed is better placed to
|
|
19
|
+
// understand the review they just received, and none of the authority-bearing
|
|
20
|
+
// fields is writable here (authoring is a separate task, with its own wall).
|
|
21
|
+
//
|
|
22
|
+
// WHAT IS NOT SERVED, ON PURPOSE:
|
|
23
|
+
//
|
|
24
|
+
// `persona` is omitted from the LIST and served only on the detail route. It
|
|
25
|
+
// is capped at 32KB per row by the schema, so a 40-agent registry would be a
|
|
26
|
+
// 1.2MB response to render a table that shows none of it. This is a size
|
|
27
|
+
// decision, not a secrecy one — the detail route hands it over to anyone who
|
|
28
|
+
// asks by name.
|
|
29
|
+
//
|
|
30
|
+
// `author_builder_id` is never served. The FACT that matters downstream is
|
|
31
|
+
// `author_rank` — the rank the definition was stamped with at sync, which is
|
|
32
|
+
// what the spawn gate consults — and serving the id would turn a registry read
|
|
33
|
+
// into a people-directory read, which has its own gate (GET /scouting).
|
|
34
|
+
//
|
|
35
|
+
// Same idioms as modules/copy-desk/routes/copy-desk.js: doorway-only requires,
|
|
36
|
+
// factory export mounted from module.json `contributes.routes`, res.fail codes.
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const express = require('express');
|
|
41
|
+
const api = require('../../../src/module-api');
|
|
42
|
+
const validate = require('../lib/validate');
|
|
43
|
+
|
|
44
|
+
const log = api.logger('agents');
|
|
45
|
+
|
|
46
|
+
// The columns the serializer needs. Spelled out rather than `SELECT *` so that a
|
|
47
|
+
// column added later (an author's email, a raw credential) cannot reach a
|
|
48
|
+
// response by default — the read surface widens only when someone edits this
|
|
49
|
+
// list and reads the comment above about what is deliberately not served.
|
|
50
|
+
const COLUMNS = `
|
|
51
|
+
id, name, title, trigger_type, trigger_spec, model_tier,
|
|
52
|
+
scope_modules, scope_paths, scope_violation,
|
|
53
|
+
source, provenance, author_rank,
|
|
54
|
+
source_path, last_synced_at, enabled, created_at, updated_at
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
// PURE. Row → wire shape, so the projection is unit-testable without a database.
|
|
58
|
+
//
|
|
59
|
+
// `model_tier` is stored as the TIER ('default' | 'routine'), never a model id:
|
|
60
|
+
// the id a tier resolves to is a deployment fact that changes under the row, and
|
|
61
|
+
// a registry that answered with a stale model name would be worse than one that
|
|
62
|
+
// answers with the tier and lets the caller resolve it. The task asks for the
|
|
63
|
+
// "resolved model tier", and the tier IS the resolved value at this layer.
|
|
64
|
+
//
|
|
65
|
+
// `armed` is derived rather than aliased from `enabled`, because two schema
|
|
66
|
+
// CHECKs make "enabled" narrower than it reads: a row carrying a
|
|
67
|
+
// `scope_violation` is forced to enabled=false, and an enabled row must carry an
|
|
68
|
+
// author_rank. Surfacing the derived boolean beside the reason means a caller
|
|
69
|
+
// asking "why did this not fire?" gets the answer in the same object.
|
|
70
|
+
function serializeDefinition(row, { includePersona = false } = {}) {
|
|
71
|
+
if (!row) return null;
|
|
72
|
+
const out = {
|
|
73
|
+
name: row.name,
|
|
74
|
+
title: row.title || null,
|
|
75
|
+
trigger: {
|
|
76
|
+
type: row.trigger_type,
|
|
77
|
+
// trigger_spec is jsonb and NOT NULL DEFAULT '{}', but a hand-written row
|
|
78
|
+
// could still be any object; the event name is lifted out because it is
|
|
79
|
+
// the only part of the spec every event agent must carry (schema CHECK).
|
|
80
|
+
spec: row.trigger_spec && typeof row.trigger_spec === 'object' ? row.trigger_spec : {},
|
|
81
|
+
event: row.trigger_type === 'event' && row.trigger_spec ? (row.trigger_spec.event || null) : null,
|
|
82
|
+
},
|
|
83
|
+
model_tier: row.model_tier,
|
|
84
|
+
provenance: row.provenance, // 'built-in' | 'instance'
|
|
85
|
+
source: row.source, // 'file' | 'db'
|
|
86
|
+
// A file-sourced row's path is its identity in the repo; a db-authored row
|
|
87
|
+
// has none, and null says so rather than an empty string that sorts oddly.
|
|
88
|
+
source_path: row.source_path || null,
|
|
89
|
+
author_rank: row.author_rank || null,
|
|
90
|
+
scope: {
|
|
91
|
+
modules: Array.isArray(row.scope_modules) ? row.scope_modules : [],
|
|
92
|
+
paths: Array.isArray(row.scope_paths) ? row.scope_paths : [],
|
|
93
|
+
violation: row.scope_violation || null,
|
|
94
|
+
},
|
|
95
|
+
armed: row.enabled === true,
|
|
96
|
+
// Why an agent is not armed, when the answer is knowable. A scope violation
|
|
97
|
+
// is the one disarm the registry can explain; anything else is simply off.
|
|
98
|
+
disarmed_reason: row.enabled === true ? null : (row.scope_violation ? 'scope_violation' : null),
|
|
99
|
+
last_synced_at: row.last_synced_at || null,
|
|
100
|
+
created_at: row.created_at,
|
|
101
|
+
updated_at: row.updated_at,
|
|
102
|
+
};
|
|
103
|
+
if (includePersona) out.persona = row.persona;
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = function agentsRoutes() {
|
|
108
|
+
const router = express.Router();
|
|
109
|
+
|
|
110
|
+
// GET /agents — the whole registry. Unpaginated on purpose: agents are
|
|
111
|
+
// instance CONFIGURATION reconciled from committed files, so the row count is
|
|
112
|
+
// bounded by what a human wrote, not by traffic. If that ever stops being
|
|
113
|
+
// true, this is the line that needs a cursor — not a silent LIMIT, which
|
|
114
|
+
// would make the registry quietly incomplete.
|
|
115
|
+
router.get('/agents', api.requireBuilder, async (req, res) => {
|
|
116
|
+
try {
|
|
117
|
+
const { rows } = await api.pool.query(
|
|
118
|
+
`SELECT ${COLUMNS} FROM agents_definitions
|
|
119
|
+
ORDER BY enabled DESC, name ASC`
|
|
120
|
+
);
|
|
121
|
+
res.json({
|
|
122
|
+
ok: true,
|
|
123
|
+
agents: rows.map((r) => serializeDefinition(r)),
|
|
124
|
+
// The counts a reader actually wants first, computed here so three
|
|
125
|
+
// callers do not each reduce the array their own way.
|
|
126
|
+
counts: {
|
|
127
|
+
total: rows.length,
|
|
128
|
+
armed: rows.filter((r) => r.enabled === true).length,
|
|
129
|
+
flagged: rows.filter((r) => r.scope_violation).length,
|
|
130
|
+
},
|
|
131
|
+
// The vocabulary, from the validator that enforces it — so a UI building
|
|
132
|
+
// a filter cannot spell a trigger type the registry will never contain.
|
|
133
|
+
// Same rule as the task-kind filter (task 1003453): serve the enum, do
|
|
134
|
+
// not let the client keep a copy.
|
|
135
|
+
vocabulary: {
|
|
136
|
+
trigger_types: [...validate.TRIGGER_TYPES],
|
|
137
|
+
model_tiers: [...validate.MODEL_TIERS],
|
|
138
|
+
events: [...validate.EVENT_CATALOG],
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log.error({ err }, 'GET /agents failed');
|
|
143
|
+
if (!res.headersSent) res.fail('agents_list_failed', 500);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// GET /agents/:name — one definition, persona included.
|
|
148
|
+
//
|
|
149
|
+
// The name is validated against the SAME regex the schema CHECKs, before it
|
|
150
|
+
// reaches the query. The query is parameterised regardless, so this is not the
|
|
151
|
+
// injection wall — it is the difference between a 404 and a 400 for a caller
|
|
152
|
+
// who sent something that could never have been a name.
|
|
153
|
+
router.get('/agents/:name', api.requireBuilder, async (req, res) => {
|
|
154
|
+
const name = String(req.params.name || '');
|
|
155
|
+
if (!validate.NAME_RE.test(name) || name.length > validate.NAME_MAX) {
|
|
156
|
+
return res.fail('invalid_agent_name', 400);
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const { rows } = await api.pool.query(
|
|
160
|
+
`SELECT ${COLUMNS}, persona FROM agents_definitions WHERE name = $1`,
|
|
161
|
+
[name]
|
|
162
|
+
);
|
|
163
|
+
if (!rows.length) return res.fail('agent_not_found', 404);
|
|
164
|
+
return res.json({ ok: true, agent: serializeDefinition(rows[0], { includePersona: true }) });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
log.error({ err, name }, 'GET /agents/:name failed');
|
|
167
|
+
if (!res.headersSent) return res.fail('agent_read_failed', 500);
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return router;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Exported for the unit tests, which drive the projection with fabricated rows
|
|
176
|
+
// rather than standing up Postgres.
|
|
177
|
+
module.exports.serializeDefinition = serializeDefinition;
|
|
@@ -99,6 +99,12 @@ function createClient(opts = {}) {
|
|
|
99
99
|
// GET /achievements — rank: any-builder — GET /achievements
|
|
100
100
|
getAchievements: (args) => request("GET", "/achievements", { hasBody: false }, args),
|
|
101
101
|
},
|
|
102
|
+
"agents": {
|
|
103
|
+
// GET /agents — rank: any-builder — GET /agents
|
|
104
|
+
getAgents: (args) => request("GET", "/agents", { hasBody: false }, args),
|
|
105
|
+
// GET /agents/{name} — rank: any-builder — GET /agents/:name
|
|
106
|
+
getAgentsName: (args) => request("GET", "/agents/{name}", { hasBody: false }, args),
|
|
107
|
+
},
|
|
102
108
|
"analytics": {
|
|
103
109
|
// GET /analytics/builder/{id} — rank: any-builder — GET /analytics/builder/:id
|
|
104
110
|
getAnalyticsBuilderId: (args) => request("GET", "/analytics/builder/{id}", { hasBody: false }, args),
|
|
@@ -51,6 +51,16 @@ const githubPush = require('./github-push');
|
|
|
51
51
|
const { repoInfo, branding } = require('../../src/module-api');
|
|
52
52
|
const { loadRepoInfo } = repoInfo;
|
|
53
53
|
const { registerMergeDriver } = require('../../scripts/gds/install-git-hooks');
|
|
54
|
+
const { computeExecutedClosure, branchTouchesExecutedCode } = require('./executed-closure');
|
|
55
|
+
|
|
56
|
+
// The closure is a property of the SERVER's trusted tree, not of the branch being
|
|
57
|
+
// resolved, so it is computed once per root and cached. Asking the clone what our code
|
|
58
|
+
// executes would let the branch answer the question the check exists to ask.
|
|
59
|
+
const closureCache = new Map();
|
|
60
|
+
function executedClosure(root) {
|
|
61
|
+
if (!closureCache.has(root)) closureCache.set(root, computeExecutedClosure({ root }));
|
|
62
|
+
return closureCache.get(root);
|
|
63
|
+
}
|
|
54
64
|
|
|
55
65
|
const execFileP = promisify(execFile);
|
|
56
66
|
|
|
@@ -141,7 +151,8 @@ async function hasConflictMarkers(gitIn) {
|
|
|
141
151
|
// { resolved: true, head_sha, branch, regenerated } — merged + pushed
|
|
142
152
|
// { resolved: false, skipped: '<why>' [, conflicted: [...] ] } — left as-is
|
|
143
153
|
// skipped reasons: disabled | unconfigured | already_up_to_date | clone_failed |
|
|
144
|
-
// fetch_failed | checkout_failed |
|
|
154
|
+
// fetch_failed | checkout_failed | diff_failed | branch_modifies_executed_code
|
|
155
|
+
// (with executes[]) | markers_remain | push_failed |
|
|
145
156
|
// unresolvable_conflict (with conflicted[]) | generator_failed | commit_failed.
|
|
146
157
|
// `regenerated` says whether step 7b had to commit post-merge generator drift on top
|
|
147
158
|
// of the merge (task 1003546) — head_sha is that commit when it did.
|
|
@@ -211,6 +222,28 @@ async function resolveBranchConflicts({ branch } = {}, deps = {}) {
|
|
|
211
222
|
if (r.code !== 0) return { resolved: false, skipped: 'checkout_failed' };
|
|
212
223
|
const before = (await git(['rev-parse', 'HEAD'])).stdout.trim();
|
|
213
224
|
|
|
225
|
+
// 5b. REFUSE A BRANCH THAT EDITS THE CODE THIS FUNCTION IS ABOUT TO RUN
|
|
226
|
+
// (task 1003367, audit B1). Everything below executes out of the clone with the
|
|
227
|
+
// live server's environment — the merge driver at step 6 and the generators at
|
|
228
|
+
// 7b — so a branch that rewrote any of it would be running its own code as the
|
|
229
|
+
// server. This check must sit BEFORE the merge, not after it: `git merge` invokes
|
|
230
|
+
// `node scripts/gds/git-merge-regen.js` by RELATIVE path with cwd at the clone
|
|
231
|
+
// root, so the driver is the clone's copy and it runs at step 6.
|
|
232
|
+
//
|
|
233
|
+
// Fail CLOSED and hand the merge to a human. The alternative — scrubbing the
|
|
234
|
+
// clone and running anyway — has to be right about every file it restores, and
|
|
235
|
+
// the closure is 80-odd files deep through src/instance-config and src/bongos/*,
|
|
236
|
+
// not the scripts/ directory alone.
|
|
237
|
+
const touched = await git(['diff', '--name-only', 'refs/remotes/origin/main...HEAD']);
|
|
238
|
+
if (touched.code !== 0) return { resolved: false, skipped: 'diff_failed' };
|
|
239
|
+
const executes = branchTouchesExecutedCode(
|
|
240
|
+
touched.stdout.split('\n'),
|
|
241
|
+
deps.executedClosure || executedClosure(repoRoot),
|
|
242
|
+
);
|
|
243
|
+
if (executes.length) {
|
|
244
|
+
return { resolved: false, skipped: 'branch_modifies_executed_code', executes };
|
|
245
|
+
}
|
|
246
|
+
|
|
214
247
|
// 6. Merge origin/main into the branch. The otb-regen driver resolves the
|
|
215
248
|
// generated files; union resolves docs/adr/README.md; any genuine conflict
|
|
216
249
|
// leaves the merge non-zero.
|
|
@@ -392,7 +425,8 @@ async function defaultRunGenerators({ work, execFile = execFileP } = {}) {
|
|
|
392
425
|
// is a no-op for the strand.
|
|
393
426
|
// Returns { refreshed: true, head_sha, branch } | { refreshed: false, skipped }.
|
|
394
427
|
// skipped reasons: disabled | unconfigured | clone_failed | fetch_failed |
|
|
395
|
-
// checkout_failed |
|
|
428
|
+
// checkout_failed | diff_failed | branch_modifies_executed_code (with executes[]) |
|
|
429
|
+
// merge_conflict | generator_failed | no_regen_diff |
|
|
396
430
|
// markers_remain | commit_failed | push_failed.
|
|
397
431
|
async function refreshStaleGenerated({ branch } = {}, deps = {}) {
|
|
398
432
|
const disabled = deps.disabled !== undefined ? deps.disabled : process.env.CONFLICT_RESOLVE_DISABLED === '1';
|
|
@@ -441,6 +475,22 @@ async function refreshStaleGenerated({ branch } = {}, deps = {}) {
|
|
|
441
475
|
r = await git(['checkout', '-B', branch, `refs/remotes/origin/${branch}`]);
|
|
442
476
|
if (r.code !== 0) return { refreshed: false, skipped: 'checkout_failed' };
|
|
443
477
|
|
|
478
|
+
// 4b. Same refusal as resolveBranchConflicts step 5b (task 1003367, audit B1). This
|
|
479
|
+
// path registers no merge driver, so its only execution vector is runGenerators
|
|
480
|
+
// at step 6 — but the check still belongs BEFORE the merge, because after it the
|
|
481
|
+
// tree is a mix of both sides and `origin/main...HEAD` no longer answers "what
|
|
482
|
+
// did this BRANCH change". Same closure, same fail-closed posture, so the two
|
|
483
|
+
// heals cannot drift into different security postures.
|
|
484
|
+
const touched = await git(['diff', '--name-only', 'refs/remotes/origin/main...HEAD']);
|
|
485
|
+
if (touched.code !== 0) return { refreshed: false, skipped: 'diff_failed' };
|
|
486
|
+
const executes = branchTouchesExecutedCode(
|
|
487
|
+
touched.stdout.split('\n'),
|
|
488
|
+
deps.executedClosure || executedClosure(repoRoot),
|
|
489
|
+
);
|
|
490
|
+
if (executes.length) {
|
|
491
|
+
return { refreshed: false, skipped: 'branch_modifies_executed_code', executes };
|
|
492
|
+
}
|
|
493
|
+
|
|
444
494
|
// 5. Merge origin/main. Expected CLEAN for this strand (the PR was mergeable and
|
|
445
495
|
// the failing signal was a stale-generated CHECK, not a conflict). A genuine
|
|
446
496
|
// conflict → abort + report merge_conflict so the resolveBranchConflicts path
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// modules/lifecycle/executed-closure.js — which files does the merge resolver EXECUTE
|
|
2
|
+
// out of a builder's branch? (task 1003367, audit ref B1, 2026-08-29 security audit.)
|
|
3
|
+
//
|
|
4
|
+
// WHY. resolveBranchConflicts and refreshStaleGenerated clone a builder's branch into a
|
|
5
|
+
// temp tree and run code out of it with the LIVE SERVER's environment — DATABASE_URL,
|
|
6
|
+
// BUILDER_SECRET_KEY, GITHUB_PUSH_TOKEN, DISCORD_BOT_TOKEN all inherited. That happens
|
|
7
|
+
// on two separate paths, and the second one is easy to miss:
|
|
8
|
+
//
|
|
9
|
+
// 1. the `otb-regen` MERGE DRIVER. registerMergeDriver writes the driver command
|
|
10
|
+
// `node scripts/gds/git-merge-regen.js %O %A %B %P` with cwd at the clone root, so
|
|
11
|
+
// git runs the CLONE's copy during `git merge` — before any post-merge step.
|
|
12
|
+
// 2. the GENERATORS, run after the merge as execFile(node, [<clone>/scripts/gds/…]).
|
|
13
|
+
//
|
|
14
|
+
// Both resolve their repo root from __dirname, which is why the trusted-code problem
|
|
15
|
+
// cannot be solved by pointing them at the server's own copy: a server-rooted
|
|
16
|
+
// git-merge-regen.js would regenerate the SERVER's checkout instead of the clone.
|
|
17
|
+
//
|
|
18
|
+
// WHAT THIS MODULE IS FOR. The audit prescribed restoring scripts/gds/ from origin/main.
|
|
19
|
+
// That is not sufficient on its own: every generator requires ../../src/instance-config,
|
|
20
|
+
// and gen-api-docs additionally requires src/bongos/route-rank-check, api-prefix and
|
|
21
|
+
// routes/_helpers. A restore scoped to scripts/gds/ leaves those builder-controlled and
|
|
22
|
+
// the hole open. So rather than curate a list by hand, we COMPUTE the transitive
|
|
23
|
+
// require-closure of the real entrypoints and let the callers refuse to auto-resolve a
|
|
24
|
+
// branch that touches any of it.
|
|
25
|
+
//
|
|
26
|
+
// The closure is computed against the SERVER's trusted tree on purpose. The question is
|
|
27
|
+
// "what does OUR code execute", and asking the clone would let the branch answer it.
|
|
28
|
+
//
|
|
29
|
+
// THE RESIDUAL ASSUMPTION, stated so it can be re-checked rather than rediscovered.
|
|
30
|
+
// This is a STATIC walk of string-literal require()s, so it is sound exactly as far as
|
|
31
|
+
// that style holds. A computed require of real code — string concatenation, a template
|
|
32
|
+
// literal, a variable — inside a closure member would be invisible here and the closure
|
|
33
|
+
// would under-report what a branch can make the server run. That is not hypothetical:
|
|
34
|
+
// src/module-loader/loader.js is in the closure and computed-requires every module's
|
|
35
|
+
// route file, but only from loadModules(), which nothing on the generator path calls.
|
|
36
|
+
// tests/executed_closure.mjs pins the known computed requires per file and fails the
|
|
37
|
+
// build on a new one — which catches a change to OUR tree, and is therefore a tripwire,
|
|
38
|
+
// not a widening. If a generator ever starts loading modules, or a closure member
|
|
39
|
+
// switches to dynamic require, this walk must grow to match before it is trusted again.
|
|
40
|
+
//
|
|
41
|
+
// COST. computeExecutedClosure does synchronous readFileSync/statSync over ~80 files.
|
|
42
|
+
// It runs in the live server process, so that is worth knowing — but it is called from a
|
|
43
|
+
// ~5-minute background sweep, never a request path, and conflict-resolve.js caches it per
|
|
44
|
+
// repo root for the process lifetime. Revisit if the entrypoint set grows a lot, or if
|
|
45
|
+
// the cache key ever stops being a constant.
|
|
46
|
+
//
|
|
47
|
+
// Route files are deliberately NOT in here: src/bongos/route-rank-check.js reads them as
|
|
48
|
+
// DATA, never require()s them (ADR 0083's one-way rule, and its own comment says so), and
|
|
49
|
+
// gen-api-docs scans route sources textually. That distinction is what makes a narrow
|
|
50
|
+
// closure correct — the resolver still REGENERATES from the builder's merged tree, it
|
|
51
|
+
// just never EXECUTES it.
|
|
52
|
+
|
|
53
|
+
'use strict';
|
|
54
|
+
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const path = require('node:path');
|
|
57
|
+
|
|
58
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
59
|
+
|
|
60
|
+
// The two execution roots, as repo-relative POSIX paths.
|
|
61
|
+
//
|
|
62
|
+
// BOTH script-tree spellings are listed while the compat shim exists. Task 1003705 moves
|
|
63
|
+
// these to scripts/bongos/ and leaves scripts/gds/ holding WORKING forwarders, which git
|
|
64
|
+
// still executes by the driver's relative path — so a guard that knew only the new name
|
|
65
|
+
// would stop seeing edits to code that still runs. Missing entries are skipped, so this
|
|
66
|
+
// is correct before the rename, during the shim window, and after C3d retires it.
|
|
67
|
+
const ENTRYPOINTS = Object.freeze([
|
|
68
|
+
// 1. the merge driver (runs DURING `git merge`)
|
|
69
|
+
'scripts/gds/git-merge-regen.js',
|
|
70
|
+
'scripts/bongos/git-merge-regen.js',
|
|
71
|
+
// 2. the generators (run after the merge) — mirrors GENERATOR_SCRIPTS in
|
|
72
|
+
// conflict-resolve.js; asserted identical by tests/executed_closure.mjs so the two
|
|
73
|
+
// lists cannot drift apart.
|
|
74
|
+
'scripts/gds/gen-repo-map.js',
|
|
75
|
+
'scripts/bongos/gen-repo-map.js',
|
|
76
|
+
'scripts/gds/gen-file-map.js',
|
|
77
|
+
'scripts/bongos/gen-file-map.js',
|
|
78
|
+
'scripts/gds/gen-session-index.js',
|
|
79
|
+
'scripts/bongos/gen-session-index.js',
|
|
80
|
+
'scripts/gds/copy-inventory.js',
|
|
81
|
+
'scripts/bongos/copy-inventory.js',
|
|
82
|
+
'scripts/gds/gen-api-docs.js',
|
|
83
|
+
'scripts/bongos/gen-api-docs.js',
|
|
84
|
+
'scripts/gds/gen-api-client.js',
|
|
85
|
+
'scripts/bongos/gen-api-client.js',
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
// Files that are not require()d but still decide what runs. .gitattributes names which
|
|
89
|
+
// paths the otb-regen driver is applied to, so editing it changes what git hands the
|
|
90
|
+
// driver and how much of the tree that driver rewrites during a merge.
|
|
91
|
+
const TRUSTED_NON_CODE = Object.freeze(['.gitattributes']);
|
|
92
|
+
|
|
93
|
+
const REQUIRE_RE = /\brequire\(\s*(['"])([^'"]+)\1\s*\)/g;
|
|
94
|
+
|
|
95
|
+
function toPosix(p) {
|
|
96
|
+
return p.split(path.sep).join('/');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Resolve a relative require specifier to a repo-relative file, trying the extensions
|
|
100
|
+
// node would. Returns null for bare/`node:` specifiers and for anything that escapes the
|
|
101
|
+
// repo — a require that leaves the tree is not a file a branch can edit.
|
|
102
|
+
function resolveRelative(fromFileAbs, spec, { root }) {
|
|
103
|
+
if (!spec.startsWith('.')) return null;
|
|
104
|
+
const base = path.resolve(path.dirname(fromFileAbs), spec);
|
|
105
|
+
const candidates = [base, base + '.js', base + '.json', path.join(base, 'index.js')];
|
|
106
|
+
for (const c of candidates) {
|
|
107
|
+
let st;
|
|
108
|
+
try {
|
|
109
|
+
st = fs.statSync(c);
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!st.isFile()) continue;
|
|
114
|
+
const rel = toPosix(path.relative(root, c));
|
|
115
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
116
|
+
return rel;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// computeExecutedClosure — every file the resolver can execute out of a cloned branch.
|
|
122
|
+
//
|
|
123
|
+
// A STATIC walk of require() literals. It deliberately does not try to follow a computed
|
|
124
|
+
// require: the one that exists (gen-api-docs' require(path.join(CORE_ROOT,
|
|
125
|
+
// 'package.json'))) resolves to package.json, which is parsed as JSON rather than run.
|
|
126
|
+
// If a computed require of real CODE is ever added, the closure would under-report — so
|
|
127
|
+
// tests/executed_closure.mjs fails the build on any computed require inside the closure,
|
|
128
|
+
// which is the honest way to hold a static analysis to its assumption.
|
|
129
|
+
function computeExecutedClosure({ root = REPO_ROOT, entrypoints = ENTRYPOINTS } = {}) {
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
const queue = [];
|
|
132
|
+
|
|
133
|
+
for (const rel of entrypoints) {
|
|
134
|
+
const abs = path.join(root, rel);
|
|
135
|
+
if (fs.existsSync(abs)) queue.push(rel);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
while (queue.length) {
|
|
139
|
+
const rel = queue.shift();
|
|
140
|
+
if (seen.has(rel)) continue;
|
|
141
|
+
seen.add(rel);
|
|
142
|
+
|
|
143
|
+
const abs = path.join(root, rel);
|
|
144
|
+
let src;
|
|
145
|
+
try {
|
|
146
|
+
src = fs.readFileSync(abs, 'utf8');
|
|
147
|
+
} catch {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
REQUIRE_RE.lastIndex = 0;
|
|
151
|
+
let m;
|
|
152
|
+
while ((m = REQUIRE_RE.exec(src))) {
|
|
153
|
+
const next = resolveRelative(abs, m[2], { root });
|
|
154
|
+
if (next && !seen.has(next)) queue.push(next);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const rel of TRUSTED_NON_CODE) seen.add(rel);
|
|
159
|
+
return Object.freeze([...seen].sort());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// branchTouchesExecutedCode — pure predicate over `git diff --name-only` output.
|
|
163
|
+
//
|
|
164
|
+
// Returns the SORTED INTERSECTION rather than a boolean so the caller can name the
|
|
165
|
+
// offending files in its skip reason. A resolver that refuses without saying which file
|
|
166
|
+
// tripped it is one nobody can act on.
|
|
167
|
+
function branchTouchesExecutedCode(touchedPaths, closure) {
|
|
168
|
+
const inClosure = new Set(closure);
|
|
169
|
+
const hits = [];
|
|
170
|
+
for (const raw of touchedPaths || []) {
|
|
171
|
+
const rel = toPosix(String(raw).trim());
|
|
172
|
+
if (rel && inClosure.has(rel)) hits.push(rel);
|
|
173
|
+
}
|
|
174
|
+
return [...new Set(hits)].sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
ENTRYPOINTS,
|
|
179
|
+
TRUSTED_NON_CODE,
|
|
180
|
+
REPO_ROOT,
|
|
181
|
+
computeExecutedClosure,
|
|
182
|
+
branchTouchesExecutedCode,
|
|
183
|
+
};
|
|
@@ -386,6 +386,21 @@ async function runSweep(deps = {}) {
|
|
|
386
386
|
`strand:unresolvable_conflict${files ? `:${files}` : ''}`,
|
|
387
387
|
`PR #${m.number} (${branch}) has a hand-written conflict (${files || 'unknown files'}) only its builder can resolve`
|
|
388
388
|
);
|
|
389
|
+
} else if (rr && rr.skipped === 'branch_modifies_executed_code') {
|
|
390
|
+
// task 1003367 (audit B1): DEFINITIVE in exactly the sense P-3 means it —
|
|
391
|
+
// the resolver refused because the branch edits code the resolver would
|
|
392
|
+
// have executed, and no number of later sweeps changes that. Without this
|
|
393
|
+
// branch the skip fell through both chains with no log and no flag, so the
|
|
394
|
+
// PR retried silently every ~5 min forever. Announcing it is not polish:
|
|
395
|
+
// a refusal nobody is told about is indistinguishable from a resolver that
|
|
396
|
+
// quietly stopped working, and this file's own header says a strand must
|
|
397
|
+
// reach its builder when it is DETECTED.
|
|
398
|
+
const files = (rr.executes || []).slice(0, 5).join(',');
|
|
399
|
+
await announceStrand(
|
|
400
|
+
r,
|
|
401
|
+
`strand:branch_modifies_executed_code${files ? `:${files}` : ''}`,
|
|
402
|
+
`PR #${m.number} (${branch}) changes code the merge resolver executes (${files || 'unknown files'}) — it will not be auto-resolved; a human must land it`
|
|
403
|
+
);
|
|
389
404
|
}
|
|
390
405
|
} catch (e) {
|
|
391
406
|
errFn(`[publish-reconciler] conflict-resolve for task ${r.task_id} (non-blocking): ${e && e.message}`);
|
|
@@ -470,6 +485,19 @@ async function runSweep(deps = {}) {
|
|
|
470
485
|
logFn(
|
|
471
486
|
`[publish-reconciler] task ${r.task_id} (${branch}) PR #${m.number} red (${m.reason}) but regeneration produced no diff — a real check failure, left for a human`
|
|
472
487
|
);
|
|
488
|
+
} else if (rr && rr.skipped === 'branch_modifies_executed_code') {
|
|
489
|
+
// task 1003367 (audit B1). A tree-level determination, so it burns a cap
|
|
490
|
+
// slot like the two above rather than retrying as if it were flaky infra:
|
|
491
|
+
// the answer is the same on every future sweep. Announced for the same
|
|
492
|
+
// reason as the conflict path — silence here is a PR that never lands and
|
|
493
|
+
// never says why.
|
|
494
|
+
attempts.set(branch, n + 1);
|
|
495
|
+
const files = (rr.executes || []).slice(0, 5).join(',');
|
|
496
|
+
await announceStrand(
|
|
497
|
+
r,
|
|
498
|
+
`strand:branch_modifies_executed_code${files ? `:${files}` : ''}`,
|
|
499
|
+
`PR #${m.number} (${branch}) changes code the regen heal executes (${files || 'unknown files'}) — it will not be auto-healed; a human must land it`
|
|
500
|
+
);
|
|
473
501
|
}
|
|
474
502
|
} catch (e) {
|
|
475
503
|
errFn(`[publish-reconciler] regen-heal for task ${r.task_id} (non-blocking): ${e && e.message}`);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.701",
|
|
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.701",
|
|
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.701",
|
|
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/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.701'; // 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');
|