@bongos/core 1.19.59 → 1.19.60

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.59",
6
- "core_contract": "1.19.59",
7
- "source_commit": "e93244fafbe4236b1b90218f03c70c92bfd0becb",
5
+ "core_version": "1.19.60",
6
+ "core_contract": "1.19.60",
7
+ "source_commit": "ce8873473b894cf991da712833f18dc9e5f3f88c",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T23:37:01.674Z",
9
+ "built_at": "2026-08-11T23:51:23.264Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 291,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1217,
14
+ "functional_verbatim": 1218,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1529,
20
- "tree_sha256": "0f793cd7617fce56439932f68de11239e7639d28e50f8d9ee485ba8a602a5f30",
19
+ "file_count": 1530,
20
+ "tree_sha256": "acefccdf8f346aff9dfc425d717f82bc3d87cc5c680d303bd19ffc334dc9b195",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "5c89524ec76f5f5df365a188541d50aa31167ae7920124baf562fb32ad30632c"
370
+ "sha256": "34b9ebfa393ad892c959bf356c1727188e71c61a8ff9ee0d0eca66433a51614e"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -1312,7 +1312,7 @@
1312
1312
  {
1313
1313
  "path": "docs/api/openapi.json",
1314
1314
  "mode": "0000644",
1315
- "sha256": "0076d08133c342fa905fa90682e606eefa02747d5b11aea9f0bdb3563b293f76"
1315
+ "sha256": "bad0bdd6cdd07d66ed8b3bbc2bb2751ce1d6a0a819bc2d455952f5b0c18cea64"
1316
1316
  },
1317
1317
  {
1318
1318
  "path": "docs/architecture.md",
@@ -3732,7 +3732,7 @@
3732
3732
  {
3733
3733
  "path": "modules/provisioning/provisioning.js",
3734
3734
  "mode": "0000644",
3735
- "sha256": "468557062f75a9a02e3cab53103389d7ecaaede6528b60587e51a71ce9dfecf5"
3735
+ "sha256": "655d7e07f8a50cd12420c67e0cb12ff4f94098e53b218b0985f866b9433e409f"
3736
3736
  },
3737
3737
  {
3738
3738
  "path": "modules/provisioning/rate-limit.js",
@@ -3742,7 +3742,7 @@
3742
3742
  {
3743
3743
  "path": "modules/provisioning/routes/provisioning.js",
3744
3744
  "mode": "0000644",
3745
- "sha256": "f48562d5f17e0ea9934b4ca96c84e1ac0fc448819a8fff54227045e52aa98731"
3745
+ "sha256": "c0a1a015e20c446513ae435075379e1c46cfe0e492574ff9f64d9ef39b081e4b"
3746
3746
  },
3747
3747
  {
3748
3748
  "path": "modules/provisioning/tests/provisioning.mjs",
@@ -4032,12 +4032,12 @@
4032
4032
  {
4033
4033
  "path": "package-lock.json",
4034
4034
  "mode": "0000644",
4035
- "sha256": "f2e15bf06d0e8080e47aeea514dfc26dc267c14a96d32fe805cb240491f0baad"
4035
+ "sha256": "862808e3b9e59ac80baa7bcdda60a08dc8f0f31152750ea99264a5aac24c1140"
4036
4036
  },
4037
4037
  {
4038
4038
  "path": "package.json",
4039
4039
  "mode": "0000644",
4040
- "sha256": "c3feb3c561c5bf96e9580673b6995835a0d5674f54f96c8b3109fc304e090556"
4040
+ "sha256": "51b6e2905a863ad6c9d25eecf376a8db1f61ee960e00dd5b12f4d5aefcf36625"
4041
4041
  },
4042
4042
  {
4043
4043
  "path": "public-docs/index.html",
@@ -5657,7 +5657,7 @@
5657
5657
  {
5658
5658
  "path": "src/module-api.js",
5659
5659
  "mode": "0000644",
5660
- "sha256": "a02eb4c1cfa272d700a9776652612143de38566cd86208f059ed1d0fed24934b"
5660
+ "sha256": "dd71e36bfd25965ca6b720f1fdbd94ba47553260915176d693f5246fdaac8d2e"
5661
5661
  },
5662
5662
  {
5663
5663
  "path": "src/module-loader/loader.js",
@@ -7149,6 +7149,11 @@
7149
7149
  "mode": "0000644",
7150
7150
  "sha256": "9a41d64fba3a8cc8ffdbccb0aea363ca6ccb63909cfeefd4bd0b05ebab052403"
7151
7151
  },
7152
+ {
7153
+ "path": "tests/provisioning_teardown_intent.mjs",
7154
+ "mode": "0000644",
7155
+ "sha256": "c5ebacf7e29be4e6afecedf5b8593eef750d497e0c502cfa2be92a89b2cff0a1"
7156
+ },
7152
7157
  {
7153
7158
  "path": "tests/pruned_lockfile_integrity.mjs",
7154
7159
  "mode": "0000644",
@@ -246,10 +246,10 @@ export interface PostOverrideRequestsIdDecideRequest { decision: string; note?:
246
246
  export interface PostProjectsInviteRequest { client_id: string; github_login: string }
247
247
  export interface PostProjectsRequest { origin: string; name: string; tagline?: string; art_url?: string }
248
248
  export interface PostProjectsResponse { project: unknown }
249
- export interface PostProvisioningInstancesIdForceTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; message: unknown }
249
+ export interface PostProvisioningInstancesIdForceTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; superseded: unknown; message: unknown }
250
250
  export interface PostProvisioningInstancesIdGithubAppRequest { account?: string }
251
251
  export interface PostProvisioningInstancesIdGithubAppResponse { state: unknown; account: unknown; post_url: unknown; personal_url: unknown; org_url: unknown; org_login: unknown; manifest: unknown; redirect_url: unknown; expires_at: unknown }
252
- export interface PostProvisioningInstancesIdTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; message: unknown }
252
+ export interface PostProvisioningInstancesIdTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; superseded: unknown; message: unknown }
253
253
  export interface PostProvisioningInstancesRequest { slug: string; target_ref?: string; hosting_shape?: string; domain?: string; tier?: string; onboard_mode?: string }
254
254
  export interface PostSearchMarkRequest { chunk_id?: StringifiableId; useful?: unknown }
255
255
  export interface PostSearchRequest { expand?: unknown; q?: string; source_kind?: string; limit?: unknown }
@@ -15637,6 +15637,7 @@
15637
15637
  "queued": {},
15638
15638
  "action": {},
15639
15639
  "status": {},
15640
+ "superseded": {},
15640
15641
  "message": {}
15641
15642
  },
15642
15643
  "required": [
@@ -15644,6 +15645,7 @@
15644
15645
  "queued",
15645
15646
  "action",
15646
15647
  "status",
15648
+ "superseded",
15647
15649
  "message"
15648
15650
  ]
15649
15651
  },
@@ -15691,6 +15693,7 @@
15691
15693
  "queued": {},
15692
15694
  "action": {},
15693
15695
  "status": {},
15696
+ "superseded": {},
15694
15697
  "message": {}
15695
15698
  },
15696
15699
  "required": [
@@ -15698,6 +15701,7 @@
15698
15701
  "queued",
15699
15702
  "action",
15700
15703
  "status",
15704
+ "superseded",
15701
15705
  "message"
15702
15706
  ]
15703
15707
  },
@@ -38,6 +38,12 @@ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest'];
38
38
  // Mirrors dev-box canTransition('deprovision', …): a requested/torn_down/error
39
39
  // instance has nothing running to tear down via the happy path.
40
40
  const TEARDOWN_FROM = new Set(['provisioning', 'active', 'tearing_down']);
41
+ // The states from which (re)enqueueing a 'provision' intent is meaningful. An
42
+ // 'active' instance needs no standup — blindly re-enqueueing one on a slug
43
+ // re-request left a stale open provision intent that then blocked the owner's
44
+ // teardown on the action-blind ux_provisioning_intents_open (task 1002691);
45
+ // 'tearing_down' is mid-reclaim (any open slot belongs to the teardown).
46
+ const PROVISION_ENQUEUE_FROM = new Set(['requested', 'provisioning', 'error', 'torn_down']);
41
47
 
42
48
  // Monthly cost estimate (USD) for a dedicated droplet tier — VISIBILITY only
43
49
  // (ADR 0111 §6), never a bill. A small local table so this module needs no
@@ -350,6 +356,31 @@ async function enqueueIntent(db, instanceId, action, requestedBy) {
350
356
  }
351
357
  }
352
358
 
359
+ // Queue a teardown, superseding a stale PENDING provision intent if one holds the
360
+ // open slot (task 1002691: ux_provisioning_intents_open is action-blind, so the old
361
+ // blind enqueue collided with such an intent and the API falsely said "teardown
362
+ // already queued" — the instance leaked). A pending provision is flipped to 'error'
363
+ // (the state CHECK has no 'superseded'; last_error says why) so the runner never
364
+ // stands it up. A RUNNING provision is mid-flight infra work and is never yanked —
365
+ // the caller gets { conflict: 'open_intent' } with the blocking intent so the route
366
+ // can answer truthfully. Each statement is atomic and the index stays the arbiter:
367
+ // any interleaving with the runner or a concurrent enqueue lands on one of these
368
+ // truthful outcomes.
369
+ async function enqueueTeardown(db, instanceId, requestedBy) {
370
+ const { rows: superseded } = await db.query(
371
+ `UPDATE provisioning_intents
372
+ SET state = 'error', last_error = 'superseded: teardown requested',
373
+ updated_at = now(), resolved_at = now()
374
+ WHERE instance_id = $1 AND action = 'provision' AND state = 'pending'
375
+ RETURNING id`,
376
+ [instanceId]
377
+ );
378
+ const { intent, created } = await enqueueIntent(db, instanceId, 'teardown', requestedBy);
379
+ if (created) return { intent, created: true, superseded: superseded.length > 0 };
380
+ if (intent.action === 'teardown') return { intent, created: false, superseded: false };
381
+ return { intent, created: false, superseded: false, conflict: 'open_intent' };
382
+ }
383
+
353
384
  // The instance's currently-open (pending|running) intent, if any.
354
385
  async function getOpenIntent(db, instanceId) {
355
386
  const { rows } = await db.query(
@@ -540,7 +571,7 @@ async function costLedger(db, { limit = 500 } = {}) {
540
571
  module.exports = {
541
572
  // constants
542
573
  INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, INTENT_ACTIONS, TEARDOWN_FROM,
543
- TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
574
+ PROVISION_ENQUEUE_FROM, TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
544
575
  // pure
545
576
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
546
577
  // pure — GitHub App Manifest flow (task 2080)
@@ -549,7 +580,7 @@ module.exports = {
549
580
  isProvisionedHostname,
550
581
  getInstanceById, getInstanceBySlug, listInstancesForOwner, listAllInstances,
551
582
  createInstance, setInstanceStatus,
552
- enqueueIntent, getOpenIntent, claimNextIntent, resolveIntent,
583
+ enqueueIntent, enqueueTeardown, getOpenIntent, claimNextIntent, resolveIntent,
553
584
  recordEvent, instanceStateForSlug, costLedger,
554
585
  // data access — GitHub App Manifest attempts (task 2080)
555
586
  createOAuthManifest, getOAuthManifestByState, attachManifestCode,
@@ -271,16 +271,22 @@ module.exports = function provisioningRoutes() {
271
271
  if (!provisioning.TEARDOWN_FROM.has(inst.status)) {
272
272
  return res.fail('not_teardownable', 409, { status: inst.status });
273
273
  }
274
- const { created } = await provisioning.enqueueIntent(pool, id, 'teardown', `api:archon:${req.builder.id}`);
275
- if (created) {
274
+ // Same truthful outcome mapping as the owner's teardown (task 1002691).
275
+ const r = await provisioning.enqueueTeardown(pool, id, `api:archon:${req.builder.id}`);
276
+ if (r.conflict) {
277
+ return res.fail('intent_conflict', { status: 409,
278
+ message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
279
+ details: { action: r.intent.action, state: r.intent.state } });
280
+ }
281
+ if (r.created) {
276
282
  await provisioning.recordEvent(pool, {
277
283
  instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'teardown',
278
- detail: `force-teardown by archon ${req.builder.github_login || req.builder.id}`,
284
+ detail: `force-teardown by archon ${req.builder.github_login || req.builder.id}${r.superseded ? ' (superseded a stale provision intent)' : ''}`,
279
285
  actor: `api:archon:${req.builder.id}`,
280
286
  }).catch(() => {});
281
287
  }
282
- res.json({ ok: true, queued: created, action: 'teardown', status: inst.status,
283
- message: created ? 'The instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
288
+ res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
289
+ message: r.created ? 'The instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
284
290
  } catch (err) {
285
291
  console.error('[provisioning] POST /provisioning/instances/:id/force-teardown', err);
286
292
  res.fail('teardown_failed', { status: 500, message: 'internal error' });
@@ -338,8 +344,16 @@ module.exports = function provisioningRoutes() {
338
344
  if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
339
345
  return res.fail('slug_taken', { status: 409, message: 'That instance slug is already in use.' });
340
346
  }
341
- // Enqueue the provision intent (idempotent: an already-open intent is returned).
342
- const { intent, created: intentCreated } = await provisioning.enqueueIntent(pool, instance.id, 'provision', 'api:self');
347
+ // Enqueue the provision intent (idempotent: an already-open intent is returned)
348
+ // — but ONLY when the instance actually needs standing up. Blindly enqueueing on
349
+ // a re-request of an ACTIVE instance left a stale open provision intent that then
350
+ // blocked the owner's teardown on the action-blind open-intent index (task 1002691).
351
+ let intent = null, intentCreated = false;
352
+ if (created || provisioning.PROVISION_ENQUEUE_FROM.has(instance.status)) {
353
+ ({ intent, created: intentCreated } = await provisioning.enqueueIntent(pool, instance.id, 'provision', 'api:self'));
354
+ } else {
355
+ intent = await provisioning.getOpenIntent(pool, instance.id);
356
+ }
343
357
  if (created) {
344
358
  await provisioning.recordEvent(pool, {
345
359
  instanceId: instance.id, ownerBuilderId: req.builder.id, event: 'request',
@@ -416,15 +430,24 @@ module.exports = function provisioningRoutes() {
416
430
  if (!provisioning.TEARDOWN_FROM.has(inst.status)) {
417
431
  return res.fail('not_teardownable', { status: 409, message: `Instance is '${inst.status}' — nothing to tear down right now.`, details: { status: inst.status } });
418
432
  }
419
- const { created } = await provisioning.enqueueIntent(pool, id, 'teardown', 'api:self');
420
- if (created) {
433
+ // enqueueTeardown supersedes a stale pending provision intent and reports each
434
+ // outcome distinctly, so "already queued" is only ever said when it is TRUE
435
+ // (task 1002691 — the old blind enqueue lied here and the instance leaked).
436
+ const r = await provisioning.enqueueTeardown(pool, id, 'api:self');
437
+ if (r.conflict) {
438
+ return res.fail('intent_conflict', { status: 409,
439
+ message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
440
+ details: { action: r.intent.action, state: r.intent.state } });
441
+ }
442
+ if (r.created) {
421
443
  await provisioning.recordEvent(pool, {
422
444
  instanceId: id, ownerBuilderId: req.builder.id, event: 'teardown',
423
- detail: 'teardown requested by owner', actor: 'api:self',
445
+ detail: r.superseded ? 'teardown requested by owner (superseded a stale provision intent)' : 'teardown requested by owner',
446
+ actor: 'api:self',
424
447
  }).catch(() => {});
425
448
  }
426
- res.json({ ok: true, queued: created, action: 'teardown', status: inst.status,
427
- message: created ? 'Your instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
449
+ res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
450
+ message: r.created ? 'Your instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
428
451
  } catch (err) {
429
452
  console.error('[provisioning] POST /provisioning/instances/:id/teardown', err);
430
453
  res.fail('teardown_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.59",
3
+ "version": "1.19.60",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.59",
9
+ "version": "1.19.60",
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.59",
3
+ "version": "1.19.60",
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
@@ -545,8 +545,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
545
545
  // landed since 1.19.57 with no explicit bump. run 31546064883. (task 1002620)
546
546
  // 1.19.59 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
547
547
  // landed since 1.19.58 with no explicit bump. run 31547103265. (task 1002620)
548
+ // 1.19.60 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
549
+ // landed since 1.19.59 with no explicit bump. run 31548021746. (task 1002620)
548
550
  // ---------------------------------------------------------------------------
549
- const CORE_VERSION = '1.19.59'; // CI auto-patch carrier (ADR 0161); see changelog above
551
+ const CORE_VERSION = '1.19.60'; // CI auto-patch carrier (ADR 0161); see changelog above
550
552
 
551
553
  // A namespaced logger so a module's log lines are attributable + consistent.
552
554
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,267 @@
1
+ // tests/provisioning_teardown_intent.mjs
2
+ //
3
+ // Teardown vs the action-blind open-intent index (task 1002691). Repro'd live on
4
+ // instance 16: POST /instances re-enqueued a 'provision' intent for an ALREADY-ACTIVE
5
+ // instance (created:false), and the owner's later teardown enqueue collided with that
6
+ // stale open intent on ux_provisioning_intents_open (per-instance, action-blind). The
7
+ // route trusted created:false and answered "A teardown is already queued" — a lie; no
8
+ // teardown was ever recorded and the instance leaked.
9
+ //
10
+ // Covers both layers of the fix:
11
+ // • enqueueTeardown — supersedes a stale PENDING provision intent, never yanks
12
+ // a RUNNING one, and reports each outcome distinctly
13
+ // (fake db that ENFORCES the unique index, so the collision
14
+ // is reproduced faithfully)
15
+ // • the teardown routes — answer truthfully per outcome: "already queued" only when
16
+ // the open intent IS a teardown; a mid-flight provision is a
17
+ // 409 conflict, not a false success
18
+ // • POST /instances — stops enqueueing a provision intent for an instance that
19
+ // needs no standing up (the enabling misstep)
20
+ //
21
+ // DB-free: the pool is never queried — the data-access fns the routes need are stubbed
22
+ // on the cached module object (the provisioning_onboard_mode.mjs pattern). No network.
23
+ //
24
+ // Run: node tests/provisioning_teardown_intent.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { createRequire } from 'node:module';
28
+ import http from 'node:http';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const provisioning = require('../modules/provisioning/provisioning.js');
32
+
33
+ let passed = 0, failed = 0;
34
+ function t(name, fn) {
35
+ try { fn(); passed++; console.log(` PASS ${name}`); }
36
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
37
+ }
38
+ async function ta(name, fn) {
39
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
40
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
41
+ }
42
+
43
+ // ── enqueueTeardown: the collision mechanics, against an index-faithful fake ──────
44
+ // A stateful fake of provisioning_intents that ENFORCES ux_provisioning_intents_open
45
+ // (at most ONE open pending|running intent per instance, action-blind) — so the exact
46
+ // collision the bug rode on is reproduced, not approximated. Dispatches on SQL shape.
47
+ function fakeIntentDb(seed = []) {
48
+ let nextId = 1;
49
+ const rows = seed.map((r) => ({ id: nextId++, requested_by: null, last_error: null, resolved_at: null, ...r }));
50
+ const open = (instanceId) => rows.filter((r) =>
51
+ String(r.instance_id) === String(instanceId) && (r.state === 'pending' || r.state === 'running'));
52
+ return {
53
+ rows,
54
+ query: async (sql, params) => {
55
+ if (/INSERT INTO provisioning_intents/.test(sql)) {
56
+ const [instance_id, action, requested_by] = params;
57
+ if (open(instance_id).length) {
58
+ throw Object.assign(
59
+ new Error('duplicate key value violates unique constraint "ux_provisioning_intents_open"'),
60
+ { code: '23505' });
61
+ }
62
+ const row = { id: nextId++, instance_id, action, requested_by, state: 'pending', last_error: null, resolved_at: null };
63
+ rows.push(row);
64
+ return { rows: [row] };
65
+ }
66
+ if (/UPDATE provisioning_intents/.test(sql) && /state = 'error'/.test(sql)) {
67
+ // The supersede's guard must live in the SQL itself: pending-only (a running
68
+ // provision is the runner's mid-flight work) and provision-only (an open
69
+ // teardown is left alone for the idempotent path).
70
+ assert.match(sql, /action = 'provision' AND state = 'pending'/);
71
+ const hit = rows.filter((r) =>
72
+ String(r.instance_id) === String(params[0]) && r.action === 'provision' && r.state === 'pending');
73
+ for (const r of hit) { r.state = 'error'; r.last_error = 'superseded: teardown requested'; }
74
+ return { rows: hit.map((r) => ({ id: r.id })) };
75
+ }
76
+ if (/SELECT \* FROM provisioning_intents/.test(sql)) {
77
+ const o = open(params[0]);
78
+ return { rows: o.length ? [o[o.length - 1]] : [] };
79
+ }
80
+ throw new Error(`fakeIntentDb: unexpected SQL: ${String(sql).slice(0, 80)}`);
81
+ },
82
+ };
83
+ }
84
+
85
+ console.log('enqueueTeardown — teardown vs the action-blind open-intent index (task 1002691):');
86
+
87
+ await ta('a stale PENDING provision intent is superseded — the teardown actually queues (the leak, fixed)', async () => {
88
+ const db = fakeIntentDb([{ instance_id: 16, action: 'provision', state: 'pending' }]);
89
+ const r = await provisioning.enqueueTeardown(db, 16, 'api:self');
90
+ assert.equal(r.created, true, 'the teardown was queued');
91
+ assert.equal(r.superseded, true, 'reports that a provision intent was canceled');
92
+ assert.equal(r.intent.action, 'teardown');
93
+ assert.equal(r.intent.state, 'pending');
94
+ const prov = db.rows.find((x) => x.action === 'provision');
95
+ assert.equal(prov.state, 'error', 'the stale provision intent is closed, not left open');
96
+ assert.match(prov.last_error, /superseded/);
97
+ });
98
+
99
+ await ta('a RUNNING provision intent is never yanked — a distinct conflict outcome, not a false "already queued"', async () => {
100
+ const db = fakeIntentDb([{ instance_id: 16, action: 'provision', state: 'running' }]);
101
+ const r = await provisioning.enqueueTeardown(db, 16, 'api:self');
102
+ assert.equal(r.created, false);
103
+ assert.equal(r.conflict, 'open_intent', 'the route needs a distinct outcome to answer truthfully');
104
+ assert.equal(r.intent.action, 'provision', 'the blocking intent is surfaced as-is');
105
+ assert.equal(r.intent.state, 'running', 'still running — the mid-flight work was not canceled');
106
+ assert.equal(db.rows.filter((x) => x.action === 'teardown').length, 0, 'no teardown row was inserted');
107
+ });
108
+
109
+ await ta('an already-open TEARDOWN is idempotent — created:false and NO conflict ("already queued" is now true)', async () => {
110
+ const db = fakeIntentDb([{ instance_id: 16, action: 'teardown', state: 'pending' }]);
111
+ const r = await provisioning.enqueueTeardown(db, 16, 'api:self');
112
+ assert.equal(r.created, false);
113
+ assert.equal(r.conflict, undefined, 'no conflict — this is the one genuine already-queued case');
114
+ assert.equal(r.intent.action, 'teardown');
115
+ });
116
+
117
+ await ta('a clean queue just enqueues (no supersede)', async () => {
118
+ const db = fakeIntentDb([]);
119
+ const r = await provisioning.enqueueTeardown(db, 16, 'api:archon:1');
120
+ assert.equal(r.created, true);
121
+ assert.equal(r.superseded, false);
122
+ assert.equal(r.intent.action, 'teardown');
123
+ assert.equal(r.intent.requested_by, 'api:archon:1');
124
+ });
125
+
126
+ t('PROVISION_ENQUEUE_FROM: an active or tearing-down instance never re-enqueues a provision intent', () => {
127
+ assert.equal(provisioning.PROVISION_ENQUEUE_FROM.has('active'), false, 'active — the enabling misstep');
128
+ assert.equal(provisioning.PROVISION_ENQUEUE_FROM.has('tearing_down'), false, 'tearing_down — mid-reclaim');
129
+ for (const s of ['requested', 'provisioning', 'error', 'torn_down']) {
130
+ assert.equal(provisioning.PROVISION_ENQUEUE_FROM.has(s), true, `${s} may (re)provision`);
131
+ }
132
+ });
133
+
134
+ // ── the routes: truthful responses per outcome, over a real mounted router ────────
135
+ let teardownOutcome = null; // what the next enqueueTeardown call reports
136
+ let teardownCalls = [];
137
+ let enqueueIntentCalls = [];
138
+ let createResult = null; // what the next createInstance call reports
139
+ provisioning.getInstanceById = async (_db, id) =>
140
+ ({ id, owner_builder_id: 7, slug: 'spike', status: 'active' });
141
+ provisioning.enqueueTeardown = async (_db, id, requestedBy) => {
142
+ teardownCalls.push({ id, requestedBy });
143
+ return teardownOutcome;
144
+ };
145
+ provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
146
+ enqueueIntentCalls.push({ id, action, requestedBy });
147
+ return { intent: { id: 99, action, state: 'pending' }, created: true };
148
+ };
149
+ provisioning.getOpenIntent = async () => null;
150
+ provisioning.createInstance = async () => createResult;
151
+ provisioning.recordEvent = async () => {};
152
+
153
+ const api = require('../src/module-api.js');
154
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
155
+ api.requirePermission = () => (_req, _res, next) => next();
156
+
157
+ const express = require('express');
158
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
159
+ const app = express();
160
+ app.use(express.json());
161
+ app.use((_req, res, next) => {
162
+ res.fail = (code, opts, details) => {
163
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
164
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
165
+ };
166
+ next();
167
+ });
168
+ app.use('/api/bongos', provisioningRoutes());
169
+
170
+ const server = app.listen(0);
171
+ await new Promise((r) => server.once('listening', r));
172
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
173
+
174
+ function post(path, body = {}) {
175
+ return new Promise((resolve, reject) => {
176
+ const payload = JSON.stringify(body);
177
+ const req = http.request(`${base}${path}`, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
180
+ }, (res) => {
181
+ let d = '';
182
+ res.on('data', (c) => { d += c; });
183
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
184
+ });
185
+ req.on('error', reject);
186
+ req.end(payload);
187
+ });
188
+ }
189
+
190
+ console.log('\nPOST /provisioning/instances/:id/teardown — truthful responses:');
191
+
192
+ await ta('a queued teardown says so (and surfaces that a stale provision was superseded)', async () => {
193
+ teardownOutcome = { intent: { action: 'teardown', state: 'pending' }, created: true, superseded: true };
194
+ const res = await post('/provisioning/instances/16/teardown');
195
+ assert.equal(res.status, 200, res.raw);
196
+ assert.equal(res.body.queued, true);
197
+ assert.equal(res.body.superseded, true);
198
+ assert.match(res.body.message, /torn down/);
199
+ });
200
+
201
+ await ta('"already queued" is said ONLY when the open intent IS a teardown', async () => {
202
+ teardownOutcome = { intent: { action: 'teardown', state: 'pending' }, created: false, superseded: false };
203
+ const res = await post('/provisioning/instances/16/teardown');
204
+ assert.equal(res.status, 200, res.raw);
205
+ assert.equal(res.body.queued, false);
206
+ assert.match(res.body.message, /already queued/);
207
+ });
208
+
209
+ await ta('a mid-flight provision is a 409 conflict — never the false "teardown already queued" (the live repro)', async () => {
210
+ teardownOutcome = { intent: { action: 'provision', state: 'running' }, created: false, superseded: false, conflict: 'open_intent' };
211
+ const res = await post('/provisioning/instances/16/teardown');
212
+ assert.equal(res.status, 409, res.raw);
213
+ assert.equal(res.body.error.code, 'intent_conflict');
214
+ assert.equal(res.body.error.details.action, 'provision', 'names the real blocker');
215
+ assert.doesNotMatch(String(res.body.error.message), /already queued/i, 'the old lie is gone');
216
+ });
217
+
218
+ console.log('\nPOST /provisioning/instances/:id/force-teardown — same truthful mapping (archon):');
219
+
220
+ await ta('force-teardown surfaces the same 409 conflict instead of a false success', async () => {
221
+ teardownOutcome = { intent: { action: 'provision', state: 'running' }, created: false, superseded: false, conflict: 'open_intent' };
222
+ const res = await post('/provisioning/instances/16/force-teardown');
223
+ assert.equal(res.status, 409, res.raw);
224
+ assert.equal(res.body.error.code, 'intent_conflict');
225
+ });
226
+
227
+ await ta('force-teardown queues + reports supersede like the owner route', async () => {
228
+ teardownOutcome = { intent: { action: 'teardown', state: 'pending' }, created: true, superseded: true };
229
+ const res = await post('/provisioning/instances/16/force-teardown');
230
+ assert.equal(res.status, 200, res.raw);
231
+ assert.equal(res.body.queued, true);
232
+ assert.equal(res.body.superseded, true);
233
+ });
234
+
235
+ console.log('\nPOST /provisioning/instances — no provision intent for an instance needing no standup:');
236
+
237
+ await ta('re-requesting an ACTIVE instance does NOT enqueue a provision intent (the enabling misstep, fixed)', async () => {
238
+ createResult = { instance: { id: 42, owner_builder_id: 7, slug: 'spike', status: 'active' }, created: false };
239
+ enqueueIntentCalls = [];
240
+ const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone' });
241
+ assert.equal(res.status, 200, res.raw);
242
+ assert.equal(enqueueIntentCalls.length, 0, 'no provision intent was enqueued');
243
+ assert.equal(res.body.queued, false);
244
+ assert.equal(res.body.open_intent, null);
245
+ });
246
+
247
+ await ta('re-requesting an ERRORED instance still enqueues (the retry path is preserved)', async () => {
248
+ createResult = { instance: { id: 42, owner_builder_id: 7, slug: 'spike', status: 'error' }, created: false };
249
+ enqueueIntentCalls = [];
250
+ const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone' });
251
+ assert.equal(res.status, 200, res.raw);
252
+ assert.equal(enqueueIntentCalls.length, 1, 'the provision retry was enqueued');
253
+ assert.equal(enqueueIntentCalls[0].action, 'provision');
254
+ });
255
+
256
+ await ta('a FRESH instance always enqueues its provision intent (unchanged happy path)', async () => {
257
+ createResult = { instance: { id: 43, owner_builder_id: 7, slug: 'fresh', status: 'requested' }, created: true };
258
+ enqueueIntentCalls = [];
259
+ const res = await post('/provisioning/instances', { slug: 'fresh', hosting_shape: 'standalone' });
260
+ assert.equal(res.status, 201, res.raw);
261
+ assert.equal(enqueueIntentCalls.length, 1);
262
+ assert.equal(enqueueIntentCalls[0].action, 'provision');
263
+ });
264
+
265
+ server.close();
266
+ console.log(`\nprovisioning_teardown_intent: ${passed} passed, ${failed} failed`);
267
+ process.exit(failed ? 1 : 0);