@bongos/core 1.21.148 → 1.21.150
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 +15 -15
- package/.claude/skills/bongos-feedback/SKILL.md +2 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/ship-assurance.js +61 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/feedback-send.js +59 -14
- package/src/module-api.js +1 -1
- package/tests/instance_feedback.mjs +45 -1
- package/tests/ship_assurance.mjs +64 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.21.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.21.150",
|
|
6
|
+
"core_contract": "1.21.150",
|
|
7
|
+
"source_commit": "0c869afc46eb5a0841a9406c43fa41f8031c01ad",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-
|
|
9
|
+
"built_at": "2026-10-19T22:01:23.493Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 608,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 3805,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "e7a8a5514591ba5b313a0cc54239367bcfb3f92d0dce344582653a125ecb5d21",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
{
|
|
43
43
|
"path": ".claude/skills/bongos-feedback/SKILL.md",
|
|
44
44
|
"mode": "0000644",
|
|
45
|
-
"sha256": "
|
|
45
|
+
"sha256": "8b295d76d7cc4c9e0f2549db0f1ac857aa8e76a92b3e00898f95820a32c855ba"
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"path": ".claude/skills/bug-triage/SKILL.md",
|
|
@@ -2927,7 +2927,7 @@
|
|
|
2927
2927
|
{
|
|
2928
2928
|
"path": "docs/module-api-changelog.md",
|
|
2929
2929
|
"mode": "0000644",
|
|
2930
|
-
"sha256": "
|
|
2930
|
+
"sha256": "42b00f22670c0c8aa194a2a7c3f22f3bcc9e4a444ff54c398c1254c06e6d5718"
|
|
2931
2931
|
},
|
|
2932
2932
|
{
|
|
2933
2933
|
"path": "docs/modules-contract.md",
|
|
@@ -7737,7 +7737,7 @@
|
|
|
7737
7737
|
{
|
|
7738
7738
|
"path": "modules/lifecycle/ship-assurance.js",
|
|
7739
7739
|
"mode": "0000644",
|
|
7740
|
-
"sha256": "
|
|
7740
|
+
"sha256": "c4dd044485e664528c01caea0500dd6fe9d8ee9d2319385000d1cee26e32102e"
|
|
7741
7741
|
},
|
|
7742
7742
|
{
|
|
7743
7743
|
"path": "modules/lifecycle/ship-card.js",
|
|
@@ -10552,12 +10552,12 @@
|
|
|
10552
10552
|
{
|
|
10553
10553
|
"path": "package-lock.json",
|
|
10554
10554
|
"mode": "0000644",
|
|
10555
|
-
"sha256": "
|
|
10555
|
+
"sha256": "d0e2fcb3573e72ebc1d3ba608eeac029763e7fb2f8fad0f459cca42759bb6c5b"
|
|
10556
10556
|
},
|
|
10557
10557
|
{
|
|
10558
10558
|
"path": "package.json",
|
|
10559
10559
|
"mode": "0000644",
|
|
10560
|
-
"sha256": "
|
|
10560
|
+
"sha256": "5348abba02d8b98849a7a6cf83a44f7d3138f00a806edb89ae2616618b15a559"
|
|
10561
10561
|
},
|
|
10562
10562
|
{
|
|
10563
10563
|
"path": "public-docs/index.html",
|
|
@@ -10577,7 +10577,7 @@
|
|
|
10577
10577
|
{
|
|
10578
10578
|
"path": "release-notes.json",
|
|
10579
10579
|
"mode": "0000644",
|
|
10580
|
-
"sha256": "
|
|
10580
|
+
"sha256": "eaf55ec677f00c2212a9fb00f680130dc60974d8e2a199a97337048c567d2706"
|
|
10581
10581
|
},
|
|
10582
10582
|
{
|
|
10583
10583
|
"path": "scripts/cron/style_guide_audit.sh",
|
|
@@ -11102,7 +11102,7 @@
|
|
|
11102
11102
|
{
|
|
11103
11103
|
"path": "scripts/gds/feedback-send.js",
|
|
11104
11104
|
"mode": "0000644",
|
|
11105
|
-
"sha256": "
|
|
11105
|
+
"sha256": "e3c82d95c9412126d0c064d40267c7ae7befbc3cebdba99c496c884dceb4a750"
|
|
11106
11106
|
},
|
|
11107
11107
|
{
|
|
11108
11108
|
"path": "scripts/gds/fetch-art-key.js",
|
|
@@ -12957,7 +12957,7 @@
|
|
|
12957
12957
|
{
|
|
12958
12958
|
"path": "src/module-api.js",
|
|
12959
12959
|
"mode": "0000644",
|
|
12960
|
-
"sha256": "
|
|
12960
|
+
"sha256": "6ff0eaf959df5d39f838ae2b19a960f7f37552e9eedcb91b183b09b38ab6c840"
|
|
12961
12961
|
},
|
|
12962
12962
|
{
|
|
12963
12963
|
"path": "src/module-loader/catalog.js",
|
|
@@ -16097,7 +16097,7 @@
|
|
|
16097
16097
|
{
|
|
16098
16098
|
"path": "tests/instance_feedback.mjs",
|
|
16099
16099
|
"mode": "0000644",
|
|
16100
|
-
"sha256": "
|
|
16100
|
+
"sha256": "a0a558b9d9e0698fa4b6078a2f2e4e2d9679e517149876809c1478743db0f6ff"
|
|
16101
16101
|
},
|
|
16102
16102
|
{
|
|
16103
16103
|
"path": "tests/instance_manifest.mjs",
|
|
@@ -18102,7 +18102,7 @@
|
|
|
18102
18102
|
{
|
|
18103
18103
|
"path": "tests/ship_assurance.mjs",
|
|
18104
18104
|
"mode": "0000644",
|
|
18105
|
-
"sha256": "
|
|
18105
|
+
"sha256": "be68b8dbf514937f581dfa3a892958fd4f16e20dd319d0ed70bf08811a3451e3"
|
|
18106
18106
|
},
|
|
18107
18107
|
{
|
|
18108
18108
|
"path": "tests/ship_autolinkify.mjs",
|
|
@@ -14,7 +14,7 @@ Sends a note about Bongos — the tool — straight to its maintainers (tasks 10
|
|
|
14
14
|
|
|
15
15
|
**When.** File when the builder (a) labels it — "Bongos feedback: …", "tell the Bongos team" — that **is** the go; (b) wishes — "Bongos should…" (**Suggestions count**: send with `--idea`, even if you also fix it in their project); or (c) vents that Bongos is broken — **File it without being asked**, don't argue them out of it. Only about **Bongos itself** (a `bongos`/`/builder-*` command, claim, ship, grade, deploy, upgrade, hall, Settings, sign-in, credits, how it organises work). Not their own project's code, Claude, their laptop or GitHub, nor a rule working as designed. **One filing per distinct problem per session.**
|
|
16
16
|
|
|
17
|
-
**Write it yourself — Never paste the rant.** First line = what went wrong, in at most twelve words (it becomes the title); then what they did, what they expected, what happened, the exact error line. A wish (`--idea`) asks the maintainers to decide something, so it
|
|
17
|
+
**Write it yourself — Never paste the rant.** First line = what went wrong, in at most twelve words (it becomes the title); then what they did, what they expected, what happened, the exact error line. A wish (`--idea`) asks the maintainers to decide something, so open it as an ask (task 1004704): one question of at most twelve words, two or three answers with what each leads to, your pick and why; the rest follows as detail. Write that shape yourself — run no helper first: a project on an older core may not have one, and a failed command before the send is noise (task 1004759). No code, file contents, secrets or personal data.
|
|
18
18
|
|
|
19
19
|
**Send** (stdin, so nothing in the text is mangled by the shell):
|
|
20
20
|
|
|
@@ -24,7 +24,7 @@ bongos feedback --message-file - <<'EOF'
|
|
|
24
24
|
EOF
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Add `--idea` for a wish. The command prints one line — relay it, then carry on. Proactive filing: one short sentence, e.g. *"I've sent that to the Bongos team."* If it says
|
|
27
|
+
Add `--idea` for a wish. The command prints one line — relay it, then carry on. Proactive filing: one short sentence, e.g. *"I've sent that to the Bongos team."* If it says "Not sent", relay that in one line and never retry: "runs only on your computer" and "older Bongos" both mean this project cannot send yet, and the second names the file the note is saved in — send it with `--message-file <that file>` after the project is upgraded. Don't send the builder to GitHub or "the Bongos team" elsewhere instead, and don't call it sent.
|
|
28
28
|
|
|
29
29
|
## Files this skill touches
|
|
30
30
|
|
|
@@ -3133,5 +3133,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3133
3133
|
landed since 1.21.146 with no explicit bump. run 39274828902. (task 1002620)
|
|
3134
3134
|
1.21.148 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3135
3135
|
landed since 1.21.147 with no explicit bump. run 39286989989. (task 1002620)
|
|
3136
|
+
1.21.149 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3137
|
+
landed since 1.21.148 with no explicit bump. run 39329687123. (task 1002620)
|
|
3138
|
+
1.21.150 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3139
|
+
landed since 1.21.149 with no explicit bump. run 39331860470. (task 1002620)
|
|
3136
3140
|
---------------------------------------------------------------------------
|
|
3137
3141
|
```
|
|
@@ -67,7 +67,13 @@ function normalizeShipAssurance(a) {
|
|
|
67
67
|
//
|
|
68
68
|
// `deps` is injected so this is unit-testable with no network and no DB.
|
|
69
69
|
async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeError }) {
|
|
70
|
-
|
|
70
|
+
// Every probe miss is kept, not just logged: when the ONLY reason there is no
|
|
71
|
+
// proof is that this server cannot read GitHub at all, the refusal must say so
|
|
72
|
+
// (task 1004760). The toast instance merged real work and was told only that the
|
|
73
|
+
// land was "not proven" — its GitHub App existed but was never installed on the
|
|
74
|
+
// repo, which is a one-click owner fix nobody was told about.
|
|
75
|
+
const probeErrors = [];
|
|
76
|
+
const report = (e) => { probeErrors.push(e); if (onProbeError) onProbeError(e); };
|
|
71
77
|
// The tip is the durable proof (task 1547); the branch/PR status is only a
|
|
72
78
|
// WITNESS for the out-of-band fallback (task 1002615). A claim with no branch
|
|
73
79
|
// makes getPublishStatus throw BAD_BRANCH — that used to abort the whole probe
|
|
@@ -117,6 +123,26 @@ async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeE
|
|
|
117
123
|
return { assurance: { kind: 'no_artifact', reason } };
|
|
118
124
|
}
|
|
119
125
|
|
|
126
|
+
const noAccess = githubAccessCause(probeErrors);
|
|
127
|
+
if (noAccess) {
|
|
128
|
+
return {
|
|
129
|
+
refusal: {
|
|
130
|
+
code: 'ship_land_not_proven',
|
|
131
|
+
status: 409,
|
|
132
|
+
message: noAccess.message,
|
|
133
|
+
details: {
|
|
134
|
+
cause: noAccess.cause,
|
|
135
|
+
install_url: noAccess.install_url || null,
|
|
136
|
+
what_this_means: noAccess.message,
|
|
137
|
+
your_options: [
|
|
138
|
+
noAccess.fix,
|
|
139
|
+
'Nothing is lost: once the server can read GitHub, the reconciler proves the merge and ships the task on its own, with its credits.',
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
120
146
|
return {
|
|
121
147
|
refusal: {
|
|
122
148
|
code: 'ship_land_not_proven',
|
|
@@ -133,6 +159,37 @@ async function deriveShipAssurance({ taskId, body = {}, db, githubPush, onProbeE
|
|
|
133
159
|
}
|
|
134
160
|
|
|
135
161
|
|
|
162
|
+
// Was the land unprovable only because this server has no way to read GitHub?
|
|
163
|
+
// PURE over the probe errors deriveShipAssurance collected. Returns null when the
|
|
164
|
+
// probes could see GitHub (a genuine "not on main" answer stays the generic
|
|
165
|
+
// refusal), else { cause, message, fix, install_url? } (task 1004760).
|
|
166
|
+
// APP_NOT_INSTALLED the App exists but nobody installed it on the repo —
|
|
167
|
+
// the owner's one remaining setup step.
|
|
168
|
+
// PUSH_UNCONFIGURED / no GitHub credential at all, and no git checkout of
|
|
169
|
+
// LOCAL_PROOF_UNAVAILABLE the project to ask instead (a hosted instance).
|
|
170
|
+
function githubAccessCause(errors) {
|
|
171
|
+
const codes = (errors || []).map((e) => e && e.code);
|
|
172
|
+
const notInstalled = (errors || []).find((e) => e && e.code === 'APP_NOT_INSTALLED');
|
|
173
|
+
if (notInstalled) {
|
|
174
|
+
const repo = notInstalled.owner && notInstalled.repo ? `${notInstalled.owner}/${notInstalled.repo}` : 'the project repository';
|
|
175
|
+
const where = notInstalled.install_url ? `at ${notInstalled.install_url}` : "from the App's page in GitHub settings (Install App)";
|
|
176
|
+
return {
|
|
177
|
+
cause: 'github_app_not_installed',
|
|
178
|
+
install_url: notInstalled.install_url || null,
|
|
179
|
+
message: `The merge may well be on main, but this server cannot check: the project's GitHub App is not installed on ${repo}, so the server cannot read the repository.`,
|
|
180
|
+
fix: `The project owner installs the GitHub App on ${repo} ${where} — one click, no other setup.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (codes.includes('PUSH_UNCONFIGURED') || codes.includes('LOCAL_PROOF_UNAVAILABLE')) {
|
|
184
|
+
return {
|
|
185
|
+
cause: 'no_github_access',
|
|
186
|
+
message: 'The merge may well be on main, but this server cannot check: it has no GitHub access (no GitHub App or token) and no copy of the repository to look in.',
|
|
187
|
+
fix: "The project owner finishes the project's GitHub setup (Settings → GitHub) so the server can read the repository.",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
136
193
|
// Record WHY a ship was allowed, not merely that it happened. POST-COMMIT and
|
|
137
194
|
// best-effort by design: insertAuditLog uses the module pool rather than the
|
|
138
195
|
// ship's transaction client, and an audit miss must never roll back a landed ship
|
|
@@ -172,13 +229,14 @@ async function gateManualShip(res, opts) {
|
|
|
172
229
|
onProbeError: (e) => log && log.warn(`POST /tasks/${taskId}/ship: land probe failed (${e && e.message}) — no proof`),
|
|
173
230
|
});
|
|
174
231
|
if (derived.refusal) {
|
|
175
|
-
|
|
232
|
+
const { code, status, message, details } = derived.refusal;
|
|
233
|
+
res.fail(code, { status, message, details });
|
|
176
234
|
return null;
|
|
177
235
|
}
|
|
178
236
|
return derived.assurance;
|
|
179
237
|
}
|
|
180
238
|
|
|
181
239
|
module.exports = {
|
|
182
|
-
normalizeShipAssurance, deriveShipAssurance, recordShipAssurance,
|
|
240
|
+
normalizeShipAssurance, deriveShipAssurance, recordShipAssurance, githubAccessCause,
|
|
183
241
|
assertShipAssurance, gateManualShip, SHIP_BODY_SCHEMA, SHIP_ASSURANCE_KINDS: KINDS, MAX_REASON_CHARS,
|
|
184
242
|
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.150",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.150",
|
|
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.21.
|
|
3
|
+
"version": "1.21.150",
|
|
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/release-notes.json
CHANGED
|
@@ -9902,5 +9902,17 @@
|
|
|
9902
9902
|
"id": "1004732",
|
|
9903
9903
|
"text": "When the hub upgrades a project's core, your laptop now notices at the next session start or ship, says so in one line, and on a clean main catches up and reinstalls the new core by itself."
|
|
9904
9904
|
}
|
|
9905
|
+
],
|
|
9906
|
+
"1.21.149": [
|
|
9907
|
+
{
|
|
9908
|
+
"id": "1004759",
|
|
9909
|
+
"text": "When a project's site is too old to send Bongos feedback, the command now says 'Not sent' and keeps the note, instead of claiming it was sent."
|
|
9910
|
+
}
|
|
9911
|
+
],
|
|
9912
|
+
"1.21.150": [
|
|
9913
|
+
{
|
|
9914
|
+
"id": "1004760",
|
|
9915
|
+
"text": "When a project's GitHub App isn't installed, a refused ship now says so and links the one-click install, instead of a bare 'not proven'."
|
|
9916
|
+
}
|
|
9905
9917
|
]
|
|
9906
9918
|
}
|
|
@@ -14,8 +14,12 @@
|
|
|
14
14
|
// signed-in builder does nothing extra.
|
|
15
15
|
//
|
|
16
16
|
// Fast and quiet by design (the owner, 2026-10-06): the command prints one line
|
|
17
|
-
// and returns at once; a detached background process
|
|
18
|
-
//
|
|
17
|
+
// and returns at once; a detached background process sends to the hub. The one
|
|
18
|
+
// call made in the foreground is the receipt from the builder's own project, so
|
|
19
|
+
// "Sent" is never printed for a report the project could not vouch for (task
|
|
20
|
+
// 1004759: a project on an older core has no receipt door, and the background
|
|
21
|
+
// 404 was invisible). No task number comes back. Only the typed text
|
|
22
|
+
// (secret-scrubbed) and version stamps travel.
|
|
19
23
|
|
|
20
24
|
'use strict';
|
|
21
25
|
|
|
@@ -47,6 +51,8 @@ const REFUSALS = {
|
|
|
47
51
|
too_long: 'That is too long to send — keep it under 4,000 characters, or split it into two.',
|
|
48
52
|
local_only: 'This project runs only on your computer, so Cloud Bongos cannot confirm who sent the report. Nothing was sent.',
|
|
49
53
|
no_scrubber: 'Feedback was not sent: the secret filter that checks every report is missing from this install.',
|
|
54
|
+
project_too_old: "Not sent: this project's live site runs an older Bongos that cannot send feedback yet. It will work once the project is upgraded.",
|
|
55
|
+
receipt_failed: "Not sent: this project's live site did not confirm who sent it",
|
|
50
56
|
};
|
|
51
57
|
|
|
52
58
|
function stateDir() { return path.join(os.homedir(), '.config', ic.FALLBACK_DIR || 'cloudbongos'); }
|
|
@@ -98,12 +104,15 @@ function repeatKey(message) {
|
|
|
98
104
|
const first = String(message).split('\n').map((l) => l.trim().toLowerCase()).find(Boolean) || '';
|
|
99
105
|
return crypto.createHash('sha256').update(first).digest('hex').slice(0, 16);
|
|
100
106
|
}
|
|
101
|
-
|
|
107
|
+
// `record: false` only looks: the CLI records a report once its project has
|
|
108
|
+
// vouched for it, so a report that never left is not "already sent" (task 1004759).
|
|
109
|
+
function checkAndRecordRepeat(message, { file = path.join(stateDir(), 'feedback-sent.json'), now = Date.now(), record = true } = {}) {
|
|
102
110
|
let seen = {};
|
|
103
111
|
try { seen = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first report */ }
|
|
104
112
|
for (const [k, t] of Object.entries(seen)) if (!(now - t < REPEAT_WINDOW_MS)) delete seen[k];
|
|
105
113
|
const key = repeatKey(message);
|
|
106
114
|
if (Object.prototype.hasOwnProperty.call(seen, key)) return true;
|
|
115
|
+
if (!record) return false;
|
|
107
116
|
seen[key] = now;
|
|
108
117
|
try {
|
|
109
118
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -112,22 +121,29 @@ function checkAndRecordRepeat(message, { file = path.join(stateDir(), 'feedback-
|
|
|
112
121
|
return false;
|
|
113
122
|
}
|
|
114
123
|
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
124
|
+
// requestReceipt — the project vouches for this exact text. Foreground: one call
|
|
125
|
+
// to the builder's own project. A 404 means the project's core predates the
|
|
126
|
+
// receipt door (task 1004591), which no retry fixes until it is upgraded.
|
|
127
|
+
async function requestReceipt({ message, kind }, deps = {}) {
|
|
118
128
|
const api = deps.api || await cliClient();
|
|
119
129
|
const base = deps.base || ic.resolveApiBase({ session: loadSessionSync() });
|
|
120
|
-
const doFetch = deps.fetchImpl || fetch;
|
|
121
130
|
const sha256 = crypto.createHash('sha256').update(message, 'utf8').digest('hex');
|
|
122
131
|
const r = await api.request('POST', '/feedback/receipt', { body: { sha256, kind } });
|
|
123
|
-
if (!r.ok) return { ok: false, step: 'receipt', code: errCode(r.data) || `http_${r.status}` };
|
|
132
|
+
if (!r.ok) return { ok: false, step: 'receipt', code: r.status === 404 ? 'project_too_old' : (errCode(r.data) || `http_${r.status}`) };
|
|
124
133
|
const origin = new URL(base).origin;
|
|
125
134
|
const hub = String((r.data && r.data.hub) || origin).replace(/\/+$/, '');
|
|
135
|
+
return { ok: true, receipt: r.data.receipt, origin, hub };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// sendToHub — the vouched report straight to the hub. Returns an outcome
|
|
139
|
+
// object; never prints. Runs in the background child.
|
|
140
|
+
async function sendToHub({ message, kind, receipt, origin, hub }, deps = {}) {
|
|
141
|
+
const doFetch = deps.fetchImpl || fetch;
|
|
126
142
|
const res = await doFetch(`${hub}/api/bongos/sso/feedback/direct`, {
|
|
127
143
|
method: 'POST',
|
|
128
144
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
129
145
|
body: JSON.stringify({
|
|
130
|
-
origin, receipt
|
|
146
|
+
origin, receipt, message, kind,
|
|
131
147
|
context: { cli_version: cliVersion(), os: `${os.platform()} ${os.release()}` },
|
|
132
148
|
}),
|
|
133
149
|
signal: AbortSignal.timeout(15000),
|
|
@@ -138,13 +154,31 @@ async function deliver({ message, kind }, deps = {}) {
|
|
|
138
154
|
return { ok: false, step: 'hub', code: errCode(body) || `http_${res.status}` };
|
|
139
155
|
}
|
|
140
156
|
|
|
157
|
+
// deliver — both halves in one go.
|
|
158
|
+
async function deliver(job, deps = {}) {
|
|
159
|
+
const r = await requestReceipt(job, deps);
|
|
160
|
+
if (!r.ok) return r;
|
|
161
|
+
return sendToHub({ ...job, receipt: r.receipt, origin: r.origin, hub: r.hub }, deps);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A report the project could not vouch for is kept, so it can be sent after the
|
|
165
|
+
// upgrade (`--message-file <it>`) instead of being written again.
|
|
166
|
+
function keepUnsent(message, { dir = stateDir(), now = new Date() } = {}) {
|
|
167
|
+
try {
|
|
168
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
169
|
+
const file = path.join(dir, `feedback-unsent-${now.toISOString().replace(/[:.]/g, '-')}.md`);
|
|
170
|
+
fs.writeFileSync(file, message, { mode: 0o600 });
|
|
171
|
+
return file;
|
|
172
|
+
} catch { return null; }
|
|
173
|
+
}
|
|
174
|
+
|
|
141
175
|
// The background child: read the queued report, deliver it, record the outcome
|
|
142
176
|
// where a curious builder (or a maintainer helping them) can look.
|
|
143
177
|
async function runDeliverChild(file) {
|
|
144
178
|
let job;
|
|
145
179
|
try { job = JSON.parse(fs.readFileSync(file, 'utf8')); } finally { fs.rmSync(path.dirname(file), { recursive: true, force: true }); }
|
|
146
180
|
let outcome;
|
|
147
|
-
try { outcome = await deliver(job); } catch (e) { outcome = { ok: false, step: 'network', code: String(e && e.message || e).slice(0, 200) }; }
|
|
181
|
+
try { outcome = job.receipt ? await sendToHub(job) : await deliver(job); } catch (e) { outcome = { ok: false, step: 'network', code: String(e && e.message || e).slice(0, 200) }; }
|
|
148
182
|
try {
|
|
149
183
|
fs.writeFileSync(path.join(stateDir(), 'feedback-last.json'), JSON.stringify({ at: new Date().toISOString(), ...outcome }), { mode: 0o600 });
|
|
150
184
|
} catch { /* nowhere to record — the report itself is what mattered */ }
|
|
@@ -177,16 +211,27 @@ async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
177
211
|
if (isLocalBase(base)) { console.error(REFUSALS.local_only); return 1; }
|
|
178
212
|
|
|
179
213
|
const job = { message: scrubbed.text, kind };
|
|
180
|
-
|
|
214
|
+
const checkRepeat = deps.checkRepeat || checkAndRecordRepeat;
|
|
215
|
+
if (checkRepeat(job.message, { record: false })) {
|
|
181
216
|
console.log('Already sent — the Bongos team has this one.');
|
|
182
217
|
return 0;
|
|
183
218
|
}
|
|
219
|
+
// The receipt in the foreground: "Sent" only once the project has vouched.
|
|
220
|
+
const r = await (deps.requestReceipt || requestReceipt)(job, { base });
|
|
221
|
+
if (!r.ok) {
|
|
222
|
+
const kept = (deps.keepUnsent || keepUnsent)(job.message);
|
|
223
|
+
const why = r.code === 'project_too_old' ? REFUSALS.project_too_old : `${REFUSALS.receipt_failed} (${r.code}).`;
|
|
224
|
+
console.error(kept ? `${why} Your note is saved in ${kept}` : why);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
checkRepeat(job.message);
|
|
228
|
+
const vouched = { ...job, receipt: r.receipt, origin: r.origin, hub: r.hub };
|
|
184
229
|
if (hasFlag('--wait', argv)) {
|
|
185
|
-
const outcome = await (deps.
|
|
230
|
+
const outcome = await (deps.sendToHub || sendToHub)(vouched);
|
|
186
231
|
console.log(outcome.ok ? SENT_LINE : `Not sent (${outcome.step}: ${outcome.code}).`);
|
|
187
232
|
return outcome.ok ? 0 : 1;
|
|
188
233
|
}
|
|
189
|
-
(deps.spawnDelivery || spawnDelivery)(
|
|
234
|
+
(deps.spawnDelivery || spawnDelivery)(vouched);
|
|
190
235
|
console.log(SENT_LINE);
|
|
191
236
|
return 0;
|
|
192
237
|
}
|
|
@@ -201,4 +246,4 @@ if (require.main === module) {
|
|
|
201
246
|
});
|
|
202
247
|
}
|
|
203
248
|
|
|
204
|
-
module.exports = { main, deliver, readMessage, REFUSALS, scrubReport, isLocalBase, repeatKey, checkAndRecordRepeat, SENT_LINE };
|
|
249
|
+
module.exports = { main, deliver, requestReceipt, sendToHub, keepUnsent, readMessage, REFUSALS, scrubReport, isLocalBase, repeatKey, checkAndRecordRepeat, SENT_LINE };
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.150'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -326,6 +326,8 @@ async function runCli(argv, extra = {}) {
|
|
|
326
326
|
requireSession: async () => ({ token: 't' }),
|
|
327
327
|
base: 'https://mercury.cloudbongos.com',
|
|
328
328
|
checkRepeat: () => false,
|
|
329
|
+
requestReceipt: async () => ({ ok: true, receipt: 'R'.repeat(24), origin: 'https://mercury.cloudbongos.com', hub: 'https://cloudbongos.com' }),
|
|
330
|
+
keepUnsent: () => null,
|
|
329
331
|
spawnDelivery: (job) => spawned.push(job),
|
|
330
332
|
...extra,
|
|
331
333
|
});
|
|
@@ -337,7 +339,49 @@ test('the CLI prints one line and hands the network to the background', async ()
|
|
|
337
339
|
const r = await runCli(['claim', 'hung']);
|
|
338
340
|
assert.equal(r.code, 0);
|
|
339
341
|
assert.deepEqual(r.out, ['Sent to the Bongos team.']);
|
|
340
|
-
assert.deepEqual(r.spawned, [{ message: 'claim hung', kind: 'bug' }]);
|
|
342
|
+
assert.deepEqual(r.spawned, [{ message: 'claim hung', kind: 'bug', receipt: 'R'.repeat(24), origin: 'https://mercury.cloudbongos.com', hub: 'https://cloudbongos.com' }]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Task 1004759 (toast, core 1.21.20): the project's live site had no receipt
|
|
346
|
+
// door, the background child got a 404, and the builder had been told "Sent".
|
|
347
|
+
test('a project too old to vouch: "Not sent" in the foreground, the note kept, nothing recorded as sent', async () => {
|
|
348
|
+
const kept = []; const recorded = [];
|
|
349
|
+
const r = await runCli(['--idea', 'one', 'skill', 'for', 'project', 'purpose'], {
|
|
350
|
+
requestReceipt: async () => ({ ok: false, step: 'receipt', code: 'project_too_old' }),
|
|
351
|
+
keepUnsent: (m) => { kept.push(m); return '/home/x/.config/cloudbongos/feedback-unsent-1.md'; },
|
|
352
|
+
checkRepeat: (m, o = {}) => { if (o.record !== false) recorded.push(m); return false; },
|
|
353
|
+
});
|
|
354
|
+
assert.equal(r.code, 1);
|
|
355
|
+
assert.deepEqual(r.out, [], 'never says Sent');
|
|
356
|
+
assert.equal(r.spawned.length, 0);
|
|
357
|
+
assert.match(r.err.join('\n'), /older Bongos/);
|
|
358
|
+
assert.match(r.err.join('\n'), /feedback-unsent-1\.md/, 'says where the note is');
|
|
359
|
+
assert.deepEqual(kept, ['one skill for project purpose']);
|
|
360
|
+
assert.deepEqual(recorded, [], 'a report that never left is not "already sent"');
|
|
361
|
+
const other = await runCli(['x'], { requestReceipt: async () => ({ ok: false, step: 'receipt', code: 'not_signed_in' }) });
|
|
362
|
+
assert.equal(other.code, 1);
|
|
363
|
+
assert.match(other.err.join('\n'), /did not confirm.*not_signed_in/);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('the repeat guard records only after the project vouched', async () => {
|
|
367
|
+
const calls = [];
|
|
368
|
+
await runCli(['x'], { checkRepeat: (m, o = {}) => { calls.push(o.record === false ? 'look' : 'record'); return false; } });
|
|
369
|
+
assert.deepEqual(calls, ['look', 'record']);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('requestReceipt: a 404 from the project reads as project_too_old', async () => {
|
|
373
|
+
const { requestReceipt } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
|
|
374
|
+
const api = { request: async () => ({ ok: false, status: 404, data: { error: 'Not found' } }) };
|
|
375
|
+
const r = await requestReceipt({ message: 'x', kind: 'idea' }, { api, base: 'https://toast.cloudbongos.com' });
|
|
376
|
+
assert.deepEqual(r, { ok: false, step: 'receipt', code: 'project_too_old' });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('keepUnsent writes the note where the builder can send it again', () => {
|
|
380
|
+
const { keepUnsent } = require_(path.join(ROOT, 'scripts/gds/feedback-send.js'));
|
|
381
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fb-unsent-'));
|
|
382
|
+
const file = keepUnsent('the note', { dir, now: new Date('2026-10-19T10:00:00Z') });
|
|
383
|
+
assert.equal(fs.readFileSync(file, 'utf8'), 'the note');
|
|
384
|
+
assert.match(path.basename(file), /^feedback-unsent-2026-10-19T10-00-00-000Z\.md$/);
|
|
341
385
|
});
|
|
342
386
|
|
|
343
387
|
test('the CLI refuses a project only this computer can reach, and sends nothing', async () => {
|
package/tests/ship_assurance.mjs
CHANGED
|
@@ -321,5 +321,69 @@ await t('no tip + no PR for the task → the 409 refusal, and getPublishStatus i
|
|
|
321
321
|
assert.equal(statusCalls, 0);
|
|
322
322
|
});
|
|
323
323
|
|
|
324
|
+
// task 1004760 — the toast instance merged real work and was refused with a bare
|
|
325
|
+
// "not proven": its GitHub App was never installed on the repo, so every probe threw
|
|
326
|
+
// APP_NOT_INSTALLED and the server could not look. The refusal must NAME that cause
|
|
327
|
+
// and the one-click fix, not leave the builder guessing.
|
|
328
|
+
console.log('\nthe refusal names a server that cannot read GitHub (task 1004760)');
|
|
329
|
+
const notInstalled = () => {
|
|
330
|
+
const e = new Error('APP_NOT_INSTALLED');
|
|
331
|
+
e.code = 'APP_NOT_INSTALLED';
|
|
332
|
+
e.owner = 'acme';
|
|
333
|
+
e.repo = 'widget';
|
|
334
|
+
e.install_url = 'https://github.com/apps/widget-app/installations/new';
|
|
335
|
+
return e;
|
|
336
|
+
};
|
|
337
|
+
await t('App not installed → the refusal says so, with the install link', async () => {
|
|
338
|
+
const r = await deriveShipAssurance({
|
|
339
|
+
taskId: 1,
|
|
340
|
+
body: {},
|
|
341
|
+
db: { getClaimPublishedSha: async () => TIP },
|
|
342
|
+
githubPush: {
|
|
343
|
+
findTaskPullRequest: async () => { throw notInstalled(); },
|
|
344
|
+
getPublishStatus: async () => null,
|
|
345
|
+
landProvenForTask: async () => { throw notInstalled(); },
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
assert.equal(r.refusal.code, 'ship_land_not_proven');
|
|
349
|
+
assert.equal(r.refusal.details.cause, 'github_app_not_installed');
|
|
350
|
+
assert.match(r.refusal.message, /not installed on acme\/widget/);
|
|
351
|
+
assert.equal(r.refusal.details.install_url, 'https://github.com/apps/widget-app/installations/new');
|
|
352
|
+
assert.ok(r.refusal.details.your_options.some((o) => o.includes('installations/new')));
|
|
353
|
+
});
|
|
354
|
+
await t('no credential and no checkout → the refusal says the server has no GitHub access', async () => {
|
|
355
|
+
for (const code of ['PUSH_UNCONFIGURED', 'LOCAL_PROOF_UNAVAILABLE']) {
|
|
356
|
+
const r = await deriveShipAssurance({
|
|
357
|
+
taskId: 1,
|
|
358
|
+
body: {},
|
|
359
|
+
db: { getClaimPublishedSha: async () => TIP },
|
|
360
|
+
githubPush: {
|
|
361
|
+
findTaskPullRequest: async () => null,
|
|
362
|
+
landProvenForTask: async () => { const e = new Error(code); e.code = code; throw e; },
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
assert.equal(r.refusal.details.cause, 'no_github_access', code);
|
|
366
|
+
assert.match(r.refusal.message, /no GitHub access/);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
await t('a server that CAN read GitHub keeps the plain refusal (the tip really is not on main)', async () => {
|
|
370
|
+
const r = await deriveShipAssurance({
|
|
371
|
+
taskId: 1,
|
|
372
|
+
body: {},
|
|
373
|
+
db: { getClaimPublishedSha: async () => TIP },
|
|
374
|
+
githubPush: {
|
|
375
|
+
findTaskPullRequest: async () => null,
|
|
376
|
+
landProvenForTask: async () => ({ proven: false, reason: 'not in main' }),
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
assert.equal(r.refusal.code, 'ship_land_not_proven');
|
|
380
|
+
assert.equal(r.refusal.details.cause, undefined);
|
|
381
|
+
assert.equal(r.refusal.message, undefined);
|
|
382
|
+
});
|
|
383
|
+
await t('the gate sends the cause as the error MESSAGE, which is what the CLI prints', () => {
|
|
384
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/ship-assurance.js'), 'utf8');
|
|
385
|
+
assert.match(src, /res\.fail\(code, \{ status, message, details \}\)/);
|
|
386
|
+
});
|
|
387
|
+
|
|
324
388
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
325
389
|
process.exit(failed ? 1 : 0);
|