@bongos/core 1.19.772 → 1.19.774
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 +204 -79
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
- package/docs/adr/0177-goal-membership-kind.md +26 -2
- package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
- package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
- package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
- package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +102 -11
- package/docs/api-reference.md +5 -4
- package/docs/copy-inventory.md +205 -190
- package/docs/copy-registry.json +435 -269
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -1
- package/modules/builder-settings/render-prefs.js +281 -33
- package/modules/builder-settings/routes/settings.js +20 -12
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/dev-box/module.json +4 -0
- package/modules/hall-ui/public/settings-render.js +138 -0
- package/modules/hall-ui/public/settings.html +16 -9
- package/modules/hall-ui/public/settings.js +3 -70
- package/modules/lifecycle/db-tasks.js +7 -1
- package/modules/lifecycle/dead-deps.js +10 -2
- package/modules/lifecycle/ship-card.js +49 -13
- package/modules/lifecycle/task-list-limits.js +44 -0
- package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
- package/modules/platform-identity/platform-identity.js +8 -19
- package/modules/platform-identity/project-catalog.js +24 -0
- package/modules/platform-identity/routes/sso.js +11 -14
- package/modules/platform-identity/tests/platform-identity.mjs +7 -9
- package/modules/provisioning/capacity-gate.js +54 -0
- package/modules/provisioning/catalog-bridge.js +2 -0
- package/modules/provisioning/disconnect.js +35 -0
- package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/disconnect.js +63 -0
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/modules/public-landing/public/projects.html +116 -11
- package/modules/public-landing/public/projects.probes.json +3 -0
- package/modules/ui-design/kit/serve.js +3 -2
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/address-park-sweep.sh +78 -0
- package/scripts/gds/audit-deps.js +38 -4
- package/scripts/gds/cli-lib.js +37 -0
- package/scripts/gds/fitness-migration-contraction.js +50 -0
- package/scripts/gds/fitness.js +18 -0
- package/scripts/gds/land-watch.js +8 -2
- package/scripts/gds/provision-config.js +4 -4
- package/scripts/gds/provision-disconnect.js +90 -0
- package/scripts/gds/provision-teardown.js +108 -0
- package/scripts/gds/provision.js +6 -65
- package/scripts/gds/seam-wiring-guard.js +273 -0
- package/scripts/gds/ship-finish.js +17 -10
- package/scripts/gds/ship-flow.js +28 -6
- package/scripts/gds/ship-resume.js +32 -4
- package/scripts/gds/start.js +13 -7
- package/src/bongos/auth-admission.js +7 -8
- package/src/bongos/auth-config.js +3 -2
- package/src/bongos/goal-scope-check.js +23 -8
- package/src/bongos/module-scope-map.js +40 -0
- package/src/module-api.js +1 -1
- package/src/module-loader/manifest-schema.js +79 -1
- package/tests/catalog_only_client.mjs +8 -5
- package/tests/fitness.mjs +22 -0
- package/tests/goal_scope_check.mjs +63 -1
- package/tests/hall_settings_world.mjs +57 -1
- package/tests/hub_disconnect_ui.mjs +105 -0
- package/tests/land_watch.mjs +44 -0
- package/tests/membership_kind_lockstep.mjs +86 -27
- package/tests/module-scope-map.mjs +39 -0
- package/tests/module_exports_unique_keys.mjs +70 -0
- package/tests/module_manifest.mjs +85 -0
- package/tests/project_catalog_forget.mjs +83 -0
- package/tests/projects_hub.mjs +10 -4
- package/tests/projects_hub_pre_uat.mjs +5 -3
- package/tests/provision_disconnect.mjs +231 -0
- package/tests/provision_teardown_address.mjs +230 -0
- package/tests/provisioning_capacity_gate.mjs +192 -0
- package/tests/provisioning_disconnect.mjs +231 -0
- package/tests/provisioning_restart.mjs +5 -5
- package/tests/public_landing_projects.mjs +4 -3
- package/tests/render_prefs.mjs +152 -0
- package/tests/seam_wiring_guard.mjs +234 -0
- package/tests/ship_card.mjs +67 -1
- package/tests/ship_resume.mjs +86 -12
- package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
- package/tests/task_list_paging.mjs +178 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// modules/provisioning/disconnect.js — the queue half of "Disconnect from Bongos
|
|
2
|
+
// entirely" (task 1003249, ADR 0290).
|
|
3
|
+
//
|
|
4
|
+
// A teardown keeps the database, backups and address (ADR 0181); a disconnect erases
|
|
5
|
+
// them and deletes the row, so the name, port and address are reusable. The web tier
|
|
6
|
+
// only enqueues it; scripts/gds/provision-disconnect.js is the runner leg that does it.
|
|
7
|
+
//
|
|
8
|
+
// Its own file because provisioning.js sits at the size ratchet. It reaches the queue
|
|
9
|
+
// through the provisioning module OBJECT, so a test can stub getOpenIntent/enqueueIntent.
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const provisioning = require('./provisioning');
|
|
13
|
+
|
|
14
|
+
// Queue a disconnect. Unlike a teardown it SUPERSEDES NOTHING: any other open intent —
|
|
15
|
+
// even a pending one — refuses it with { conflict: 'open_intent' }, because a
|
|
16
|
+
// destructive run must never race a standup or a teardown that is about to start.
|
|
17
|
+
// An open disconnect makes a repeat idempotent. The partial unique index
|
|
18
|
+
// ux_provisioning_intents_open stays the arbiter if two requests interleave.
|
|
19
|
+
async function enqueueDisconnect(db, instanceId, requestedBy) {
|
|
20
|
+
const open = await provisioning.getOpenIntent(db, instanceId);
|
|
21
|
+
if (open) return open.action === 'disconnect' ? { intent: open, created: false } : { intent: open, created: false, conflict: 'open_intent' };
|
|
22
|
+
const r = await provisioning.enqueueIntent(db, instanceId, 'disconnect', requestedBy);
|
|
23
|
+
if (r.created || r.intent.action === 'disconnect') return r;
|
|
24
|
+
return { ...r, conflict: 'open_intent' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Delete the provisioning row. Its intents, events and OAuth-manifest attempts go with it
|
|
28
|
+
// (ON DELETE CASCADE), including the running disconnect intent itself — which is why the
|
|
29
|
+
// runner resolving that intent afterwards is a harmless no-op. Returns the rows removed.
|
|
30
|
+
async function deleteInstance(db, instanceId) {
|
|
31
|
+
const { rowCount } = await db.query('DELETE FROM provisioning_instances WHERE id = $1', [instanceId]);
|
|
32
|
+
return rowCount;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { enqueueDisconnect, deleteInstance };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- provisioning_016_disconnect.sql — the disconnect intent action (task 1003249,
|
|
2
|
+
-- ADR 0290: "Disconnect from Bongos entirely").
|
|
3
|
+
--
|
|
4
|
+
-- WHY. A teardown deliberately keeps a project's database, backups and address
|
|
5
|
+
-- (ADR 0181), so "Take it offline" is always reversible. The owner asked for a
|
|
6
|
+
-- separate, owner-initiated true delete: tear down, drop the database and its
|
|
7
|
+
-- dumps, retire the catalog + membership rows, and delete the provisioning row so
|
|
8
|
+
-- the name, port and address are reusable. It is destructive, so it runs where
|
|
9
|
+
-- every destructive leg runs — on the control-plane runner, which remains the sole
|
|
10
|
+
-- token-holder (ADR 0111 §2). The web tier only ENQUEUES it.
|
|
11
|
+
--
|
|
12
|
+
-- Widening a CHECK enum is additive (permits MORE values; no row changes) — the
|
|
13
|
+
-- provisioning_003/005/010/011 precedent. Applied only when the provisioning module
|
|
14
|
+
-- is enabled. Safe to re-run. Keep INTENT_ACTIONS in provisioning.js in lockstep.
|
|
15
|
+
|
|
16
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
|
|
17
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
|
|
18
|
+
CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect'));
|
|
19
|
+
|
|
20
|
+
INSERT INTO schema_migrations (version) VALUES ('provisioning_016_disconnect')
|
|
21
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"maintainer": "Off the Boats maintainers",
|
|
11
11
|
"maintenance": { "status": "core-maintained" },
|
|
12
12
|
"contributes": {
|
|
13
|
-
"routes": ["provisioning"],
|
|
13
|
+
"routes": ["provisioning", "disconnect"],
|
|
14
14
|
"migrations": true,
|
|
15
15
|
"pollers": ["liveness-sweep", "catalog-backfill"]
|
|
16
16
|
},
|
|
@@ -58,7 +58,8 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
|
|
|
58
58
|
// instance whose liveness probe says it is down — the settings-apply leg minus the
|
|
59
59
|
// env patch. Its own action because no existing one fits: provision refuses
|
|
60
60
|
// 'active' and settings-apply requires a value that actually changed.
|
|
61
|
-
|
|
61
|
+
// 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
|
|
62
|
+
const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect'];
|
|
62
63
|
// Every state a teardown can be asked for — i.e. everything except the one
|
|
63
64
|
// state where there is provably nothing left to release. DERIVED from
|
|
64
65
|
// INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
|
|
@@ -1489,8 +1490,7 @@ module.exports = {
|
|
|
1489
1490
|
listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
|
|
1490
1491
|
// data access — the owed-push marker (task 1003524)
|
|
1491
1492
|
recordSettingsPushOwed,
|
|
1492
|
-
createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
|
|
1493
|
-
createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
|
|
1493
|
+
createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings, updateInstanceDetail,
|
|
1494
1494
|
enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
|
|
1495
1495
|
recordEvent, instanceStateForSlug, planetChoicesForHosts, projectOwnedBy, costLedger,
|
|
1496
1496
|
// data access — GitHub App Manifest attempts (task 2080)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// modules/provisioning/routes/disconnect.js — POST /provisioning/instances/:id/disconnect
|
|
2
|
+
// (task 1003249, ADR 0290: "Disconnect from Bongos entirely").
|
|
3
|
+
//
|
|
4
|
+
// The web tier's half of the owner's true delete. Like every provisioning action it only
|
|
5
|
+
// ENQUEUES; the control-plane runner does the destruction (ADR 0111 §2). Its own routes
|
|
6
|
+
// file (declared in module.json) because routes/provisioning.js sits at the size ratchet.
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const express = require('express');
|
|
10
|
+
const api = require('../../../src/module-api');
|
|
11
|
+
const { pool, validateOrRespond, parseId } = api;
|
|
12
|
+
const provisioning = require('../provisioning');
|
|
13
|
+
const disconnect = require('../disconnect');
|
|
14
|
+
|
|
15
|
+
const log = api.logger('provisioning');
|
|
16
|
+
|
|
17
|
+
module.exports = function buildDisconnectRouter() {
|
|
18
|
+
const router = express.Router();
|
|
19
|
+
|
|
20
|
+
// POST /provisioning/instances/:id/disconnect — rank: any authenticated builder (own
|
|
21
|
+
// resource); an archon may disconnect any project. 404 for anyone else, so another
|
|
22
|
+
// owner's id is never confirmed. The typed name is checked HERE, not only in the page:
|
|
23
|
+
// this erases a database, so a replayed or scripted request must carry the same proof
|
|
24
|
+
// the button does. No status gate — any state may be disconnected; the queue refuses
|
|
25
|
+
// while another run is open (disconnect.enqueueDisconnect).
|
|
26
|
+
router.post('/provisioning/instances/:id/disconnect', api.requireBuilder, async (req, res) => {
|
|
27
|
+
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
28
|
+
if (!id) return;
|
|
29
|
+
if (validateOrRespond(req, res, { confirm_slug: { required: true, type: 'string', maxLength: 40 } })) return;
|
|
30
|
+
try {
|
|
31
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
32
|
+
const isOwner = !!inst && String(inst.owner_builder_id) === String(req.builder.id);
|
|
33
|
+
if (!inst || (!isOwner && req.builder.rank !== 'archon')) return res.fail('instance_not_found', 404);
|
|
34
|
+
if (String(req.body.confirm_slug).trim().toLowerCase() !== inst.slug) {
|
|
35
|
+
return res.fail('confirm_mismatch', { status: 400,
|
|
36
|
+
message: `Type the project's name exactly — ${inst.slug} — to disconnect it. Nothing was changed.` });
|
|
37
|
+
}
|
|
38
|
+
const actor = isOwner ? 'api:self' : `api:archon:${req.builder.id}`;
|
|
39
|
+
const r = await disconnect.enqueueDisconnect(pool, id, actor);
|
|
40
|
+
if (r.conflict) {
|
|
41
|
+
return res.fail('intent_conflict', { status: 409,
|
|
42
|
+
message: `A '${r.intent.action}' run is ${r.intent.state} for this project. Disconnecting waits for it to finish — try again once it has.`,
|
|
43
|
+
details: { action: r.intent.action, state: r.intent.state } });
|
|
44
|
+
}
|
|
45
|
+
if (r.created) {
|
|
46
|
+
await provisioning.recordEvent(pool, {
|
|
47
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'disconnect',
|
|
48
|
+
detail: isOwner ? 'disconnect requested by owner' : `disconnect requested by archon ${req.builder.github_login || req.builder.id}`,
|
|
49
|
+
actor,
|
|
50
|
+
}).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
res.json({ ok: true, queued: r.created, action: 'disconnect', status: inst.status,
|
|
53
|
+
message: r.created
|
|
54
|
+
? 'Queued. The platform takes the project down, erases its database and backups, and releases its name and address. It leaves your list when that is done.'
|
|
55
|
+
: 'A disconnect is already queued for this project.' });
|
|
56
|
+
} catch (err) {
|
|
57
|
+
log.error('[provisioning] POST /provisioning/instances/:id/disconnect', err);
|
|
58
|
+
res.fail('disconnect_failed', { status: 500, message: 'internal error' });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return router;
|
|
63
|
+
};
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// only shape that bills; ../paid-shape-gate.js.
|
|
18
18
|
// An `adopt` ALSO passes the credential
|
|
19
19
|
// preflight — ../credential-preflight.js,
|
|
20
|
-
// task 1002909; greenfield is exempt)
|
|
20
|
+
// task 1002909; greenfield is exempt; a full box → 409, ../capacity-gate.js)
|
|
21
21
|
// GET /provisioning/instances — list the caller's instances
|
|
22
22
|
// GET /provisioning/instances/:id — read one of the caller's instances
|
|
23
23
|
// (also SELF-HEALS its catalog projection,
|
|
@@ -51,6 +51,7 @@ const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModu
|
|
|
51
51
|
const { recommendationsForDetails } = require('../recommendations');
|
|
52
52
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
53
53
|
const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
|
|
54
|
+
const { requireRoomOnBox } = require('../capacity-gate'); // a full box refused up front (task 1003938)
|
|
54
55
|
const { adoptCredentialFault } = require('../credential-preflight');
|
|
55
56
|
const catalogBridge = require('../catalog-bridge');
|
|
56
57
|
const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
|
|
@@ -299,10 +300,9 @@ module.exports = function provisioningRoutes() {
|
|
|
299
300
|
});
|
|
300
301
|
|
|
301
302
|
// POST /provisioning/instances — request a new instance. rank: any authenticated
|
|
302
|
-
// builder (own resource); a `dedicated` (metered) shape
|
|
303
|
-
//
|
|
304
|
-
// ENQUEUES a 'provision' intent
|
|
305
|
-
// the runner (task P3) does the real standup. Idempotent on slug for the SAME
|
|
303
|
+
// builder (own resource); a `dedicated` (metered) shape also needs provisioning.fleet.manage
|
|
304
|
+
// (../paid-shape-gate.js), and a full shared box answers 409 box_full up front (../capacity-gate.js).
|
|
305
|
+
// ENQUEUES a 'provision' intent the runner (task P3) stands up. Idempotent on slug for the SAME
|
|
306
306
|
// owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409.
|
|
307
307
|
// Body: { slug (required), target_ref?, hosting_shape? (co-tenant|dedicated),
|
|
308
308
|
// domain?, tier?, onboard_mode? (greenfield|adopt) }.
|
|
@@ -312,7 +312,7 @@ module.exports = function provisioningRoutes() {
|
|
|
312
312
|
// ALREADY has, so the runner must clone + layer onto its history instead of pushing a
|
|
313
313
|
// fresh tree at `main` (which an existing repo rejects as unrelated history). Absent →
|
|
314
314
|
// 'greenfield', the safe default.
|
|
315
|
-
router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForMeteredShape(auth.requirePermission), async (req, res) => {
|
|
315
|
+
router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForMeteredShape(auth.requirePermission), requireRoomOnBox(pool, log), async (req, res) => {
|
|
316
316
|
if (validateOrRespond(req, res, {
|
|
317
317
|
slug: { required: true, type: 'string', maxLength: 40 },
|
|
318
318
|
target_ref: { type: 'string', maxLength: 200 },
|
|
@@ -939,7 +939,21 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
939
939
|
than letting the word "Delete" imply it. -->
|
|
940
940
|
<details class="finePrint" id="mDelWhat">
|
|
941
941
|
<summary>What this removes — and what it keeps</summary>
|
|
942
|
-
<p>Tearing down stops the project’s server and its nightly backups. It does <b>not</b> erase the project: it stays in your list marked <b>torn down</b> so you can bring it back, your repository on GitHub is untouched, and its database and existing backups are kept on purpose.
|
|
942
|
+
<p>Tearing down stops the project’s server and its nightly backups. It does <b>not</b> erase the project: it stays in your list marked <b>torn down</b> so you can bring it back, your repository on GitHub is untouched, and its database and existing backups are kept on purpose. To erase it for good — its database, its backups and its web address — use <b>Disconnect from Bongos entirely</b> below.</p>
|
|
943
|
+
</details>
|
|
944
|
+
</div>
|
|
945
|
+
<!-- "Disconnect from Bongos entirely" (task 1003249, ADR 0290): the owner's TRUE
|
|
946
|
+
delete, in its own card after the ender so the reversible control never reads
|
|
947
|
+
as the destructive one. The fold is a sibling of the volatile slot, like
|
|
948
|
+
#mDelWhat, so no re-render can drop what it promises. -->
|
|
949
|
+
<div class="card mSec" id="mDisconnect">
|
|
950
|
+
<h2>Disconnect from Bongos entirely</h2>
|
|
951
|
+
<div id="mDisc"><p class="finePrint">Reading the project…</p></div>
|
|
952
|
+
<details class="finePrint" id="mDiscWhat">
|
|
953
|
+
<summary>What this erases — and what it keeps</summary>
|
|
954
|
+
<p><b>Erased for good:</b> the project’s server, its database and every nightly backup of it, its settings, its place on the map and the list of its members. Its name and web address are released, so another project can take them.</p>
|
|
955
|
+
<p><b>Kept:</b> your repository on GitHub, exactly as it is. If a GitHub sign-in app was registered for the project, that stays on your GitHub account too — remove it in your GitHub settings if you no longer want it.</p>
|
|
956
|
+
<p>This cannot be undone. To stop the project and keep everything, use <b>Take it offline</b> above instead.</p>
|
|
943
957
|
</details>
|
|
944
958
|
</div>
|
|
945
959
|
</section>
|
|
@@ -5196,21 +5210,19 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5196
5210
|
retention is deliberate on shared boxes; a dedicated machine takes its
|
|
5197
5211
|
database with it). Copy follows the runner, never the other way around. */
|
|
5198
5212
|
function teardownTruth(inst) {
|
|
5199
|
-
/* the address line is NOT decoration
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
both the honest word and the accurate one, since a revive re-uses exactly
|
|
5205
|
-
this hostname. */
|
|
5213
|
+
/* the address line is NOT decoration. Teardown now parks the hostname at a
|
|
5214
|
+
dead address and removes its proxy block (task 1002897), so nothing answers
|
|
5215
|
+
there — but the row keeps the address, because a revive re-uses exactly
|
|
5216
|
+
this hostname. "Reserved" is the accurate word for that; "gone" would not be,
|
|
5217
|
+
and neither is the old error-page promise, true only before the park. */
|
|
5206
5218
|
/* pre-standup: at 'requested' nothing has been built, so the shape arms
|
|
5207
5219
|
below would describe a server and a database that never existed. error
|
|
5208
5220
|
and provisioning keep the built-world text on purpose — a run that failed
|
|
5209
5221
|
partway may already have a database, a port and a DNS record. */
|
|
5210
5222
|
if (inst && inst.status === 'requested') return 'Nothing has been stood up yet — this cancels the standup. Anything already on GitHub is untouched, and the name and web address stay reserved for this project.';
|
|
5211
5223
|
return inst && inst.hosting_shape === 'dedicated'
|
|
5212
|
-
? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched. Its web address stays reserved for this project and
|
|
5213
|
-
: 'The server goes away. Your repository is untouched. The database is kept, so bringing it back later restores its memory. Its web address stays reserved for this project and
|
|
5224
|
+
? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched. Its web address stays reserved for this project, and nothing answers there until you bring it back. Taking it offline erases nothing.'
|
|
5225
|
+
: 'The server goes away. Your repository is untouched. The database is kept, so bringing it back later restores its memory. Its web address stays reserved for this project, and nothing answers there until you bring it back. Taking it offline erases nothing.';
|
|
5214
5226
|
}
|
|
5215
5227
|
|
|
5216
5228
|
/* onManage: the same facts, but the pointers name the controls THIS surface
|
|
@@ -5224,7 +5236,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5224
5236
|
"nothing more will happen" (task 1003232) */
|
|
5225
5237
|
if (inst.status === 'torn_down' || inst.status === 'tearing_down') {
|
|
5226
5238
|
if (!inst.domain) return '';
|
|
5227
|
-
return 'Its address, <code>' + esc(inst.domain) + '</code>, stays reserved for this project —
|
|
5239
|
+
return 'Its address, <code>' + esc(inst.domain) + '</code>, stays reserved for this project — nothing answers there until you bring it back.';
|
|
5228
5240
|
}
|
|
5229
5241
|
if (inst.domain) {
|
|
5230
5242
|
if (inst.status === 'active') {
|
|
@@ -5553,6 +5565,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5553
5565
|
it: the delete section reads only status + open_intent, both already in
|
|
5554
5566
|
hand, and a queued teardown must show up without a reload */
|
|
5555
5567
|
renderDelete(inst);
|
|
5568
|
+
renderDisconnect(inst);
|
|
5556
5569
|
/* its address (task 1003355) is fetch-free too — the control it carries
|
|
5557
5570
|
reads status + domain + open_intent, all already in hand */
|
|
5558
5571
|
renderAddress(inst);
|
|
@@ -7026,6 +7039,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7026
7039
|
'<p class="finePrint">This page updates when it starts.</p>');
|
|
7027
7040
|
return;
|
|
7028
7041
|
}
|
|
7042
|
+
/* a queued disconnect takes the project offline itself; a teardown ask here would
|
|
7043
|
+
only be refused by the server's open-run check (task 1003249) */
|
|
7044
|
+
if (inst.open_intent && inst.open_intent.action === 'disconnect') {
|
|
7045
|
+
delMsg('<p class="finePrint">A disconnect is queued for this project — taking it offline is part of it.</p>');
|
|
7046
|
+
return;
|
|
7047
|
+
}
|
|
7029
7048
|
/* a project that never finished standing up still reaches here — that is
|
|
7030
7049
|
the whole point of the widened gate; teardownTruth() scopes the copy to
|
|
7031
7050
|
its status */
|
|
@@ -7116,6 +7135,92 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
7116
7135
|
if (t.hasAttribute('data-del-no')) { mDelAsking = null; if (manageInst) renderDelete(manageInst); }
|
|
7117
7136
|
});
|
|
7118
7137
|
|
|
7138
|
+
/* ── "Disconnect from Bongos entirely" (task 1003249, ADR 0290): the TRUE delete. The
|
|
7139
|
+
ender's shape on purpose — the name typed back (and checked again by the SERVER, which
|
|
7140
|
+
receives it as confirm_slug), a project-scoped guard so the minute tick never repaints
|
|
7141
|
+
over a half-typed name, and every refusal in the server's own words. ── */
|
|
7142
|
+
var mDiscAsking = null;
|
|
7143
|
+
|
|
7144
|
+
function discMsg(html) { $('mDisc').innerHTML = html; }
|
|
7145
|
+
|
|
7146
|
+
function renderDisconnect(inst) {
|
|
7147
|
+
if (mDiscAsking && mDiscAsking === inst.slug) return;
|
|
7148
|
+
var open = inst.open_intent;
|
|
7149
|
+
if (open && open.action === 'disconnect') {
|
|
7150
|
+
discMsg('<div class="addr">This project is being disconnected.</div>' +
|
|
7151
|
+
'<p class="finePrint">When it finishes, the project leaves your list and this page can no longer find it.</p>');
|
|
7152
|
+
return;
|
|
7153
|
+
}
|
|
7154
|
+
if (open) {
|
|
7155
|
+
discMsg('<p class="plain">Disconnecting waits for the current ' + esc(open.action || '') + ' run to finish.</p>');
|
|
7156
|
+
return;
|
|
7157
|
+
}
|
|
7158
|
+
discMsg('<p class="plain">Erase this project from the platform for good: its database and backups, its settings and its place on the map. Your repository on GitHub is kept.</p>' +
|
|
7159
|
+
'<div class="actions"><button type="button" class="btn small danger" data-disc-ask>Disconnect entirely</button></div>');
|
|
7160
|
+
}
|
|
7161
|
+
|
|
7162
|
+
function askDisconnect() {
|
|
7163
|
+
var inst = manageInst;
|
|
7164
|
+
if (!inst) return;
|
|
7165
|
+
mDiscAsking = inst.slug;
|
|
7166
|
+
discMsg('<div class="confirmRow"><span class="plain">Disconnect <b>' + esc(inst.slug) + '</b> from Bongos entirely? ' +
|
|
7167
|
+
'Its database and backups are erased and this cannot be undone. Type the project’s name to confirm.</span></div>' +
|
|
7168
|
+
'<div class="attach">' +
|
|
7169
|
+
'<input type="text" id="mDiscName" autocomplete="off" spellcheck="false" maxlength="40" ' +
|
|
7170
|
+
'aria-label="Type the project name to confirm the disconnect" placeholder="' + esc(inst.slug) + '">' +
|
|
7171
|
+
'<button type="button" class="btn small danger" data-disc-yes disabled>Yes, disconnect it</button>' +
|
|
7172
|
+
'<button type="button" class="linklike" data-disc-no>Keep it</button></div>');
|
|
7173
|
+
var field = $('mDiscName');
|
|
7174
|
+
if (field) field.focus();
|
|
7175
|
+
}
|
|
7176
|
+
|
|
7177
|
+
function discTypedMatches() {
|
|
7178
|
+
var field = $('mDiscName');
|
|
7179
|
+
return !!(field && manageInst && field.value.trim() === manageInst.slug);
|
|
7180
|
+
}
|
|
7181
|
+
|
|
7182
|
+
function doDisconnect() {
|
|
7183
|
+
var inst = manageInst;
|
|
7184
|
+
if (!inst || !discTypedMatches()) return;
|
|
7185
|
+
var typed = $('mDiscName').value.trim();
|
|
7186
|
+
mDiscAsking = null;
|
|
7187
|
+
discMsg('<p class="finePrint">Asking…</p>');
|
|
7188
|
+
fetch(API + '/provisioning/instances/' + encodeURIComponent(inst.id) + '/disconnect', {
|
|
7189
|
+
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
7190
|
+
body: JSON.stringify({ confirm_slug: typed })
|
|
7191
|
+
}).then(function (res) {
|
|
7192
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
7193
|
+
if (res.ok) {
|
|
7194
|
+
discMsg('<div class="projMsg ok">' + esc(data.message || 'Disconnect requested.') + '</div>');
|
|
7195
|
+
loadManage(true); /* the queued run this page renders just changed */
|
|
7196
|
+
return;
|
|
7197
|
+
}
|
|
7198
|
+
renderDisconnect(inst);
|
|
7199
|
+
var e = apiError(data, res.status);
|
|
7200
|
+
$('mDisc').insertAdjacentHTML('beforeend', '<div class="projMsg err">' +
|
|
7201
|
+
(res.status === 401 ? signInAgain() : esc(e.message || 'The disconnect could not be queued — try again.')) + '</div>');
|
|
7202
|
+
});
|
|
7203
|
+
}).catch(function () {
|
|
7204
|
+
renderDisconnect(inst);
|
|
7205
|
+
$('mDisc').insertAdjacentHTML('beforeend',
|
|
7206
|
+
'<div class="projMsg err">Couldn’t reach the platform — check your connection and try again.</div>');
|
|
7207
|
+
});
|
|
7208
|
+
}
|
|
7209
|
+
|
|
7210
|
+
$('mDisc').addEventListener('input', function (ev) {
|
|
7211
|
+
if (!ev.target || ev.target.id !== 'mDiscName') return;
|
|
7212
|
+
var yes = $('mDisc').querySelector('[data-disc-yes]');
|
|
7213
|
+
if (yes) yes.disabled = !discTypedMatches();
|
|
7214
|
+
});
|
|
7215
|
+
|
|
7216
|
+
$('mDisc').addEventListener('click', function (ev) {
|
|
7217
|
+
var t = ev.target.closest && ev.target.closest('button');
|
|
7218
|
+
if (!t) return;
|
|
7219
|
+
if (t.hasAttribute('data-disc-ask')) return askDisconnect();
|
|
7220
|
+
if (t.hasAttribute('data-disc-yes')) return doDisconnect();
|
|
7221
|
+
if (t.hasAttribute('data-disc-no')) { mDiscAsking = null; if (manageInst) renderDisconnect(manageInst); }
|
|
7222
|
+
});
|
|
7223
|
+
|
|
7119
7224
|
/* ── restart an unreachable project (task 1003238). Its own route, because
|
|
7120
7225
|
PROVISION_ENQUEUE_FROM excludes 'active' — re-POSTing the slug the way
|
|
7121
7226
|
Try again / Bring it back do would be refused on every press. Every
|
|
@@ -155,6 +155,9 @@
|
|
|
155
155
|
{ "name": "manage flows: the ender asks for the name typed back, queues the teardown, and the page re-reads the project as queued", "auth": true, "width": 1440, "url": "/projects?view=manage&id=105",
|
|
156
156
|
"steps": [["wait",900],["click","[data-del-ask]"],["wait",200],["fill","#mDelName","mooring"],["wait",100],["click","[data-del-yes]"],["wait",900]],
|
|
157
157
|
"expect": {"text":[["#mDel .addr","A teardown is queued for this project."],["#mName","mooring"]],"hidden":["#mDelName","[data-del-ask]"],"visible":["#mStrip .strip.flowDown"]} },
|
|
158
|
+
{ "name": "manage flows: disconnect entirely asks for the name typed back, queues the true delete, and the page re-reads the project as being disconnected (task 1003249)", "auth": true, "width": 1440, "url": "/projects?view=manage&id=104",
|
|
159
|
+
"steps": [["wait",900],["click","[data-disc-ask]"],["wait",200],["fill","#mDiscName","old-lantern"],["wait",100],["click","[data-disc-yes]"],["wait",900]],
|
|
160
|
+
"expect": {"text":[["#mDisc .addr","This project is being disconnected."],["#mName","old-lantern"]],"hidden":["#mDiscName","[data-disc-ask]"]} },
|
|
158
161
|
{ "name": "the wizard driver (task 1003346): name, type, about, home (fresh; the stub grants repo access, so the new repository is named from the project), address (the handle's verdict), the review as a ledger, then the filing — the done panel with the standup rail and the human steps", "auth": true, "width": 1440, "url": "/projects?view=new",
|
|
159
162
|
"steps": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500]],
|
|
160
163
|
"expect": {"visible":["#panel-done","#liveProgress .prog","#liveProgress .prog .stages li.now","#humanSteps li"],"hidden":["#panel-review","#panel-name"],"text":[["#done-h","The drums are rolling"]],"attr":[["#panel-done","class","panel on"]]} },
|
|
@@ -157,6 +157,7 @@ function stamp(v) {
|
|
|
157
157
|
const FLOW_RECEIPTS = {
|
|
158
158
|
teardown: { queued: true, message: 'Teardown queued.' },
|
|
159
159
|
restart: { queued: true, message: 'Restart queued.' },
|
|
160
|
+
disconnect: { queued: true, message: 'Queued. The platform takes the project down, erases its database and backups, and releases its name and address. It leaves your list when that is done.' },
|
|
160
161
|
domain: { recorded: true, queued: true, message: 'Address recorded.' },
|
|
161
162
|
// the wizard's filing (task 1003346): the receipt names the instance the page then polls — Harbor, the fixture's standing-up row — and the address the page promises is the one the server assigned
|
|
162
163
|
provision: { queued: true, message: 'Queued. The platform picks it up from here.', instance: { id: 102, domain: 'mercury.cloudbongos.com' } },
|
|
@@ -220,10 +221,10 @@ function withPlanet(inst) {
|
|
|
220
221
|
}
|
|
221
222
|
function flowReceipt(rest, method) {
|
|
222
223
|
if (method === 'POST' && rest === '/provisioning/instances') return FLOW_RECEIPTS.provision;
|
|
223
|
-
const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(teardown|restart|domain)$/);
|
|
224
|
+
const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(teardown|restart|domain|disconnect)$/);
|
|
224
225
|
if (!m) return null;
|
|
225
226
|
if (method !== 'POST') return null;
|
|
226
|
-
if (m[2] === 'teardown' || m[2] === 'restart') ASKED.set(m[1], m[2]);
|
|
227
|
+
if (m[2] === 'teardown' || m[2] === 'restart' || m[2] === 'disconnect') ASKED.set(m[1], m[2]);
|
|
227
228
|
return FLOW_RECEIPTS[m[2]];
|
|
228
229
|
}
|
|
229
230
|
function withAsk(inst) {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"default": true,
|
|
7
7
|
"maintenance": { "status": "core-maintained" },
|
|
8
8
|
"contributes": { "skills": ["design", "design-sync", "figma-design-sync", "design-taste-frontend", "design-taste-frontend-v1", "high-end-visual-design", "redesign-existing-projects", "impeccable", "style", "minimalist-ui", "industrial-brutalist-ui", "gpt-taste", "stitch-design-taste", "image-to-code", "imagegen-frontend-web", "imagegen-frontend-mobile", "brandkit"], "disciplines": ["ui"] },
|
|
9
|
+
"declarativeSeams": { "skills": ["design", "design-sync", "figma-design-sync"] },
|
|
9
10
|
"provides": [],
|
|
10
11
|
"consumes": []
|
|
11
12
|
}
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.774",
|
|
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.774",
|
|
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.774",
|
|
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",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/gds/address-park-sweep.sh — park the addresses of projects torn down BEFORE
|
|
3
|
+
# teardown learned to do it itself (task 1003947, the backfill half of task 1002897).
|
|
4
|
+
#
|
|
5
|
+
# WHY. Task 1002897 made a teardown remove its <slug>.caddy block and park its hostname
|
|
6
|
+
# at the RFC 5737 blackhole. That only applies to teardowns run after the pin carries it,
|
|
7
|
+
# so every earlier row still has its Caddy block proxying to a dead port and its A record
|
|
8
|
+
# pointing at the platform: eleven hostnames answered 502 on 2026-09-16, while the hub
|
|
9
|
+
# copy says "nothing answers there". This is that same teardown step, run once over the
|
|
10
|
+
# ledger's torn-down rows. It does not re-run `provision.js teardown <slug>`, which would
|
|
11
|
+
# rewrite each row's torn_down_at.
|
|
12
|
+
#
|
|
13
|
+
# The reserved-hostname rule, the zone and the apex come from the INSTALLED core (the
|
|
14
|
+
# same helpers a teardown calls), so this sweep cannot disagree with what teardown parks.
|
|
15
|
+
#
|
|
16
|
+
# Run on the control plane as a sudoer:
|
|
17
|
+
# bash scripts/gds/address-park-sweep.sh # survey only — read-only
|
|
18
|
+
# bash scripts/gds/address-park-sweep.sh --apply # remove the snippets, reload Caddy, park the hostnames
|
|
19
|
+
#
|
|
20
|
+
# Left in place on purpose, even with --apply: each project's database, dumps and checkout,
|
|
21
|
+
# and the row's domain + port (ADR 0181 — a revive re-uses that address).
|
|
22
|
+
set -uo pipefail
|
|
23
|
+
|
|
24
|
+
APPLY=0; [ "${1:-}" = "--apply" ] && APPLY=1
|
|
25
|
+
DB="${PGDATABASE:-cloudbongos}"
|
|
26
|
+
INSTANCE_DIR="${INSTANCE_DIR:-/home/lars/cloudbongos-instance}"
|
|
27
|
+
SITES="${PROVISION_CADDY_SNIPPET_DIR:-/etc/caddy/sites}"
|
|
28
|
+
BOX_ENV="${BOX_ENV:-/etc/cloudbongos/box.env}"
|
|
29
|
+
|
|
30
|
+
rows=$(sudo -n -u lars psql -d "$DB" -tAF '|' -c "select slug, coalesce(domain, '') from provisioning_instances where status='torn_down' order by slug" 2>/dev/null) \
|
|
31
|
+
|| { echo "cannot read provisioning_instances from $DB as lars"; exit 1; }
|
|
32
|
+
|
|
33
|
+
# slug|domain|zone|reserved|blackhole per row, plus an apex line — from the installed core.
|
|
34
|
+
plan=$(sudo -n -u lars env ROWS="$rows" bash -c 'cd "$1" && exec node -' _ "$INSTANCE_DIR" <<'JS'
|
|
35
|
+
const core = './node_modules/@bongos/core';
|
|
36
|
+
const { isReservedHostname } = require(`${core}/modules/provisioning/provisioning.js`);
|
|
37
|
+
const { dnsReleaseEnv } = require(`${core}/scripts/gds/provision-net.js`);
|
|
38
|
+
const apex = require(`${core}/scripts/gds/instance-apex.js`).instanceApex();
|
|
39
|
+
console.log(`apex|${apex || ''}`);
|
|
40
|
+
for (const line of String(process.env.ROWS || '').split('\n').filter(Boolean)) {
|
|
41
|
+
const [slug, domain] = line.split('|');
|
|
42
|
+
const env = domain ? dnsReleaseEnv(domain) : { CLOUDFLARE_ZONE: '', BOX_IP: '' };
|
|
43
|
+
console.log(['row', slug, domain || '', env.CLOUDFLARE_ZONE, domain && isReservedHostname(domain, apex) ? 1 : 0, env.BOX_IP].join('|'));
|
|
44
|
+
}
|
|
45
|
+
JS
|
|
46
|
+
) || { echo "cannot load the installed core from $INSTANCE_DIR"; exit 1; }
|
|
47
|
+
|
|
48
|
+
apex=$(printf '%s\n' "$plan" | awk -F'|' '$1 == "apex" {print $2}')
|
|
49
|
+
[ -n "$apex" ] || { echo "the installed core names no apex — refusing: the reserved-hostname guard would be blind"; exit 1; }
|
|
50
|
+
echo "== torn-down rows still holding a Caddy block or an unparked address (apex $apex) =="
|
|
51
|
+
|
|
52
|
+
left=(); reload=0
|
|
53
|
+
while IFS='|' read -r kind slug domain zone reserved blackhole; do
|
|
54
|
+
[ "$kind" = "row" ] || continue
|
|
55
|
+
# The slug grammar provisioning.isValidSlug enforces; anything else never reaches a command.
|
|
56
|
+
[[ "$slug" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || continue
|
|
57
|
+
if [ -e "$SITES/$slug.caddy" ]; then
|
|
58
|
+
printf ' %-22s snippet %s\n' "$slug" "$SITES/$slug.caddy"
|
|
59
|
+
left+=("$slug:snippet")
|
|
60
|
+
if [ "$APPLY" = 1 ]; then sudo -n rm -f "$SITES/$slug.caddy" </dev/null && reload=1; fi
|
|
61
|
+
fi
|
|
62
|
+
[ -n "$domain" ] || continue
|
|
63
|
+
if [ "$reserved" = 1 ]; then printf ' %-22s %s is platform-reserved — never parked\n' "$slug" "$domain"; continue; fi
|
|
64
|
+
now=$(getent ahostsv4 "$domain" 2>/dev/null </dev/null | awk 'NR == 1 {print $1}')
|
|
65
|
+
[ -z "$now" ] || [ "$now" = "$blackhole" ] && continue
|
|
66
|
+
printf ' %-22s %s resolves to %s — park at %s (zone %s)\n' "$slug" "$domain" "$now" "$blackhole" "$zone"
|
|
67
|
+
left+=("$slug:dns")
|
|
68
|
+
[ "$APPLY" = 1 ] || continue
|
|
69
|
+
sudo -n -u lars bash -c 'set -a; . "$1"; set +a; BOX_HOSTNAME="$2" BOX_IP="$3" CLOUDFLARE_ZONE="$4" BOX_PROXIED=0 exec "$BOX_DNS_HOOK"' \
|
|
70
|
+
_ "$BOX_ENV" "$domain" "$blackhole" "$zone" </dev/null || echo " ⚠ could not park $domain — re-run to retry"
|
|
71
|
+
done <<< "$plan"
|
|
72
|
+
|
|
73
|
+
[ "$reload" = 1 ] && { sudo -n systemctl reload caddy || echo " ⚠ caddy reload failed — the removed blocks are still loaded"; }
|
|
74
|
+
|
|
75
|
+
echo "== summary =="
|
|
76
|
+
if [ "$APPLY" = 1 ]; then echo " acted on (${#left[@]}): ${left[*]:-none}"; else echo " left (${#left[@]}): ${left[*]:-none}"; fi
|
|
77
|
+
if [ "$APPLY" = 1 ]; then echo " (applied — re-run without --apply to confirm nothing is left; DNS may take a TTL to settle)";
|
|
78
|
+
else echo " (survey only — re-run with --apply to remove the snippets, reload Caddy and park the hostnames)"; fi
|
|
@@ -25,6 +25,8 @@ const { resolveDefaultVersion } = require('./version-select.js');
|
|
|
25
25
|
// task 1003153 — the DEAD-edge detector core, shared with the claim refusal so
|
|
26
26
|
// "abandoned dependency" means one thing across the CLI, the route and this audit.
|
|
27
27
|
const { findStrandedTasks } = require('../../modules/lifecycle/dead-deps.js');
|
|
28
|
+
// task 1003752 — the route's page bound + the first-page offset rule, declared once.
|
|
29
|
+
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
|
|
28
30
|
|
|
29
31
|
// Lifecycle statuses that count as "open" (auditable) vs "an unshipped blocker".
|
|
30
32
|
const OPEN = new Set(['ready', 'backlog', 'active', 'blocked']);
|
|
@@ -81,6 +83,34 @@ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker }
|
|
|
81
83
|
return out;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// Read the WHOLE task corpus, one allowed page at a time (task 1003752). This
|
|
87
|
+
// was a single `limit: 3000` read, which GET /tasks caps at TASK_LIST_MAX_LIMIT
|
|
88
|
+
// (1000) — so it 400'd on every run and the detector below never saw a single
|
|
89
|
+
// task. Paging is not a nicety here: the corpus passed one page long ago (3610
|
|
90
|
+
// rows on 2026-09-16), so even a legal `limit: 1000` would silently UNDER-read,
|
|
91
|
+
// which in a detector is the same failure wearing a success exit code.
|
|
92
|
+
//
|
|
93
|
+
// `getPage(query)` is injected rather than closed over so the loop is testable
|
|
94
|
+
// with no API; it returns the { ok, status, data } shape cliClient gives back.
|
|
95
|
+
// Dedupes by id: listTasks orders by `priority NULLS LAST, created_at, id`, so
|
|
96
|
+
// pages are stable — but a detector whose whole job is surfacing rows nobody
|
|
97
|
+
// can see must not be the thing that loses one.
|
|
98
|
+
async function fetchAllTasks(getPage, { query = {}, pageSize = TASK_LIST_MAX_LIMIT, maxPages = 100 } = {}) {
|
|
99
|
+
const byId = new Map();
|
|
100
|
+
let offset = 0;
|
|
101
|
+
for (let page = 0; page < maxPages; page++) {
|
|
102
|
+
const q = buildTaskPageQuery({ ...query, limit: pageSize, offset });
|
|
103
|
+
const r = await getPage(q);
|
|
104
|
+
if (!r || !r.ok) throw new Error(`HTTP ${r && r.status}`);
|
|
105
|
+
const rows = (r.data && r.data.tasks) || [];
|
|
106
|
+
for (const t of rows) byId.set(String(t.id), t);
|
|
107
|
+
// A short page is the end of the table. Compare against the CLAMPED size
|
|
108
|
+
// the route actually honoured, never the size the caller asked for.
|
|
109
|
+
if (rows.length < q.limit) return [...byId.values()];
|
|
110
|
+
offset += q.limit;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`more than ${maxPages} pages of tasks — refusing to loop forever`);
|
|
113
|
+
}
|
|
84
114
|
// --stranded (task 1003153): the INVERSE audit. findMissingDeps asks "which edge
|
|
85
115
|
// is MISSING"; this asks "which existing edge is DEAD" — a live task gated by an
|
|
86
116
|
// ABANDONED dependency. That edge can never satisfy (an abandoned task can never
|
|
@@ -91,9 +121,13 @@ function findMissingDeps(tasks, { open = OPEN, isBlocker = isUnshippedBlocker }
|
|
|
91
121
|
// Exits 1 when anything is stranded, so a sweep or CI leg can gate on it.
|
|
92
122
|
async function reportStranded({ asJson }) {
|
|
93
123
|
const api = await cliClient();
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
124
|
+
let all;
|
|
125
|
+
try {
|
|
126
|
+
all = await fetchAllTasks((query) => api.tasks.getTasks({ query }), { query: { include: 'deps' } });
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error(`audit-deps: could not list tasks (${e.message})`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
97
131
|
const stranded = findStrandedTasks(all);
|
|
98
132
|
|
|
99
133
|
// Context worth stating even at zero: abandoned tasks that still CARRY
|
|
@@ -174,7 +208,7 @@ async function main() {
|
|
|
174
208
|
console.log('NB: advisory only. Backfill `high` edges by hand (POST /tasks/:id/dependencies) after reading both tasks; a wrong edge gates the dependent.');
|
|
175
209
|
}
|
|
176
210
|
|
|
177
|
-
module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS, reportStranded };
|
|
211
|
+
module.exports = { findMissingDeps, classify, OPEN, isUnshippedBlocker, HOTSPOTS, reportStranded, fetchAllTasks };
|
|
178
212
|
|
|
179
213
|
if (require.main === module) {
|
|
180
214
|
main().catch((e) => { console.error('audit-deps:', e.message); process.exit(1); });
|