@bongos/core 1.19.673 → 1.19.675

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.
@@ -14,6 +14,9 @@
14
14
 
15
15
  const express = require('express');
16
16
  const api = require('../../../src/module-api');
17
+ // Structured + attributable, and the console ratchet counts raw console calls —
18
+ // the api.logger(<module key>) precedent every other module's routes follow.
19
+ const log = api.logger('discord');
17
20
  const auth = require('../auth');
18
21
  const db = require('../db');
19
22
  const shipBroadcast = require('../ship-broadcast');
@@ -93,7 +96,28 @@ module.exports = function buildDiscordRouter() {
93
96
  }
94
97
  return res.json({ ok: true, guild_id: plan.guildId, op_count: plan.ops.length, ops: plan.ops, warnings: plan.warnings });
95
98
  } catch (err) {
96
- console.error('[gds] GET /discord/channels/plan', err);
99
+ log.error({ err: err && err.message }, 'GET /discord/channels/plan failed');
100
+ return res.fail('internal_error', 500);
101
+ }
102
+ });
103
+
104
+ // GET /api/bongos/discord/channels/snapshot — what docs/discord/channels.json
105
+ // WOULD say if it described the guild exactly as it is right now (task 1003510).
106
+ // Read-only in both directions: it writes nothing to Discord and nothing to the
107
+ // repo — the operator reviews the output and commits it, so the layout stays
108
+ // Archon-gated by merge, which is the property ADR 0037 §1 rests on.
109
+ // 503 when the bot is disabled / not connected to a guild.
110
+ // rank: archon — channel administration command.
111
+ router.get('/discord/channels/snapshot', auth.requireBuilder, auth.requirePermission('discord.channels.manage'), async (req, res) => {
112
+ try {
113
+ const channels = require('../discord-channels');
114
+ const snap = channels.computeSnapshotConfig();
115
+ if (!snap.ok) {
116
+ return res.fail('bot_unavailable', 503, { reason: snap.reason });
117
+ }
118
+ return res.json({ ok: true, guild_id: snap.guildId, config: snap.config, warnings: snap.warnings });
119
+ } catch (err) {
120
+ log.error({ err: err && err.message }, 'GET /discord/channels/snapshot failed');
97
121
  return res.fail('internal_error', 500);
98
122
  }
99
123
  });
@@ -111,7 +135,7 @@ module.exports = function buildDiscordRouter() {
111
135
  }
112
136
  return res.json({ ok: true, applied: result.applied, failed: result.failed, total: result.total, warnings: result.warnings });
113
137
  } catch (err) {
114
- console.error('[gds] POST /discord/channels/reconcile', err);
138
+ log.error({ err: err && err.message }, 'POST /discord/channels/reconcile failed');
115
139
  return res.fail('internal_error', 500);
116
140
  }
117
141
  });
@@ -352,6 +352,27 @@ async function createCriterion({ versionId, criterionId, criterionMd, goalId = n
352
352
  return rows[0];
353
353
  }
354
354
 
355
+ // updateCriterionGoal — move an EXISTING criterion into a goal, or out of one
356
+ // (task 1003509). Criteria used to be born linked and stay that way: goal_id was
357
+ // writable only in createCriterion's INSERT, so the runner-seeded V1 criterion a
358
+ // fresh instance starts with could never join the goal it obviously belongs to,
359
+ // and /progress showed it "(ungrouped)" forever beside a goal reporting
360
+ // criteria: []. Amending it needed SQL, which a hub-provisioned owner has no
361
+ // access to at all (walk finding W5).
362
+ //
363
+ // goalId null DETACHES (back to a version-level criterion). The caller owns every
364
+ // question this raises — that the goal exists and sits on the criterion's own
365
+ // version, who may write it, and whether the move completes the goal; this is the
366
+ // UPDATE and nothing else, exactly as updateTaskGoal below is for tasks.
367
+ async function updateCriterionGoal(id, goalId) {
368
+ const { rows } = await pool.query(
369
+ `UPDATE done_when_criteria SET goal_id = $1 WHERE id = $2
370
+ RETURNING id, version_id, criterion_id, criterion_md, goal_id, sort_order, satisfied`,
371
+ [goalId, id]
372
+ );
373
+ return rows[0] ?? null;
374
+ }
375
+
355
376
  async function updateTaskGoal(id, goalId) {
356
377
  const { rows } = await pool.query(
357
378
  `UPDATE tasks SET goal_id = $1, updated_at = now() WHERE id = $2 RETURNING *`,
@@ -1034,6 +1055,7 @@ module.exports = {
1034
1055
  setCreditsReward,
1035
1056
  suggestCreditsReward,
1036
1057
  taskTouchesProtectedPath,
1058
+ updateCriterionGoal,
1037
1059
  updateTaskAutomationTag,
1038
1060
  updateTaskDescription,
1039
1061
  updateTaskTitle,
@@ -187,6 +187,39 @@ async function recordProseEdit(kind, id, req, deltas, deps = {}) {
187
187
  }
188
188
  }
189
189
 
190
+ // recordCriterionGoalMove — the audit row for moving a criterion between goals
191
+ // (task 1003509). Same reason recordProseEdit exists: the global write-audit
192
+ // middleware records the request body, which carries the DESTINATION and nothing
193
+ // else, so on its own the trail cannot say which goal the criterion left. For a
194
+ // regrouping that is the half that matters — "criterion 12 joined goal 5" is not
195
+ // recoverable without "…and left goal 3".
196
+ //
197
+ // A no-op move (same goal_id in and out) writes nothing, mirroring the empty-delta
198
+ // short-circuit above. And an audit failure is logged, never thrown: the move has
199
+ // already committed, so throwing here would report a failure that did not happen.
200
+ async function recordCriterionGoalMove({ criterionId, fromGoalId, toGoalId }, req, deps = {}) {
201
+ const log = deps.log || ((m) => proseLog().info(m));
202
+ const from = fromGoalId ?? null;
203
+ const to = toGoalId ?? null;
204
+ if (from === to) return;
205
+ const builderId = req.builder && req.builder.id;
206
+ log(`criterion ${criterionId} moved by builder ${builderId}: goal ${from ?? '(none)'} -> ${to ?? '(none)'}`);
207
+ const insert = deps.insertAuditLog || api.insertAuditLog;
208
+ try {
209
+ await insert({
210
+ builderId,
211
+ route: `criterion:${criterionId}:goal-move`,
212
+ method: 'PATCH',
213
+ requestBodyRedacted: JSON.stringify({ from_goal_id: from, to_goal_id: to }),
214
+ responseStatus: 200,
215
+ ip: req.ip || null,
216
+ userAgent: (req.get && req.get('user-agent')) || null,
217
+ });
218
+ } catch (e) {
219
+ log(`[gds] AUDIT GAP: criterion ${criterionId} goal move landed but its audit row failed: ${e && e.message}`);
220
+ }
221
+ }
222
+
190
223
  // ---------------------------------------------------------------------------
191
224
  // WHY PATCH /goals/:id looks the way it does (kept here, not in the route:
192
225
  // routes/goals.js is over the fitness oversize threshold, so prose there is
@@ -332,6 +365,7 @@ module.exports = {
332
365
  criterionProseRefusal,
333
366
  goalAcceptingRequestsHandler,
334
367
  logProseEdit,
368
+ recordCriterionGoalMove,
335
369
  recordProseEdit,
336
370
  goalProseHandler,
337
371
  goalVisibilityHandler,
@@ -224,7 +224,7 @@ module.exports = function buildDoneWhenRouter() {
224
224
  });
225
225
  }, { errorCode: 'satisfy_failed' }));
226
226
 
227
- // PATCH /done-when/:criterionId — correct a criterion's TEXT (task 1003102).
227
+ // PATCH /done-when/:criterionId — amend a criterion's TEXT (task 1003102) and/or MOVE it between goals (task 1003509).
228
228
  // Was write-once: this file exposed create, satisfy and unsatisfy and nothing
229
229
  // else, so wrong text had no API repair path. The incident that forced it: a
230
230
  // shell-quoting error stripped backtick-wrapped literals out of three criteria
@@ -246,31 +246,128 @@ module.exports = function buildDoneWhenRouter() {
246
246
  // claim "gated like creation" is true. (Caught by the grader on the first cut of
247
247
  // task 1003102: the comment said it, the code did half of it.)
248
248
  // rank: metic+archon — matches POST /versions/:id/done-when.
249
+ //
250
+ // THE GOAL MOVE (task 1003509 — walk finding W5). `goal_id` used to be writable
251
+ // only by createCriterion's INSERT, so a criterion was born linked and stayed
252
+ // that way for life. The runner seeds a fresh instance's V1 criterion with NO
253
+ // goal — at standup there is no goal yet to name (provision-repo.js
254
+ // seedFirstVersionCmd) — so the owner's first goal could never adopt it:
255
+ // /progress showed the criterion "(ungrouped)" forever beside the goal it
256
+ // obviously belonged to, reporting `criteria: []`. Amending it took SQL, and a
257
+ // hub-provisioned owner has no SQL access at all.
258
+ //
259
+ // BOTH ENDS OF A MOVE ARE GATED, not just the goal it leaves. A move is a
260
+ // criterion write on the DESTINATION every bit as much as on the source, and
261
+ // gating only the source would let a goal's own owner push their criterion into
262
+ // a goal they have no authority over — a criterion being, per the CREATE route
263
+ // above, a thing that permanently blocks version close and goal auto-achieve
264
+ // with no delete route to undo it. So authorizeCriterionCreate runs against both
265
+ // and both must pass. DETACH (`goal_id: null`) has no destination, so it lands
266
+ // on that gate's goal-less branch and is Archon-only — the same wall that makes
267
+ // version-level criteria Archon-only at creation (ADR 0154), reached without a
268
+ // rule of its own.
269
+ //
270
+ // A SATISFIED criterion may still MOVE. criterionProseRefusal freezes the text
271
+ // of a verified claim, and that reasoning is about re-wording: moving one
272
+ // between goals changes how it is grouped, never what was checked. It is
273
+ // refused here only when the prose is what is changing.
274
+ //
275
+ // WHO CAN ADOPT THE SEEDED CRITERION. Because a goal-LESS criterion is
276
+ // version-level, the source gate's own goal-less branch makes ANY write to one
277
+ // Archon-only — which predates this task (a non-Archon could not edit an
278
+ // ungrouped criterion's prose either). That is not a wall in front of W5: the
279
+ // founder of a wizard-born instance IS its Archon, so the one criterion this
280
+ // was filed for is theirs to place. A Metic is unaffected where it matters —
281
+ // regrouping between two goals they already hold needs no escalation.
249
282
  router.patch('/done-when/:criterionId', auth.requireBuilder, auth.requirePermission('criterion.create'), asyncHandler('PATCH /done-when/:criterionId', async (req, res) => {
250
283
  if (validateOrRespond(req, res, {
251
- criterion_md: { required: true, type: 'string', minLength: 1, maxLength: LIMITS.NOTES },
284
+ criterion_md: { type: 'string', minLength: 1, maxLength: LIMITS.NOTES },
285
+ goal_id: { type: 'integer', min: 1 },
252
286
  })) return;
287
+ // `goal_id: null` is a DETACH and has to be told apart from an absent field, so
288
+ // the presence of the KEY decides, never its value. (The validator treats an
289
+ // explicit null as absent, which is why it validates at all.)
290
+ const movingGoal = Object.prototype.hasOwnProperty.call(req.body || {}, 'goal_id');
291
+ const changingProse = req.body.criterion_md != null;
292
+ if (!movingGoal && !changingProse) {
293
+ return res.fail('nothing_to_update', { status: 400, message: 'Send criterion_md, goal_id, or both.' });
294
+ }
253
295
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
254
296
  if (id === null) return;
255
297
  const existing = await doneWhen.getCriterion(id);
256
298
  if (!existing) return res.fail('criterion_not_found', 404);
257
- // The ownership wall, resolved exactly as the CREATE route resolves it: the
258
- // criterion's OWN goal + version, and membership read only for a non-Archon.
259
- const goal = existing.goal_id != null ? await db.getGoal(existing.goal_id) : null;
260
299
  const version = await db.getVersion(existing.version_id);
261
- const actorMembership = (goal && req.builder.rank !== 'archon')
262
- ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
263
- : null;
264
- const authority = authorizeCriterionCreate({
265
- goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
266
- });
300
+ // The ownership wall, resolved exactly as the CREATE route resolves it: the
301
+ // goal + version, and membership read only for a non-Archon. One closure, so
302
+ // both ends of a move are decided by the same code rather than a copy.
303
+ const authorizeFor = async (goal) => {
304
+ const actorMembership = (goal && req.builder.rank !== 'archon')
305
+ ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
306
+ : null;
307
+ return authorizeCriterionCreate({
308
+ goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
309
+ });
310
+ };
311
+ const currentGoal = existing.goal_id != null ? await db.getGoal(existing.goal_id) : null;
312
+ const authority = await authorizeFor(currentGoal);
267
313
  if (!authority.ok) return res.status(authority.status).json(authority.body);
268
- const refusal = goalEdits.criterionProseRefusal(existing);
269
- if (refusal) return res.fail(refusal.code, refusal.status, refusal.message ? { message: refusal.message } : undefined);
270
- const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md });
271
- if (!result) return res.fail('criterion_not_found', 404);
272
- await goalEdits.recordProseEdit('criterion', id, req, result.deltas);
273
- return res.json({ ok: true, criterion: result.criterion, deltas: result.deltas, unchanged: !!result.unchanged });
314
+
315
+ const targetGoalId = movingGoal ? (req.body.goal_id ?? null) : null;
316
+ if (movingGoal) {
317
+ let targetGoal = null;
318
+ if (targetGoalId !== null) {
319
+ targetGoal = await db.getGoal(targetGoalId);
320
+ // Same refusal the CREATE route gives, for the same reason: a criterion may
321
+ // never cross versions, which would put it in one version's rollup and the
322
+ // other's goal at once.
323
+ if (!targetGoal || String(targetGoal.version_id) !== String(existing.version_id)) {
324
+ return res.fail('bad_goal_id', { status: 400, message: `goal_id ${targetGoalId} does not exist on version '${existing.version_id}'.` });
325
+ }
326
+ }
327
+ const destination = await authorizeFor(targetGoal);
328
+ if (!destination.ok) return res.status(destination.status).json(destination.body);
329
+ }
330
+
331
+ let criterion = existing;
332
+ let deltas = [];
333
+ let unchanged = false;
334
+ if (changingProse) {
335
+ const refusal = goalEdits.criterionProseRefusal(existing);
336
+ if (refusal) return res.fail(refusal.code, refusal.status, refusal.message ? { message: refusal.message } : undefined);
337
+ const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md });
338
+ if (!result) return res.fail('criterion_not_found', 404);
339
+ await goalEdits.recordProseEdit('criterion', id, req, result.deltas);
340
+ criterion = result.criterion;
341
+ deltas = result.deltas;
342
+ unchanged = !!result.unchanged;
343
+ }
344
+ const achievedGoals = [];
345
+ const fromGoalId = existing.goal_id ?? null;
346
+ // A move to where it already is writes nothing at all — not the row, not the
347
+ // audit, not a re-derive. Nothing about the world changed.
348
+ if (movingGoal && fromGoalId !== targetGoalId) {
349
+ const moved = await db.updateCriterionGoal(id, targetGoalId);
350
+ if (!moved) return res.fail('criterion_not_found', 404);
351
+ criterion = moved;
352
+ await goalEdits.recordCriterionGoalMove(
353
+ { criterionId: id, fromGoalId, toGoalId: targetGoalId }, req
354
+ );
355
+ // BOTH ENDS are re-derived, because a move changes what each of them still
356
+ // needs. The destination is the obvious half — adopting an already-satisfied
357
+ // criterion can be the thing that completes a goal. The SOURCE is the half
358
+ // that is easy to miss and just as real: taking an UNSATISFIED criterion out
359
+ // of a goal can leave every criterion that remains satisfied, which is that
360
+ // goal completing. Skipping it would leave a goal silently open that nothing
361
+ // will ever close again — no later ship re-checks it, because no task under
362
+ // it changed. achieveGoalIfComplete is conservative and idempotent (open
363
+ // goals only, and it never un-achieves), so asking twice is two reads.
364
+ for (const goalId of [fromGoalId, targetGoalId]) {
365
+ if (goalId === null) continue;
366
+ const outcome = await db.achieveGoalIfComplete({ goalId });
367
+ if (outcome && outcome.achieved) achievedGoals.push(goalId);
368
+ }
369
+ }
370
+ return res.json({ ok: true, criterion, deltas, unchanged, achieved_goals: achievedGoals });
274
371
  }, { errorCode: 'criterion_update_failed' }));
275
372
 
276
373
  // Auth: flip a criterion back to unsatisfied (rare; for corrections).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.673",
3
+ "version": "1.19.675",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.673",
9
+ "version": "1.19.675",
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.673",
3
+ "version": "1.19.675",
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",
@@ -10,8 +10,9 @@
10
10
  // directly — it asks the deployed bot (via the API) to do the work.
11
11
  //
12
12
  // Usage:
13
- // node scripts/gds/discord-channels.js plan # dry run: what WOULD change
13
+ // node scripts/gds/discord-channels.js plan # dry run: what WOULD change
14
14
  // node scripts/gds/discord-channels.js apply # apply docs/discord/channels.json to Discord
15
+ // node scripts/gds/discord-channels.js snapshot # print the guild AS channels.json (seed the file)
15
16
  //
16
17
  // The channel layout itself is edited in docs/discord/channels.json and merged
17
18
  // (rank-gated) — this command just triggers the reconcile.
@@ -20,8 +21,8 @@ const { cliClient } = require('./cli-lib');
20
21
 
21
22
  async function main() {
22
23
  const cmd = (process.argv[2] || '').toLowerCase();
23
- if (cmd !== 'plan' && cmd !== 'apply') {
24
- console.error('usage: node scripts/gds/discord-channels.js <plan|apply>');
24
+ if (cmd !== 'plan' && cmd !== 'apply' && cmd !== 'snapshot') {
25
+ console.error('usage: node scripts/gds/discord-channels.js <plan|apply|snapshot>');
25
26
  process.exit(2);
26
27
  }
27
28
 
@@ -29,6 +30,8 @@ async function main() {
29
30
  let res;
30
31
  if (cmd === 'plan') {
31
32
  res = await api.discord.getDiscordChannelsPlan();
33
+ } else if (cmd === 'snapshot') {
34
+ res = await api.discord.getDiscordChannelsSnapshot();
32
35
  } else {
33
36
  res = await api.discord.postDiscordChannelsReconcile({ body: {} });
34
37
  }
@@ -47,7 +50,15 @@ async function main() {
47
50
  process.exit(1);
48
51
  }
49
52
 
53
+ // Warnings go to STDERR so `snapshot > channels.json` writes clean JSON.
50
54
  for (const w of body.warnings || []) console.warn(`warning: ${w}`);
55
+ if (cmd === 'snapshot') {
56
+ // STDOUT is the file body and nothing else — this command exists to be
57
+ // redirected. Review the diff before committing: it is the guild's real
58
+ // state, which is not necessarily the state anyone intended.
59
+ console.log(JSON.stringify(body.config, null, 2));
60
+ return;
61
+ }
51
62
  if (cmd === 'plan') {
52
63
  const ops = body.ops || [];
53
64
  if (!ops.length) { console.log('In sync — no channel changes needed.'); return; }
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.673'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.675'; // 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');