@bongos/core 1.19.999 → 1.19.1001
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 +116 -91
- package/.claude/skills/collab-review/SKILL.md +24 -13
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +155 -5
- package/docs/api-reference.md +6 -4
- package/docs/copy-inventory.md +18 -13
- package/docs/copy-registry.json +70 -15
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +79 -0
- package/modules/hall-ui/public/collab.css +60 -0
- package/modules/hall-ui/public/collab.js +134 -5
- package/modules/lifecycle/help-requests.js +118 -8
- package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
- package/modules/lifecycle/routes/help-requests.js +93 -13
- package/modules/provisioning/capacity.js +3 -3
- package/modules/provisioning/control-plane-guard.js +1 -1
- package/modules/provisioning/core-upgrade.js +7 -7
- package/modules/provisioning/cost-ledger.js +74 -0
- package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
- package/modules/provisioning/onboard-plan.js +1 -1
- package/modules/provisioning/paid-shape-gate.js +28 -15
- package/modules/provisioning/provisioning.js +20 -39
- package/modules/provisioning/routes/provisioning.js +4 -3
- package/modules/provisioning/routes/repo-private.js +1 -1
- package/modules/provisioning/tests/liveness.mjs +16 -8
- package/modules/provisioning/tests/provisioning.mjs +1 -1
- package/modules/public-landing/public/projects.html +26 -27
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/init.js +12 -4
- package/scripts/gds/onboard.js +1 -1
- package/scripts/gds/provision-capacity.js +6 -1
- package/scripts/gds/provision-config.js +6 -3
- package/scripts/gds/provision-core-upgrade.js +8 -8
- package/scripts/gds/provision-disconnect.js +2 -2
- package/scripts/gds/provision-process.js +2 -2
- package/scripts/gds/provision-repo-private.js +1 -1
- package/scripts/gds/provision-repo.js +13 -3
- package/scripts/gds/provision-teardown.js +2 -2
- package/scripts/gds/provision-units.js +2 -2
- package/scripts/gds/provision.js +6 -6
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +140 -7
- package/tests/core_upgrade_door.mjs +1 -1
- package/tests/core_upgrade_runner.mjs +20 -20
- package/tests/deploy_page_projects.mjs +5 -5
- package/tests/hall_collab_world.mjs +6 -2
- package/tests/help_requests.mjs +201 -6
- package/tests/hub_disconnect_ui.mjs +4 -1
- package/tests/init.mjs +6 -6
- package/tests/onboard.mjs +5 -4
- package/tests/project_catalog_backfill.mjs +1 -1
- package/tests/project_catalog_bridge.mjs +2 -2
- package/tests/project_catalog_routes.mjs +4 -4
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +22 -5
- package/tests/projects_hub_repo_private.mjs +1 -1
- package/tests/provision.mjs +32 -32
- package/tests/provision_byo_host.mjs +1 -1
- package/tests/provision_caddy_wiring.mjs +1 -1
- package/tests/provision_disconnect.mjs +3 -3
- package/tests/provision_repo_private.mjs +1 -1
- package/tests/provision_restart.mjs +5 -5
- package/tests/provision_settings_apply.mjs +4 -4
- package/tests/provision_teardown_address.mjs +2 -2
- package/tests/provisioning_capacity.mjs +40 -34
- package/tests/provisioning_capacity_gate.mjs +5 -12
- package/tests/provisioning_cost_ledger.mjs +98 -0
- package/tests/provisioning_default_domain.mjs +1 -1
- package/tests/provisioning_domain_attach.mjs +4 -4
- package/tests/provisioning_domain_unique.mjs +2 -2
- package/tests/provisioning_error_note_scope.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +5 -5
- package/tests/provisioning_onboard_mode.mjs +2 -2
- package/tests/provisioning_paid_shape_gate.mjs +57 -44
- package/tests/provisioning_repo_private_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +11 -4
- package/tests/provisioning_settings_apply.mjs +12 -5
- package/tests/provisioning_settings_env.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +3 -3
- package/tests/public_landing_projects.mjs +13 -6
- package/tests/upgrade.mjs +2 -2
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1001",
|
|
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.1001",
|
|
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.1001",
|
|
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/scripts/gds/init.js
CHANGED
|
@@ -93,7 +93,12 @@ const KNOWN_MODULES = ['game', 'art-pipeline', 'discord', 'dev-box'];
|
|
|
93
93
|
// more than the server does is worse than no list, because the refusal arrives a step
|
|
94
94
|
// later with no useful message. Dropping it means `--shape co-tenant` fails here, naming
|
|
95
95
|
// the shapes that work.
|
|
96
|
-
|
|
96
|
+
//
|
|
97
|
+
// 'dedicated' left for the same reason, one step further on: ADR 0327 decision 4 deleted
|
|
98
|
+
// the shape outright, so it is not merely refused but unstorable (migration
|
|
99
|
+
// provisioning_027 folded its value into 'cloud-host'). 'standalone' was renamed to
|
|
100
|
+
// 'cloud-host' by the same migration. Both were requestable from here until task 1004182.
|
|
101
|
+
const HOSTING_SHAPES = ['byo-host', 'cloud-host'];
|
|
97
102
|
|
|
98
103
|
// The Cloud Bongos core npm package an instance pins as a dependency (ADR 0108).
|
|
99
104
|
// `bongos init --adopt` LAYERS this onto an existing repo's package.json; `bongos
|
|
@@ -459,11 +464,14 @@ async function interview({ adopt = false } = {}) {
|
|
|
459
464
|
// be a second copy of it, in a file no designer renaming the product would
|
|
460
465
|
// think to open. A CLI prompt naming the stored value it is about to write is
|
|
461
466
|
// the right register anyway: this asks for a column value, not for a purchase.
|
|
462
|
-
const shape = (await ask(rl, ' Hosting shape? (byo-host = you run it on your own host /
|
|
467
|
+
const shape = (await ask(rl, ' Hosting shape? (byo-host = you run it on your own host / cloud-host = its own repo + pinned @bongos/core, stood up for you)', 'byo-host')).toLowerCase();
|
|
463
468
|
const domain = await ask(rl, ' Domain you own — bring-your-own (blank = the platform assigns <slug>.<its apex> when configured)?');
|
|
464
469
|
const targetRef = await ask(rl, ' Target credential ref? (which control-plane creds the runner should use; optional)');
|
|
465
|
-
|
|
466
|
-
|
|
470
|
+
// No tier question any more. It existed only for 'dedicated', which ADR 0327
|
|
471
|
+
// decision 4 deleted: on the owner's own platform account a bigger plan is a
|
|
472
|
+
// setting they choose, not a hosting shape we model. `tier` stays in the spec
|
|
473
|
+
// shape below because a previously-written spec may still carry one.
|
|
474
|
+
const tier = '';
|
|
467
475
|
provisioning = {
|
|
468
476
|
shape: HOSTING_SHAPES.includes(shape) ? shape : 'byo-host',
|
|
469
477
|
domain: domain || undefined,
|
package/scripts/gds/onboard.js
CHANGED
|
@@ -54,7 +54,7 @@ function buildInitSpec(a = {}) {
|
|
|
54
54
|
version: { id: a.versionId || 'V1', name: a.versionName || `${a.versionId || 'V1'} — Prove the loop`, done_when: a.doneWhen || 'The loop is proven end-to-end.' },
|
|
55
55
|
};
|
|
56
56
|
if (a.domain) spec.domains = { publicOrigin: `https://${a.domain}` };
|
|
57
|
-
if (a.domain || a.slug) spec.provisioning = { shape: '
|
|
57
|
+
if (a.domain || a.slug) spec.provisioning = { shape: 'cloud-host', domain: a.domain || null, slug: a.slug || null };
|
|
58
58
|
return spec;
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -31,7 +31,12 @@ const MEMINFO_CMD = "grep -E '^Mem(Total|Available):' /proc/meminfo";
|
|
|
31
31
|
async function capacityPreflight(inst, deps) {
|
|
32
32
|
const { db, provisioning, exec, log, apply } = deps;
|
|
33
33
|
const target = capacity.placementBox(inst.hosting_shape, { remoteHost: CONFIG.remoteHost });
|
|
34
|
-
|
|
34
|
+
// No target box = no box of OURS to place this on, so there is no ceiling to ask about.
|
|
35
|
+
// Today that is 'byo-host' alone: the owner supplies the machine. It also covered
|
|
36
|
+
// 'dedicated' until ADR 0327 decision 4 removed that shape, which is why the skip reason
|
|
37
|
+
// is named for the QUESTION rather than for whichever shape happens to answer it — a
|
|
38
|
+
// reason label that names a shape goes stale the moment the shape does.
|
|
39
|
+
if (!target) return { ok: true, skipped: 'no-shared-box' };
|
|
35
40
|
const boxExec = target.box === 'control-plane' ? (deps.controlExec || exec) : exec;
|
|
36
41
|
let res = null;
|
|
37
42
|
try { res = boxExec(MEMINFO_CMD, { allowFail: true }); } catch { res = null; }
|
|
@@ -102,9 +102,12 @@ const CONFIG = {
|
|
|
102
102
|
// remote-exec splits the two boxes; all-local (no remoteHost) falls back to publicIp
|
|
103
103
|
// since the control plane and the run box are then the same machine.
|
|
104
104
|
controlPlaneIp: process.env.PROVISION_CONTROL_PLANE_IP || null,
|
|
105
|
-
// Parent dir for
|
|
106
|
-
//
|
|
107
|
-
// an npm dep. Distinct from co-tenant
|
|
105
|
+
// Parent dir for the OWN repo checkout of an ADR 0108 standalone-topology instance —
|
|
106
|
+
// today that is the 'cloud-host' hosting shape. Each lives at <standaloneBase>/<slug>
|
|
107
|
+
// with @bongos/core installed as an npm dep. Distinct from co-tenant, which shares THIS
|
|
108
|
+
// control-plane checkout. The name stays "standalone" because it describes the TOPOLOGY,
|
|
109
|
+
// not the shape whose stored value task 1004182 renamed; PROVISION_STANDALONE_BASE is
|
|
110
|
+
// set on the live box and named in the /new-project skill, so renaming it breaks both.
|
|
108
111
|
standaloneBase: process.env.PROVISION_STANDALONE_BASE || '/srv/cloudbongos',
|
|
109
112
|
// ── Privilege escalation (task 2066, ADR 0128) ───────────────────────────
|
|
110
113
|
// The intent runner runs as a NON-root user (provision-intent-runner.service:
|
|
@@ -39,7 +39,7 @@ const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
|
39
39
|
// for one would be a request that is accepted and never lands. The route refuses the
|
|
40
40
|
// same shape; this is the second gate, and it is the one that holds if a row's shape
|
|
41
41
|
// changes between the request and the drain.
|
|
42
|
-
const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', '
|
|
42
|
+
const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'cloud-host', 'control-plane']));
|
|
43
43
|
|
|
44
44
|
// The strict three-number form a core version takes. Re-stated here rather than imported
|
|
45
45
|
// from the web tier's core-upgrade.js: a module may not be required from scripts/ (ADR
|
|
@@ -152,7 +152,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
152
152
|
const { db, provisioning, exec, log, apply, privileged } = deps;
|
|
153
153
|
// A standalone's box IS the control plane's box, the same routing restartInstance and
|
|
154
154
|
// settingsApplyInstance use; a co-tenant shares this checkout.
|
|
155
|
-
const boxExec = inst.hosting_shape === '
|
|
155
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
156
156
|
const to = intent && intent.target_version;
|
|
157
157
|
log(`core-upgrade ${inst.slug} → ${to || '(no target)'} (shape=${inst.hosting_shape}, status=${inst.status})`);
|
|
158
158
|
|
|
@@ -190,7 +190,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
190
190
|
// ---- 2. is the target real, and permitted -----------------------------------------
|
|
191
191
|
const channelMod = deps.updateChannel || require('./update-channel.js');
|
|
192
192
|
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
193
|
-
const instanceDir = inst.hosting_shape === '
|
|
193
|
+
const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
|
|
194
194
|
const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
|
|
195
195
|
const installed = readInstalled(instanceDir);
|
|
196
196
|
// THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
|
|
@@ -325,7 +325,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
|
|
|
325
325
|
// probe on `exec` would then ask the co-hosting box whether an account exists while
|
|
326
326
|
// the command it is deciding for runs on the control plane — a wrong answer that
|
|
327
327
|
// looks like a right one.
|
|
328
|
-
const boxExec = inst.hosting_shape === '
|
|
328
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || deps.exec) : deps.exec;
|
|
329
329
|
const r = boxExec(`id -u ${wanted}`, { allowFail: true });
|
|
330
330
|
const failed = !r || r.softFailed === true || r.ok === false;
|
|
331
331
|
if (!failed) return { runAs: wanted, fellBack: false };
|
|
@@ -371,7 +371,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
371
371
|
// just to node_modules/@bongos/core — and the door aimed at the package instead of
|
|
372
372
|
// the project. So ask, rather than assume; in the legacy shape the answer is
|
|
373
373
|
// REPO_ROOT and the command below is byte-identical to what it always was.
|
|
374
|
-
const instanceDir = inst.hosting_shape === '
|
|
374
|
+
const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
|
|
375
375
|
// WHICH ACCOUNT, AND WHY IT IS RESOLVED RATHER THAN DERIVED (task 1004128).
|
|
376
376
|
//
|
|
377
377
|
// Task 1004119 wrote this as `control-plane ? APP_USER : instanceUser(inst)` and was
|
|
@@ -409,7 +409,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
|
|
|
409
409
|
// is the interesting answer, not an exception: pre-flight refusing is what this is for.
|
|
410
410
|
function preflightUpgrade(inst, to, deps) {
|
|
411
411
|
const { exec, privileged } = deps;
|
|
412
|
-
const boxExec = inst.hosting_shape === '
|
|
412
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
413
413
|
// A distinct local name: `deps.selfInstanceRoot || selfInstanceRoot` with a const of
|
|
414
414
|
// the same name would TDZ-shadow the import and crash every uninjected call.
|
|
415
415
|
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
@@ -718,10 +718,10 @@ async function preflightCoreUpgrade(inst, deps, intent) {
|
|
|
718
718
|
async function snapshotCoreVersions(inst, deps) {
|
|
719
719
|
const { db, exec, log } = deps;
|
|
720
720
|
if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') return { ok: true, skipped: true };
|
|
721
|
-
const boxExec = inst.hosting_shape === '
|
|
721
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
722
722
|
const channelMod = deps.updateChannel || require('./update-channel.js');
|
|
723
723
|
const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
|
|
724
|
-
const instanceDir = inst.hosting_shape === '
|
|
724
|
+
const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
|
|
725
725
|
try {
|
|
726
726
|
const probe = boxExec(versionCmd(inst), { allowFail: true });
|
|
727
727
|
const body = probe && !probe.softFailed ? (probe.stdout || '') : null;
|
|
@@ -49,7 +49,7 @@ function eraseCommands(inst, { privileged = false } = {}) {
|
|
|
49
49
|
`${sudo}find ${CONFIG.backupDir} -maxdepth 1 -type f -name '${dumpGlob(inst)}' -delete`,
|
|
50
50
|
`${sudo}rm -rf ${path.posix.dirname(webEnvPath(inst))} ${instanceStateDir(inst)}`,
|
|
51
51
|
];
|
|
52
|
-
if (inst.hosting_shape === '
|
|
52
|
+
if (inst.hosting_shape === 'cloud-host') cmds.push(`${sudo}rm -rf ${standaloneRoot(inst)}`);
|
|
53
53
|
cmds.push(`if id -u ${user} >/dev/null 2>&1; then ${sudo}userdel ${user}; fi`);
|
|
54
54
|
return cmds;
|
|
55
55
|
}
|
|
@@ -63,7 +63,7 @@ async function disconnectInstance(inst, deps) {
|
|
|
63
63
|
await teardownInstance(inst, deps);
|
|
64
64
|
|
|
65
65
|
if (inst.hosting_shape !== 'dedicated') {
|
|
66
|
-
const boxExec = inst.hosting_shape === '
|
|
66
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
67
67
|
for (const cmd of eraseCommands(inst, { privileged })) {
|
|
68
68
|
try { boxExec(cmd); }
|
|
69
69
|
catch (e) { return { ok: false, error: `erase step failed (${cmd.split(' ').slice(0, 3).join(' ')}): ${(e && e.message) || e}` }; }
|
|
@@ -75,13 +75,13 @@ function supervisorConfPath(inst) {
|
|
|
75
75
|
// cannot drift: a standalone runs out of its own pinned core, anything else out of this
|
|
76
76
|
// checkout.
|
|
77
77
|
function programCommand(inst) {
|
|
78
|
-
return inst.hosting_shape === '
|
|
78
|
+
return inst.hosting_shape === 'cloud-host'
|
|
79
79
|
? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
|
|
80
80
|
: '/usr/bin/node src/platform-server.js';
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function supervisorProgram(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
84
|
-
const workDir = inst.hosting_shape === '
|
|
84
|
+
const workDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : REPO_ROOT;
|
|
85
85
|
return `[program:${inst.slug}]
|
|
86
86
|
; Cloud Bongos instance — ${inst.slug} (provisioned by provision.js, ADR 0306)
|
|
87
87
|
command=${programCommand(inst)}
|
|
@@ -40,7 +40,7 @@ async function repoPrivateInstance(inst, deps) {
|
|
|
40
40
|
|
|
41
41
|
// Only a standalone project has its own checkout to wire; any other shape reads from a
|
|
42
42
|
// checkout this project does not own, so a key minted here would change nothing.
|
|
43
|
-
if (inst.hosting_shape !== '
|
|
43
|
+
if (inst.hosting_shape !== 'cloud-host') {
|
|
44
44
|
return { ok: false, error: 'This project shares its code checkout with others, so its repository cannot be made private from here.' };
|
|
45
45
|
}
|
|
46
46
|
if (inst.status !== 'active') {
|
|
@@ -79,8 +79,18 @@ function seedFirstVersionCmd(inst, { privileged = false } = {}) {
|
|
|
79
79
|
return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${dbName(inst)} -c '${sql}'`, privileged);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// ──
|
|
83
|
-
//
|
|
82
|
+
// ── The CLOUD-HOST shape (hosting_shape='cloud-host', ADR 0108, task 1978) ────
|
|
83
|
+
//
|
|
84
|
+
// TWO SENSES OF "STANDALONE", AND THIS FILE USES BOTH. The ADR 0108 sense is a repo
|
|
85
|
+
// TOPOLOGY: an instance running from its own checkout with the core as an npm dependency,
|
|
86
|
+
// as opposed to co-tenant's shared control-plane checkout. That is what `standaloneRoot`,
|
|
87
|
+
// `standaloneBase` and `PROVISION_STANDALONE_BASE` name, and those keep their spelling —
|
|
88
|
+
// `PROVISION_STANDALONE_BASE` in particular is a live environment variable on the control
|
|
89
|
+
// plane and is documented in the /new-project skill. The other sense was the HOSTING SHAPE
|
|
90
|
+
// once stored as 'standalone'; task 1004182 renamed that value to 'cloud-host'. Only the
|
|
91
|
+
// stored value moved. Do not "finish the rename" by touching the topology names.
|
|
92
|
+
//
|
|
93
|
+
// A cloud-host instance runs from its OWN repo checkout (host content: config/,
|
|
84
94
|
// migrations/instance/, the core PIN in package.json) with @bongos/core installed
|
|
85
95
|
// as an npm DEPENDENCY — NOT the shared control-plane checkout the co-tenant shape uses.
|
|
86
96
|
// It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
|
|
@@ -883,7 +893,7 @@ function deployKeyPath(inst) { return path.posix.join(standaloneRoot(inst), '.gi
|
|
|
883
893
|
async function ensurePrivateRepoAccess(inst, deps) {
|
|
884
894
|
const { log, apply } = deps;
|
|
885
895
|
const exec = deps.controlExec || deps.exec;
|
|
886
|
-
if (inst.hosting_shape !== '
|
|
896
|
+
if (inst.hosting_shape !== 'cloud-host') return { skipped: 'shape' };
|
|
887
897
|
const repo = parseTargetRef(inst.target_ref);
|
|
888
898
|
if (!repo) return { skipped: 'no_repo' };
|
|
889
899
|
// Dry-run stays OFFLINE like every other plan path here (the checkDnsTokenScope
|
|
@@ -24,7 +24,7 @@ async function teardownInstance(inst, deps) {
|
|
|
24
24
|
// local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
|
|
25
25
|
// box, where it was never installed (that would allowFail-noop and leave the real
|
|
26
26
|
// control-plane unit running). Co-tenant + dedicated are unchanged.
|
|
27
|
-
const boxExec = inst.hosting_shape === '
|
|
27
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? controlExec : exec;
|
|
28
28
|
const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
|
|
29
29
|
log(`teardown ${inst.slug} (status=${inst.status})`);
|
|
30
30
|
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
|
|
@@ -47,7 +47,7 @@ async function teardownInstance(inst, deps) {
|
|
|
47
47
|
// files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
|
|
48
48
|
// silently keeping durable read access to the owner's private repo is the exact
|
|
49
49
|
// shape a teardown exists to end. A missing token / API hiccup never blocks it.
|
|
50
|
-
if (inst.hosting_shape === '
|
|
50
|
+
if (inst.hosting_shape === 'cloud-host' && inst.target_ref) {
|
|
51
51
|
const repo = parseTargetRef(inst.target_ref);
|
|
52
52
|
if (repo) {
|
|
53
53
|
try {
|
|
@@ -16,7 +16,7 @@ const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanc
|
|
|
16
16
|
function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
17
17
|
const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
|
|
18
18
|
const user = instanceUser(inst), stateDir = instanceStateDir(inst);
|
|
19
|
-
const standalone = inst.hosting_shape === '
|
|
19
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
20
20
|
const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
|
|
21
21
|
const execStart = standalone
|
|
22
22
|
? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
|
|
@@ -91,7 +91,7 @@ function backupTimerPath(inst) { return `/etc/systemd/system/${backupUnitName(in
|
|
|
91
91
|
// scripts/gds/ — mirrors serviceUnit's execStart resolution so they can't diverge.
|
|
92
92
|
function backupScriptPath(inst) {
|
|
93
93
|
const rel = 'scripts/gds/db-backup-nightly.sh';
|
|
94
|
-
return inst.hosting_shape === '
|
|
94
|
+
return inst.hosting_shape === 'cloud-host'
|
|
95
95
|
? path.posix.join(standaloneRoot(inst), CORE_PKG_DIR, rel)
|
|
96
96
|
: path.posix.join(REPO_ROOT, rel);
|
|
97
97
|
}
|
package/scripts/gds/provision.js
CHANGED
|
@@ -238,7 +238,7 @@ async function provisionInstance(inst, deps) {
|
|
|
238
238
|
// So a standalone uses the control-plane executors; co-tenant + dedicated are
|
|
239
239
|
// unchanged (boxExec === exec, boxWriteFile === writeFile). When no remoteHost is
|
|
240
240
|
// set, controlExec === exec anyway, so this is a no-op for the all-local default.
|
|
241
|
-
const standalone = inst.hosting_shape === '
|
|
241
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
242
242
|
const boxExec = standalone ? controlExec : exec;
|
|
243
243
|
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
244
244
|
const sudoP = privileged ? 'sudo ' : ''; // systemctl escalation prefix (task 2066)
|
|
@@ -372,7 +372,7 @@ async function provisionInstance(inst, deps) {
|
|
|
372
372
|
// materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
|
|
373
373
|
// (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
|
|
374
374
|
// throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
|
|
375
|
-
if (inst.hosting_shape === '
|
|
375
|
+
if (inst.hosting_shape === 'cloud-host') {
|
|
376
376
|
await scaffoldStandaloneRepo(inst, deps);
|
|
377
377
|
// A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's
|
|
378
378
|
// `git pull --ff-only origin main` — the pull that failed unauthenticated
|
|
@@ -740,7 +740,7 @@ async function domainAttachInstance(inst, deps) {
|
|
|
740
740
|
const controlExec = deps.controlExec || exec;
|
|
741
741
|
// Same box routing as provisionInstance: a standalone runs control-plane-local
|
|
742
742
|
// (task 2074), so its env/caddy/service live HERE, not on the co-hosting box.
|
|
743
|
-
const standalone = inst.hosting_shape === '
|
|
743
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
744
744
|
const boxExec = standalone ? controlExec : exec;
|
|
745
745
|
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
746
746
|
const sudoP = privileged ? 'sudo ' : '';
|
|
@@ -876,7 +876,7 @@ async function domainAttachInstance(inst, deps) {
|
|
|
876
876
|
async function settingsApplyInstance(inst, deps) {
|
|
877
877
|
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
878
878
|
// Same box routing as domainAttachInstance: a standalone runs control-plane-local.
|
|
879
|
-
const standalone = inst.hosting_shape === '
|
|
879
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
880
880
|
const boxExec = standalone ? (deps.controlExec || exec) : exec;
|
|
881
881
|
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
882
882
|
const sudoP = privileged ? 'sudo ' : '';
|
|
@@ -992,7 +992,7 @@ async function settingsApplyInstance(inst, deps) {
|
|
|
992
992
|
async function restartInstance(inst, deps) {
|
|
993
993
|
const { db, provisioning, exec, log, apply, privileged } = deps;
|
|
994
994
|
// Same box routing as settingsApplyInstance: a standalone runs control-plane-local.
|
|
995
|
-
const boxExec = inst.hosting_shape === '
|
|
995
|
+
const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
|
|
996
996
|
const sudoP = privileged ? 'sudo ' : '';
|
|
997
997
|
log(`restart ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
|
|
998
998
|
|
|
@@ -1124,7 +1124,7 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1124
1124
|
return { ok: false, error: 'GitHub App Manifest cred placement is not supported for a dedicated droplet yet — run oauth-secret.js on the droplet instead' };
|
|
1125
1125
|
}
|
|
1126
1126
|
if (!mrow.github_code) return { ok: false, error: 'manifest row carries no github_code to convert' };
|
|
1127
|
-
const standalone = inst.hosting_shape === '
|
|
1127
|
+
const standalone = inst.hosting_shape === 'cloud-host';
|
|
1128
1128
|
const boxExec = standalone ? (deps.controlExec || deps.exec) : deps.exec;
|
|
1129
1129
|
const boxWriteFile = standalone ? (deps.controlWriteFile || deps.writeFile) : deps.writeFile;
|
|
1130
1130
|
const sudoP = deps.privileged ? 'sudo ' : '';
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1001'; // 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');
|
package/tests/collab_page.mjs
CHANGED
|
@@ -565,12 +565,138 @@ test('the asker can settle in place, with both outcomes', () => {
|
|
|
565
565
|
assert.match(js, /patchHelpRequestsId/, 'through the real API, not a local edit');
|
|
566
566
|
});
|
|
567
567
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
568
|
+
// REPLACED, not deleted (task 1004216). This test used to read "there is
|
|
569
|
+
// deliberately NO reply thread — that would reimplement the Agora", deferring
|
|
570
|
+
// replies to task 1002946 under goal 1000064. That task is ABANDONED and that
|
|
571
|
+
// goal is ARCHIVED, so the deferral had no addressee left: the surface that was
|
|
572
|
+
// to own replies will never be built, and an ask stayed unanswerable to keep a
|
|
573
|
+
// promise to a thing that does not exist. ADR 0330 records the reversal.
|
|
574
|
+
//
|
|
575
|
+
// What the old test was PROTECTING is still real, so it is what these assert:
|
|
576
|
+
// a thread here must not grow into the chat surface ADR 0187 §4a declined.
|
|
577
|
+
test('an ask can be answered — and the answer surface stops well short of a chat', () => {
|
|
578
|
+
const src = read('modules', 'hall-ui', 'public', 'collab.js');
|
|
579
|
+
// Comments stripped before the bans are checked: this file ARGUES at length
|
|
580
|
+
// about the four things it is not building, and a scan that read the argument
|
|
581
|
+
// as the thing would fail on the very prose explaining the restraint.
|
|
582
|
+
const js = src.replace(/^\s*\/\/.*$/gm, '');
|
|
583
|
+
assert.match(js, /data-reply-form/, 'there is somewhere to write an answer');
|
|
584
|
+
assert.match(js, /POST', `\/help-requests\/\$\{id\}\/replies`/, 'and it posts to the replies route');
|
|
585
|
+
|
|
586
|
+
// The four things a chat has that this must not grow.
|
|
587
|
+
assert.ok(!/data-edit|patchReply|editReply/i.test(js), 'no edit — a reply is what was said at the time');
|
|
588
|
+
assert.ok(!/deleteReply|data-reply-delete/i.test(js), 'no delete');
|
|
589
|
+
assert.ok(!/data-react|reaction|emoji/i.test(js), 'no reactions');
|
|
590
|
+
assert.ok(!/parent_reply|data-reply-to|@mention|mentions/i.test(js), 'no nesting and no mentions');
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// RUN, not grepped — this file's own convention for everything in collab-lib.js,
|
|
594
|
+
// and the grader was right that the first pass broke it. canClose in particular
|
|
595
|
+
// decides whether a close control is drawn at all, so each branch of it needs a
|
|
596
|
+
// test that calls it rather than a regex that agrees with a wrong answer.
|
|
597
|
+
test('canClose: the asker and the addressee, and nobody else', () => {
|
|
598
|
+
const L = lib();
|
|
599
|
+
const craftAsk = { requested_by: '90', needs_craft: 'artist', needs_builder_id: null };
|
|
600
|
+
const namedAsk = { requested_by: '90', needs_craft: null, needs_builder_id: '7' };
|
|
601
|
+
|
|
602
|
+
assert.equal(L.canClose(namedAsk, 90, []), true, 'the asker always may');
|
|
603
|
+
assert.equal(L.canClose(namedAsk, 7, []), true, 'so may the person it names');
|
|
604
|
+
assert.equal(L.canClose(craftAsk, 7, ['artist']), true, 'or a craft they belong to');
|
|
605
|
+
|
|
606
|
+
assert.equal(L.canClose(craftAsk, 7, ['builder']), false, 'a craft they do not belong to is not theirs to close');
|
|
607
|
+
assert.equal(L.canClose(namedAsk, 8, ['artist']), false, 'a stranger to a by-name ask');
|
|
608
|
+
assert.equal(L.canClose(craftAsk, 7, []), false, 'no declared craft reaches no craft ask');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('canClose fails CLOSED on every missing fact', () => {
|
|
612
|
+
// The page reads meId from /me. A failed read leaves it null, and the correct
|
|
613
|
+
// answer then is "draw nothing" — never "draw it and let the server refuse".
|
|
614
|
+
const L = lib();
|
|
615
|
+
const ask = { requested_by: '90', needs_builder_id: '7' };
|
|
616
|
+
for (const bad of [null, undefined]) {
|
|
617
|
+
assert.equal(L.canClose(ask, bad, ['artist']), false, `meId ${bad} draws no control`);
|
|
618
|
+
}
|
|
619
|
+
assert.equal(L.canClose(null, 7, ['artist']), false, 'no request, no control');
|
|
620
|
+
assert.equal(L.canClose({ requested_by: '90', needs_craft: null }, 7, null), false,
|
|
621
|
+
'a non-array craft list is not iterated into a yes');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('canClose compares ids ACROSS types — the API sends strings, /me sends what it sends', () => {
|
|
625
|
+
// The live bug this forecloses: needs_builder_id arrives as "7" and meId as 7,
|
|
626
|
+
// and === between them is false, so the person a request names would silently
|
|
627
|
+
// lose their own close control.
|
|
628
|
+
const L = lib();
|
|
629
|
+
assert.equal(L.canClose({ requested_by: '90', needs_builder_id: '7' }, 7, []), true);
|
|
630
|
+
assert.equal(L.canClose({ requested_by: 90, needs_builder_id: 7 }, '7', []), true);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test('threadOf: oldest first, ties broken on id, and never explodes on a missing thread', () => {
|
|
634
|
+
const L = lib();
|
|
635
|
+
const out = L.threadOf({ replies: [
|
|
636
|
+
{ id: 3, created_at: '2026-09-22T10:00:00Z' },
|
|
637
|
+
{ id: 1, created_at: '2026-09-20T10:00:00Z' },
|
|
638
|
+
{ id: 2, created_at: '2026-09-22T10:00:00Z' },
|
|
639
|
+
] });
|
|
640
|
+
assert.deepEqual(out.map((r) => r.id), [1, 2, 3], 'two replies in the same second keep the order written');
|
|
641
|
+
assert.deepEqual(L.threadOf({}), [], 'a row with no replies field');
|
|
642
|
+
assert.deepEqual(L.threadOf(null), []);
|
|
643
|
+
assert.deepEqual(L.threadOf({ replies: 'nonsense' }), [], 'and a field that is not a list');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test('saidLabel is the PAST tense — an answer is not a debt', () => {
|
|
647
|
+
// ageLabel says "waiting 3 days", which under an answer reads as the answer
|
|
648
|
+
// still being owed: the exact opposite of what a reply means.
|
|
649
|
+
const L = lib();
|
|
650
|
+
const NOW2 = Date.parse('2026-09-23T12:00:00Z');
|
|
651
|
+
const ago = (d) => new Date(NOW2 - d * 86400000).toISOString();
|
|
652
|
+
assert.equal(L.saidLabel(ago(0), NOW2), 'today');
|
|
653
|
+
assert.equal(L.saidLabel(ago(1), NOW2), 'yesterday');
|
|
654
|
+
assert.equal(L.saidLabel(ago(3), NOW2), '3 days ago');
|
|
655
|
+
assert.equal(L.saidLabel(ago(21), NOW2), '3 weeks ago');
|
|
656
|
+
assert.equal(L.saidLabel('nonsense', NOW2), 'just now', 'an unknown time is not an accusation');
|
|
657
|
+
for (const label of [L.saidLabel(ago(3), NOW2), L.saidLabel(ago(21), NOW2)]) {
|
|
658
|
+
assert.ok(!/waiting/.test(label), `"${label}" must not borrow the ask's tense`);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test('replyAuthor names somebody, whatever the server sent', () => {
|
|
663
|
+
const L = lib();
|
|
664
|
+
// Neutral names on purpose: this is the portable core, and a founder's login
|
|
665
|
+
// baked into a fixture is what audit-authorship.sh exists to catch.
|
|
666
|
+
assert.equal(L.replyAuthor({ builder_name: 'Preview Alpha', builder_login: 'preview-alpha' }), 'Preview Alpha');
|
|
667
|
+
assert.equal(L.replyAuthor({ builder_login: 'preview-alpha' }), 'preview-alpha', 'the login when there is no display name');
|
|
668
|
+
assert.equal(L.replyAuthor({ builder_id: 7 }), 'builder 7', 'the bare id an older server sends');
|
|
669
|
+
assert.equal(L.replyAuthor({}), 'someone', 'never an empty byline');
|
|
670
|
+
assert.equal(L.replyAuthor(null), '');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test('validateReplyBody refuses before the round trip, and trims what it accepts', () => {
|
|
674
|
+
const L = lib();
|
|
675
|
+
assert.deepEqual(L.validateReplyBody(' moot '), { ok: true, body: 'moot' });
|
|
676
|
+
assert.equal(L.validateReplyBody(' ').ok, false);
|
|
677
|
+
assert.equal(L.validateReplyBody(' ').reason, 'empty');
|
|
678
|
+
assert.equal(L.validateReplyBody(null).reason, 'empty');
|
|
679
|
+
assert.equal(L.validateReplyBody('x'.repeat(L.MAX_REPLY + 1)).reason, 'too_long');
|
|
680
|
+
assert.equal(L.validateReplyBody('x'.repeat(L.MAX_REPLY)).ok, true, 'the cap itself is allowed');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('append-only is enforced by the DATABASE, not by the routes that exist today', () => {
|
|
684
|
+
// A route layer that merely omits an update is one careless PR from allowing
|
|
685
|
+
// one. The table refuses an UPDATE or a DELETE outright, so the guarantee
|
|
686
|
+
// holds against a future route, a migration, or a hand-run statement.
|
|
687
|
+
const raw = read('modules', 'lifecycle', 'migrations', 'lifecycle_011_help_request_replies.sql');
|
|
688
|
+
assert.match(raw, /BEFORE UPDATE OR DELETE ON lifecycle_help_request_replies/);
|
|
689
|
+
assert.match(raw, /RAISE EXCEPTION/, 'and it raises rather than silently ignoring the write');
|
|
690
|
+
// Same reason as above — the migration's header names the columns it refuses
|
|
691
|
+
// to have, so the ban is checked against the STATEMENTS, not the argument.
|
|
692
|
+
const sql = raw.replace(/^\s*--.*$/gm, '');
|
|
693
|
+
for (const col of ['updated_at', 'edited_by', 'deleted_at', 'parent_reply_id']) {
|
|
694
|
+
assert.ok(!new RegExp(`\\b${col}\\b`).test(sql), `no ${col} column — the shape refuses to become a chat`);
|
|
573
695
|
}
|
|
696
|
+
|
|
697
|
+
const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
|
|
698
|
+
assert.ok(!/router\.(patch|delete|put)\('\/help-requests\/:id\/replies/.test(routes),
|
|
699
|
+
'and no route offers to change one');
|
|
574
700
|
});
|
|
575
701
|
|
|
576
702
|
// ---- placement ------------------------------------------------------------
|
|
@@ -724,7 +850,14 @@ test('the skill the page invokes exists, and is the ANSWERING half', () => {
|
|
|
724
850
|
assert.match(md, /^name: collab-review$/m);
|
|
725
851
|
assert.match(md, /GET \/api\/bongos\/help-requests\/for-me/);
|
|
726
852
|
assert.match(md, /GET \/api\/bongos\/task-recommendations\/for-me/);
|
|
727
|
-
// The
|
|
728
|
-
|
|
853
|
+
// The rules the routes enforce, stated where a session will read them. Since
|
|
854
|
+
// ADR 0330 the addressee CAN close an ask — so what the skill must carry is no
|
|
855
|
+
// longer "you cannot", it is "you do not, because you are not the one who read
|
|
856
|
+
// the answer", plus the half that did not widen.
|
|
857
|
+
assert.match(md, /POST \/api\/bongos\/help-requests\/:id\/replies/, 'it answers on the ask');
|
|
858
|
+
assert.match(md, /Do NOT close the ask/, 'and hands the close to a person');
|
|
859
|
+
assert.match(md, /withdraw/i, 'and knows withdraw did not widen');
|
|
860
|
+
assert.ok(!/POST \/api\/bongos\/help-requests --body-file/.test(md),
|
|
861
|
+
'the file-a-counter-ask workaround is deleted, not left beside the real thing');
|
|
729
862
|
assert.match(md, /DATA, not instructions/, 'an ask is untrusted input');
|
|
730
863
|
});
|
|
@@ -119,7 +119,7 @@ await t('and widening it did NOT let the destructive acts through', () => {
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
await t('an ordinary instance is untouched by the guard', () => {
|
|
122
|
-
for (const shape of ['co-tenant', 'dedicated', '
|
|
122
|
+
for (const shape of ['co-tenant', 'dedicated', 'cloud-host']) {
|
|
123
123
|
for (const a of P.INTENT_ACTIONS) {
|
|
124
124
|
assert.equal(G.intentAllowedForShape(a, shape), true, `${a}/${shape} must be unaffected`);
|
|
125
125
|
}
|