@bongos/core 1.19.851 → 1.19.853

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.
@@ -278,8 +278,35 @@ async function gradeTrendByDay(days = 30) {
278
278
  // -------------------------------------------------------------------------
279
279
 
280
280
 
281
- async function builderAnalytics(builderId, days = 90) {
281
+ // THE KIND DIMENSION (task 1004005). `kind` narrows every series that CAN be
282
+ // narrowed to one kind of work, so the page can answer "how am I doing at THIS
283
+ // kind of work". Three rules it follows, each of them a correctness rule rather
284
+ // than a style one:
285
+ //
286
+ // 1. COST IS NOT ANSWERABLE PER KIND, and is therefore not answered. cost_log
287
+ // has no kind and reaches tasks only through a NULLABLE task_id that is
288
+ // "NULL for non-task spend (infra, domain, etc.)" — most rows, as the
289
+ // cost-per-work comment below records. A kind-filtered cost line would
290
+ // silently drop nearly every cost row and plot a near-zero trend that looks
291
+ // like a real, cheap one. So a filtered call returns cost_trend: [] and
292
+ // cost_per_task: null, and the payload says why in `unavailable` — the page
293
+ // hides the chart and states the reason instead of drawing a lie.
294
+ // 2. top_kinds is NEVER filtered. It is the fingerprint the kind picker is
295
+ // BUILT from; narrowing it to the selected kind would leave the reader with
296
+ // one bar and no way back.
297
+ // 3. The same `kind` is honoured by ecosystemAnalytics, so the overlay compares
298
+ // like with like. A filtered line against an unfiltered benchmark is the
299
+ // failure mode this task's own text names as worse than no benchmark.
300
+ //
301
+ // An unknown kind is not rejected here — it simply matches nothing, which reads
302
+ // as an empty window. COALESCE mirrors top_kinds, so the literal 'unspecified'
303
+ // bucket the fingerprint shows is selectable.
304
+ async function builderAnalytics(builderId, days = 90, kind = null) {
282
305
  const d = String(days);
306
+ const k = kind === undefined || kind === null || kind === '' ? null : String(kind);
307
+ // Every kind-aware series takes this as its LAST parameter: null → no filter,
308
+ // so an unfiltered call runs the same plan it always did.
309
+ const KIND_TASKS = `($3::text IS NULL OR COALESCE(kind, 'unspecified') = $3)`;
283
310
  const [shipRate, costTrend, durationTrend, gradeTrend, topKinds, headline, claimRollup] =
284
311
  await Promise.all([
285
312
  // Ships per day — the core "dev curve".
@@ -290,11 +317,14 @@ async function builderAnalytics(builderId, days = 90) {
290
317
  WHERE shipped_by = $1 AND status = 'shipped'
291
318
  AND shipped_at >= now() - ($2 || ' days')::interval
292
319
  AND ${SMOKE_NOT_LIKE}
320
+ AND ${KIND_TASKS}
293
321
  GROUP BY 1 ORDER BY 1 ASC`,
294
- [builderId, d]
322
+ [builderId, d, k]
295
323
  ),
296
324
  // Cost per day + tasks touched that day → cost-per-task in JS.
297
- pool.query(
325
+ // Skipped entirely under a kind filter — see rule 1 above. Not queried and
326
+ // then discarded: an un-askable question should not cost a round trip.
327
+ k !== null ? { rows: [] } : pool.query(
298
328
  `SELECT to_char(date_trunc('day', recorded_at), 'YYYY-MM-DD') AS day,
299
329
  SUM(amount_usd)::numeric(12,2) AS total_usd,
300
330
  COUNT(DISTINCT task_id)::int AS n_tasks
@@ -314,8 +344,9 @@ async function builderAnalytics(builderId, days = 90) {
314
344
  AND actual_minutes IS NOT NULL AND est_minutes IS NOT NULL
315
345
  AND shipped_at >= now() - ($2 || ' days')::interval
316
346
  AND ${SMOKE_NOT_LIKE}
347
+ AND ${KIND_TASKS}
317
348
  GROUP BY 1 ORDER BY 1 ASC`,
318
- [builderId, d]
349
+ [builderId, d, k]
319
350
  ),
320
351
  // Grade pass-rate per day (quality holding up as throughput rises?).
321
352
  // Quality-graded rows only — outage / null-axis rows would read as
@@ -330,10 +361,14 @@ async function builderAnalytics(builderId, days = 90) {
330
361
  JOIN tasks t ON t.id = g.task_id
331
362
  WHERE t.shipped_by = $1
332
363
  AND g.graded_at >= now() - ($2 || ' days')::interval
364
+ AND ($3::text IS NULL OR COALESCE(t.kind, 'unspecified') = $3)
333
365
  GROUP BY 1 ORDER BY 1 ASC`,
334
- [builderId, d]
366
+ [builderId, d, k]
335
367
  ),
336
368
  // Top kinds shipped (all-time — a "what do I build" fingerprint).
369
+ // DELIBERATELY UNFILTERED — see rule 2 above: this list is what the kind
370
+ // picker is built from, so narrowing it to the current selection would
371
+ // leave the reader one bar and no way back to the others.
337
372
  pool.query(
338
373
  `SELECT COALESCE(kind, 'unspecified') AS kind, COUNT(*)::int AS n
339
374
  FROM tasks
@@ -352,18 +387,23 @@ async function builderAnalytics(builderId, days = 90) {
352
387
  FROM tasks
353
388
  WHERE shipped_by = $1
354
389
  AND ${SMOKE_NOT_LIKE}
390
+ AND ${KIND_TASKS}
355
391
  AND (shipped_at IS NULL OR shipped_at >= now() - ($2 || ' days')::interval)`,
356
- [builderId, d]
392
+ [builderId, d, k]
357
393
  ),
358
394
  // Confirmation rate — share of resolved claims that ended in a ship
359
395
  // (vs. abandoned / expired / partial). Distinct from the grade pass-rate.
360
396
  pool.query(
361
397
  `SELECT COUNT(*)::int AS resolved_claims,
362
398
  SUM(CASE WHEN outcome = 'shipped' THEN 1 ELSE 0 END)::int AS shipped_claims
363
- FROM claims
364
- WHERE builder_id = $1 AND outcome IS NOT NULL
365
- AND claimed_at >= now() - ($2 || ' days')::interval`,
366
- [builderId, d]
399
+ FROM claims c
400
+ WHERE c.builder_id = $1 AND c.outcome IS NOT NULL
401
+ AND c.claimed_at >= now() - ($2 || ' days')::interval
402
+ AND ($3::text IS NULL OR EXISTS (
403
+ SELECT 1 FROM tasks t
404
+ WHERE t.id = c.task_id
405
+ AND COALESCE(t.kind, 'unspecified') = $3))`,
406
+ [builderId, d, k]
367
407
  ),
368
408
  ]);
369
409
 
@@ -408,15 +448,24 @@ async function builderAnalytics(builderId, days = 90) {
408
448
 
409
449
  return {
410
450
  window_days: days,
451
+ kind: k, // the filter in force, echoed so a client can never mislabel a chart
452
+ // WHAT THIS WINDOW CANNOT ANSWER, named rather than left to look like a
453
+ // zero. Under a kind filter cost is un-attributable (see rule 1), so the
454
+ // page hides those two readouts and says why. An unfiltered call omits
455
+ // nothing and this is [].
456
+ unavailable: k === null ? [] : ['cost_trend', 'cost_per_task', 'total_cost_usd'],
411
457
  ship_rate: shipRate.rows, // [{ day, n }]
412
- cost_trend, // [{ day, total_usd, n_tasks, per_work }] — per_work is cumulative (task 1503)
458
+ cost_trend, // [{ day, total_usd, n_tasks, per_work }] — per_work is cumulative (task 1503); [] under a kind filter
413
459
  duration_trend: durationTrend.rows, // [{ day, avg_actual, avg_est }]
414
460
  grade_trend: grades, // [{ day, n, n_passed, avg_score }]
415
- top_kinds: topKinds.rows, // [{ kind, n }]
461
+ top_kinds: topKinds.rows, // [{ kind, n }] — always ALL kinds; this is the picker's source
416
462
  headline: {
417
463
  total_shipped: h.total_shipped,
418
- total_cost_usd: Math.round(totalCost * 100) / 100,
419
- cost_per_task: h.total_shipped > 0 ? Math.round((totalCost / h.total_shipped) * 100) / 100 : 0,
464
+ // null, not 0, under a kind filter: 0 is a measurement and this is the
465
+ // absence of one. A client that prints $0.00 here has said something false.
466
+ total_cost_usd: k === null ? Math.round(totalCost * 100) / 100 : null,
467
+ cost_per_task: k !== null ? null
468
+ : h.total_shipped > 0 ? Math.round((totalCost / h.total_shipped) * 100) / 100 : 0,
420
469
  avg_duration_min: h.avg_duration_min,
421
470
  confirmation_rate: c.resolved_claims > 0 ? Math.round((c.shipped_claims / c.resolved_claims) * 100) / 100 : null,
422
471
  resolved_claims: c.resolved_claims,
@@ -433,8 +482,14 @@ async function builderAnalytics(builderId, days = 90) {
433
482
  // daily series already have public feeds (/public/grades, /public/cost-summary),
434
483
  // so this adds just the ship-rate, duration, top-kinds, and headline aggregates.
435
484
 
436
- async function ecosystemAnalytics(days = 90) {
485
+ async function ecosystemAnalytics(days = 90, kind = null) {
437
486
  const d = String(days);
487
+ // Same contract as builderAnalytics' kind, and for one reason: the hall overlays
488
+ // this series on a builder's own. If only one side could be narrowed, the overlay
489
+ // would compare a kind against the whole ecosystem and still look like a
490
+ // like-for-like benchmark (task 1004005).
491
+ const k = kind === undefined || kind === null || kind === '' ? null : String(kind);
492
+ const KIND_TASKS = `($2::text IS NULL OR COALESCE(kind, 'unspecified') = $2)`;
438
493
  const [shipRate, durationTrend, topKinds, headline] = await Promise.all([
439
494
  pool.query(
440
495
  `SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
@@ -443,8 +498,9 @@ async function ecosystemAnalytics(days = 90) {
443
498
  WHERE status = 'shipped' AND shipped_by IS NOT NULL
444
499
  AND shipped_at >= now() - ($1 || ' days')::interval
445
500
  AND ${SMOKE_NOT_LIKE}
501
+ AND ${KIND_TASKS}
446
502
  GROUP BY 1 ORDER BY 1 ASC`,
447
- [d]
503
+ [d, k]
448
504
  ),
449
505
  pool.query(
450
506
  `SELECT to_char(date_trunc('day', shipped_at), 'YYYY-MM-DD') AS day,
@@ -455,8 +511,9 @@ async function ecosystemAnalytics(days = 90) {
455
511
  AND actual_minutes IS NOT NULL AND est_minutes IS NOT NULL
456
512
  AND shipped_at >= now() - ($1 || ' days')::interval
457
513
  AND ${SMOKE_NOT_LIKE}
514
+ AND ${KIND_TASKS}
458
515
  GROUP BY 1 ORDER BY 1 ASC`,
459
- [d]
516
+ [d, k]
460
517
  ),
461
518
  pool.query(
462
519
  `SELECT COALESCE(kind, 'unspecified') AS kind, COUNT(*)::int AS n
@@ -475,17 +532,19 @@ async function ecosystemAnalytics(days = 90) {
475
532
  FROM tasks
476
533
  WHERE status = 'shipped' AND shipped_by IS NOT NULL
477
534
  AND shipped_at >= now() - ($1 || ' days')::interval
478
- AND ${SMOKE_NOT_LIKE}`,
479
- [d]
535
+ AND ${SMOKE_NOT_LIKE}
536
+ AND ${KIND_TASKS}`,
537
+ [d, k]
480
538
  ),
481
539
  ]);
482
540
 
483
541
  const h = headline.rows[0] || { total_shipped: 0, active_builders: 0, avg_duration_min: 0 };
484
542
  return {
485
543
  window_days: days,
544
+ kind: k, // echoed so an overlay can prove both sides were narrowed the same way
486
545
  ship_rate: shipRate.rows,
487
546
  duration_trend: durationTrend.rows,
488
- top_kinds: topKinds.rows,
547
+ top_kinds: topKinds.rows, // always ALL kinds — the picker's source, never narrowed
489
548
  headline: {
490
549
  total_shipped: h.total_shipped,
491
550
  active_builders: h.active_builders,
@@ -28,6 +28,26 @@ function parseDays(raw) {
28
28
  return 90;
29
29
  }
30
30
 
31
+ // The `kind` filter (task 1004005) — "how am I doing at THIS kind of work".
32
+ //
33
+ // Bounded HERE rather than trusted to the query layer, even though both callers
34
+ // pass it as a bound $n parameter and neither interpolates it. A kind is one
35
+ // short token from a known vocabulary, so anything longer or stranger is a
36
+ // mistake or a probe, and a 200-char string has no business reaching a GROUP BY
37
+ // on either of these tables. An absent, blank or over-long value resolves to
38
+ // null, which every query below reads as "no filter" — the unfiltered plan.
39
+ //
40
+ // An UNRECOGNISED kind is deliberately NOT rejected: `kind` is a free text
41
+ // column with a COALESCE('unspecified') bucket, the vocabulary grows, and a 400
42
+ // on a kind that simply has no rows yet would be a worse answer than the honest
43
+ // empty window the filter already returns.
44
+ const KIND_MAX = 40;
45
+ function parseKind(raw) {
46
+ const s = (raw === undefined || raw === null ? '' : String(raw)).trim();
47
+ if (!s || s.length > KIND_MAX) return null;
48
+ return s;
49
+ }
50
+
31
51
  module.exports = function buildAnalyticsRouter() {
32
52
  const router = express.Router();
33
53
 
@@ -40,7 +60,7 @@ module.exports = function buildAnalyticsRouter() {
40
60
  // rank: public — ecosystem-wide dev-curve aggregate; individuals stripped.
41
61
  router.get('/public/analytics/ecosystem', asyncHandler('GET /public/analytics/ecosystem', async (req, res) => {
42
62
  const days = parseDays(req.query.days);
43
- const data = await db.ecosystemAnalytics(days);
63
+ const data = await db.ecosystemAnalytics(days, parseKind(req.query.kind));
44
64
  res.set('Cache-Control', 'public, max-age=60');
45
65
  res.json(data);
46
66
  }, { message: false, errorCode: 'ecosystem_analytics_failed' }));
@@ -55,7 +75,7 @@ module.exports = function buildAnalyticsRouter() {
55
75
  return res.fail('forbidden', { status: 403, message: 'You can only view your own analytics.' });
56
76
  }
57
77
  const days = parseDays(req.query.days);
58
- const data = await db.builderAnalytics(targetId, days);
78
+ const data = await db.builderAnalytics(targetId, days, parseKind(req.query.kind));
59
79
  res.json({ builder_id: targetId, ...data });
60
80
  }, { message: false, errorCode: 'builder_analytics_failed' }));
61
81
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.851",
3
+ "version": "1.19.853",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.851",
9
+ "version": "1.19.853",
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.851",
3
+ "version": "1.19.853",
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",
@@ -27,6 +27,7 @@
27
27
 
28
28
  'use strict';
29
29
 
30
+ const { execFileSync } = require('node:child_process');
30
31
  const path = require('node:path');
31
32
 
32
33
  // The same value provision-config.js exports as REPO_ROOT, recomputed here rather than
@@ -44,14 +45,55 @@ function parseTargetRef(ref) {
44
45
  return m ? { owner: m[1], name: m[2] } : null;
45
46
  }
46
47
 
47
- // The git remote for an instance's OWN repo. Reuses the SAME SSH deploy alias the
48
- // control-plane checkout uses (git@<alias>:<owner>/<repo>.git) — NO secret in the
49
- // command (SSH-key auth, nothing to leak into journald), consistent with how the
50
- // platform repo is wired. Standing up a new instance repo therefore needs only that
51
- // the control plane's deploy key has WRITE access to that repo (ADR 0125). Alias is
52
- // overridable via PROVISION_GIT_SSH_ALIAS.
53
- function instanceRepoRemote(repo, { sshAlias = process.env.PROVISION_GIT_SSH_ALIAS || 'github-deploy' } = {}) {
54
- return `git@${sshAlias}:${repo.owner}/${repo.name}.git`;
48
+ // The SSH alias the founder control plane wires for the platform repo (ADR 0125/0129).
49
+ const DEPLOY_ALIAS = 'github-deploy';
50
+
51
+ // One probe per alias per process — `ssh -G` re-reads the same static config every call.
52
+ const aliasProbeCache = new Map();
53
+
54
+ // Does an SSH host alias actually resolve HERE? `ssh -G <name>` prints the effective
55
+ // config for that name: a real `Host <name>` block rewrites `hostname`, an absent one
56
+ // echoes the name straight back — which is then exactly what git hands to DNS, and
57
+ // fails on. A box with no ssh client at all answers the same way. This has to be probed
58
+ // rather than assumed because the alias is a property of ONE box's ~/.ssh/config, not of
59
+ // this code (task 1003996). `runSsh` is injectable for tests; nothing spawns at require
60
+ // time, so this file stays inert on the merge-resolver path (see the header).
61
+ function sshAliasResolves(alias, runSsh) {
62
+ if (!runSsh && aliasProbeCache.has(alias)) return aliasProbeCache.get(alias);
63
+ let resolves = false;
64
+ try {
65
+ const probe = runSsh || ((a) => execFileSync('ssh', ['-G', a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }));
66
+ const hostname = (String(probe(alias)).match(/^hostname[ \t]+(\S+)/mi) || [])[1];
67
+ resolves = !!hostname && hostname.toLowerCase() !== alias.toLowerCase();
68
+ } catch { resolves = false; }
69
+ if (!runSsh) aliasProbeCache.set(alias, resolves);
70
+ return resolves;
71
+ }
72
+
73
+ // The git remote for an instance's OWN repo — git@<alias>:<owner>/<repo>.git, NO secret
74
+ // in the command (SSH-key auth, nothing to leak into journald).
75
+ //
76
+ // Which <alias>: an explicit one, else PROVISION_GIT_SSH_ALIAS, else the control plane's
77
+ // own deploy alias WHEN IT RESOLVES here, else github.com. The last two rungs are the
78
+ // point: reusing the founder box's alias is what makes the instance repo reachable by the
79
+ // same key that already pushes the platform repo (ADR 0125), and on that box the probe
80
+ // keeps choosing it. Anywhere else the alias is a dangling name, and `git@github.com:` is
81
+ // both the correct address and the one a plane's own deploy key is issued against.
82
+ function instanceRepoRemote(repo, { sshAlias, runSsh } = {}) {
83
+ const alias = sshAlias
84
+ || process.env.PROVISION_GIT_SSH_ALIAS
85
+ || (sshAliasResolves(DEPLOY_ALIAS, runSsh) ? DEPLOY_ALIAS : 'github.com');
86
+ return `git@${alias}:${repo.owner}/${repo.name}.git`;
87
+ }
88
+
89
+ // Every remote URL shape this code writes for `repo`: the github.com form plus the deploy
90
+ // alias in play. What a stored remote is VALIDATED against (upgrade.js), which must not
91
+ // re-run the probe — a remote we wrote stays ours even from a box where the alias no
92
+ // longer resolves.
93
+ function instanceRepoRemotes(repo) {
94
+ const aliases = new Set(['github.com', DEPLOY_ALIAS]);
95
+ if (process.env.PROVISION_GIT_SSH_ALIAS) aliases.add(process.env.PROVISION_GIT_SSH_ALIAS);
96
+ return [...aliases].map((a) => `git@${a}:${repo.owner}/${repo.name}.git`);
55
97
  }
56
98
 
57
99
  // The instance owner's stored `repo`-scoped OAuth token (ADR 0155), or null (none
@@ -100,6 +142,7 @@ async function lookupInstanceRow(instanceDir, deps = {}) {
100
142
 
101
143
  module.exports = {
102
144
  instanceRepoRemote,
145
+ instanceRepoRemotes,
103
146
  lookupInstanceRow,
104
147
  parseTargetRef,
105
148
  resolveOwnerGithubToken,
@@ -28,6 +28,7 @@ const { spawnSync } = require('node:child_process');
28
28
  const CORE_PKG = '@bongos/core';
29
29
  const VALID_CHANNELS = ['pinned', 'patch', 'minor'];
30
30
  const DEFAULT_CHANNEL = 'patch';
31
+ const NPM_VIEW_TIMEOUT_MS = 30000;
31
32
  // The subscription manifest an operator edits to opt instances in. Ships EMPTY (instances: []) in the
32
33
  // core template — a fresh instance subscribes nothing until the operator lists a target here.
33
34
  const SUBSCRIPTIONS_REL = path.join('config', 'update-subscriptions.json');
@@ -55,8 +56,10 @@ const DEFAULT_STALENESS_THRESHOLD = 5;
55
56
 
56
57
  // Parse "1.17.2" / "v1.17.2" / "1.17.2-rc.1" → { major, minor, patch, pre } or null. `pre` is the
57
58
  // prerelease tag ('' for a plain stable release), used only to EXCLUDE prereleases from auto-targets.
59
+ // `+build` metadata parses and is dropped: semver gives it no precedence, and npm installs the
60
+ // version underneath it, so a caller asking "is this older?" must see through it (task 1004009).
58
61
  function parseSemver(v) {
59
- const m = String(v == null ? '' : v).trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
62
+ const m = String(v == null ? '' : v).trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
60
63
  if (!m) return null;
61
64
  return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || '' };
62
65
  }
@@ -78,11 +81,27 @@ function compareSemver(a, b) {
78
81
  for (const k of ['major', 'minor', 'patch']) {
79
82
  if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
80
83
  }
81
- // Same x.y.z: a prerelease is LOWER than the stable release. (Two prereleases: not ordered
82
- // precisely here — we never auto-target a prerelease, so their relative order is immaterial.)
84
+ // Same x.y.z: a prerelease is LOWER than the stable release.
83
85
  if (pa.pre && !pb.pre) return -1;
84
86
  if (!pa.pre && pb.pre) return 1;
85
- return 0;
87
+ return comparePrerelease(pa.pre, pb.pre);
88
+ }
89
+
90
+ // Semver §11.4 for two prerelease tags: dot-separated identifiers left to right, numeric ones as
91
+ // numbers and below text ones, and the longer list wins a tie. Auto-targeting never needed this
92
+ // (a prerelease is never a target); `bongos upgrade`'s downgrade guard does — rc.2 → rc.1 is one.
93
+ function comparePrerelease(a, b) {
94
+ const ia = a ? a.split('.') : [];
95
+ const ib = b ? b.split('.') : [];
96
+ for (let i = 0; i < Math.min(ia.length, ib.length); i++) {
97
+ if (ia[i] === ib[i]) continue;
98
+ const na = /^\d+$/.test(ia[i]);
99
+ const nb = /^\d+$/.test(ib[i]);
100
+ if (na && nb) return +ia[i] < +ib[i] ? -1 : 1;
101
+ if (na !== nb) return na ? -1 : 1;
102
+ return ia[i] < ib[i] ? -1 : 1;
103
+ }
104
+ return Math.sign(ia.length - ib.length);
86
105
  }
87
106
 
88
107
  function normalizeChannel(channel) {
@@ -126,11 +145,12 @@ function resolveChannelTarget({ installed, channel, available } = {}) {
126
145
  // The package is public (task 1003872), so this needs no credential and no instance-local
127
146
  // `.npmrc` — any cwd resolves it. Fail-SOFT: any error returns
128
147
  // { ok:false } so the routine skips that instance rather than crashing the whole sweep. `run` +
129
- // `pkg` are injectable for tests.
130
- function listAvailableVersions({ cwd = process.cwd(), pkg = CORE_PKG, run = spawnSync, env = process.env } = {}) {
148
+ // `pkg` are injectable for tests. The timeout is a DEFAULT, not an opt-in: a hung registry read
149
+ // would otherwise stall a whole sweep, or an upgrade refusal that only wanted to name a version.
150
+ function listAvailableVersions({ cwd = process.cwd(), pkg = CORE_PKG, run = spawnSync, env = process.env, timeoutMs = NPM_VIEW_TIMEOUT_MS } = {}) {
131
151
  let res;
132
152
  try {
133
- res = run('npm', ['view', pkg, 'versions', '--json'], { cwd, env, encoding: 'utf8' });
153
+ res = run('npm', ['view', pkg, 'versions', '--json'], { cwd, env, encoding: 'utf8', timeout: timeoutMs });
134
154
  } catch (e) {
135
155
  return { ok: false, error: e && e.message ? e.message : String(e), versions: [] };
136
156
  }
@@ -32,6 +32,7 @@ const path = require('node:path');
32
32
  const crypto = require('node:crypto');
33
33
  const { spawnSync } = require('node:child_process');
34
34
  const { arg, hasFlag } = require('./cli-lib');
35
+ const { parseSemver, compareSemver, isStable, listAvailableVersions } = require('./update-channel');
35
36
 
36
37
  const CORE_PKG = '@bongos/core';
37
38
  const ARTIFACT = 'bongos-core';
@@ -202,7 +203,7 @@ function dirtyPinFiles(instanceDir, run = spawnSync) { return dirtyPaths(instanc
202
203
 
203
204
  // The GitHub owner/name behind `remote`, or null when the remote is not a GitHub
204
205
  // repository (there is nothing to push a GitHub token at) or cannot be read. Accepts
205
- // the plain github.com forms and the shared SSH alias instanceRepoRemote wires
206
+ // the plain github.com forms and every SSH alias instanceRepoRemotes wires
206
207
  // (`git@github-deploy:owner/repo.git`) — never another host, so a stored token is
207
208
  // never pointed at github.com/<owner>/<repo> for a repo that lives elsewhere.
208
209
  function remoteGithubRepo(instanceDir, remote = 'origin', run = spawnSync) {
@@ -214,7 +215,7 @@ function remoteGithubRepo(instanceDir, remote = 'origin', run = spawnSync) {
214
215
  const repo = auth.parseTargetRef(url);
215
216
  if (!repo) return null;
216
217
  const host = (url.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)[/:]/i) || [])[1] || '';
217
- if (host.toLowerCase() !== 'github.com' && url !== auth.instanceRepoRemote(repo)) return null;
218
+ if (host.toLowerCase() !== 'github.com' && !auth.instanceRepoRemotes(repo).includes(url)) return null;
218
219
  return repo;
219
220
  }
220
221
 
@@ -1093,9 +1094,19 @@ async function runUpgrade(opts, deps = {}) {
1093
1094
  let snapshot = null;
1094
1095
  if (!targetVersion) return { ok: false, error: 'missing --to <version> (the target core version)' };
1095
1096
 
1096
- const fromVersion = readInstalledCoreVersion(instanceDir, fsImpl) || readPinnedCoreVersion(instanceDir, fsImpl);
1097
+ const pinnedVersion = readPinnedCoreVersion(instanceDir, fsImpl);
1098
+ const fromVersion = readInstalledCoreVersion(instanceDir, fsImpl) || pinnedVersion;
1097
1099
  const doRollback = (reason) => rollback({ instanceDir, snapshot, fromVersion, toVersion: targetVersion, opts }, reason, deps);
1098
- log(`bongos upgrade — ${CORE_PKG}: ${fromVersion || '(none)'} → ${targetVersion}`);
1100
+ // A --to OLDER than this instance's core is refused before anything moves, --force included
1101
+ // (task 1004009). Measured against the NEWER of installed and pinned — a half-applied bump
1102
+ // leaves them apart. rollback() restores its own pin and never comes through here.
1103
+ const baseline = compareSemver(pinnedVersion, fromVersion) > 0 ? pinnedVersion : fromVersion;
1104
+ const isDowngrade = !!(parseSemver(baseline) && parseSemver(targetVersion) && compareSemver(targetVersion, baseline) < 0);
1105
+ if (isDowngrade && !opts.allowDowngrade) {
1106
+ const newest = listAvailableVersions({ cwd: instanceDir, run }).versions.filter(isStable).sort(compareSemver).pop();
1107
+ return { ok: false, downgrade: true, fromVersion, toVersion: targetVersion, error: `refusing to DOWNGRADE: --to ${targetVersion} is older than this instance's core ${baseline}${pinnedVersion && pinnedVersion !== fromVersion ? ` (installed ${fromVersion}, pinned ${pinnedVersion})` : ''}; newest published: ${newest || 'unknown — the registry could not be read'}. Re-read the live version and target that; pass --allow-downgrade only if going backwards is the point (migrations are not reverted).` };
1108
+ }
1109
+ log(`bongos ${isDowngrade ? 'DOWNGRADE (--allow-downgrade; migrations are NOT reverted)' : 'upgrade'} — ${CORE_PKG}: ${fromVersion || '(none)'} → ${targetVersion}`);
1099
1110
  log(` instance: ${instanceDir}${dryRun ? ' [DRY RUN — no changes]' : ''}`);
1100
1111
  if (fromVersion === targetVersion && !opts.force) {
1101
1112
  return { ok: false, error: `already on ${targetVersion} (pass --force to re-run the steps anyway)`, fromVersion };
@@ -1350,7 +1361,7 @@ async function runUpgrade(opts, deps = {}) {
1350
1361
  return { ok: false, error: `${why} — rolled back to ${snapshot.prevVersion || 'the previous core'}`, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, rolledBack: true, rollback: rb };
1351
1362
  }
1352
1363
 
1353
- const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: opts.note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
1364
+ const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: isDowngrade ? `downgrade (--allow-downgrade)${opts.note ? `: ${opts.note}` : ''}` : opts.note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
1354
1365
  if (ledger.ok) log(' ✓ recorded in core_upgrades ledger');
1355
1366
  else if (ledger.skipped) log(` • ledger skipped (${ledger.reason})`);
1356
1367
  else err(` ! ledger insert failed (${ledger.reason}) — record it manually`);
@@ -1383,7 +1394,7 @@ async function runUpgrade(opts, deps = {}) {
1383
1394
  }, run, log, err);
1384
1395
 
1385
1396
  const ok = versionOk && healthOk !== false && servedOk !== false;
1386
- log(ok ? `\n✓ upgrade complete — core ${fromVersion || '(none)'} → ${targetVersion}` : `\n! upgrade finished with warnings — review above.`);
1397
+ log(ok ? `\n✓ ${isDowngrade ? 'downgrade' : 'upgrade'} complete — core ${fromVersion || '(none)'} → ${targetVersion}` : `\n! ${isDowngrade ? 'downgrade' : 'upgrade'} finished with warnings — review above.`);
1387
1398
  return { ok, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, ledger: ledger.ok, pin };
1388
1399
  }
1389
1400
 
@@ -1412,6 +1423,7 @@ function parseArgs(argv) {
1412
1423
  gitRemote: arg('--git-remote', argv),
1413
1424
  dryRun: hasFlag('--dry-run', argv),
1414
1425
  force: hasFlag('--force', argv),
1426
+ allowDowngrade: hasFlag('--allow-downgrade', argv), // task 1004009: a --to older than the installed core is refused without it
1415
1427
  skipMigrate: hasFlag('--skip-migrate', argv),
1416
1428
  skipMaterialize: hasFlag('--skip-materialize', argv),
1417
1429
  skipRegenDocs: hasFlag('--skip-regen-docs', argv),
@@ -1449,7 +1461,8 @@ async function main(argv = process.argv.slice(2)) {
1449
1461
  ' --commit-pin commit + push package.json/-lock so a pull-deploy reset cannot revert the bump. The push rides the instance owner\'s stored GitHub token when one is stored (a PRIVATE instance\'s origin is a read-only deploy key — ADR 0176), else the git remote (--git-remote <name>, default origin); a refused push is reported loudly with the fix',
1450
1462
  ' --pin-no-push with --commit-pin: commit the pin but do NOT push it — for an instance whose repo of record is not ours (a provisioned co-tenant), where the push is wrong and no pull-deploy can revert the commit',
1451
1463
  ' --dry-run show the plan; change nothing',
1452
- ' --force skip the clean-tree + same-version guards',
1464
+ ' --force skip the clean-tree + same-version guards (NOT the downgrade guard)',
1465
+ ' --allow-downgrade let --to be OLDER than the installed core. Refused otherwise: a stale --to silently takes merged work off a live instance, and migrations are never reverted, so the DB stays ahead of the code',
1453
1466
  '',
1454
1467
  'Full bump: pre-flight → bump pin → npm install → verify integrity pin → migrate → materialize .claude → regen API docs+client → restart → verify (health + served coreVersion).',
1455
1468
  'On a failure after the pin moves (install/pin-verify/migrate/restart/health/served-version), auto-rollback reverts the pin, reinstalls the previous core, restarts, and records a rolled_back ledger row (the DB is not rolled back — core migrations are additive).',
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.851'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.853'; // 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');