@bongos/core 1.21.61 → 1.21.63
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 +62 -42
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0366-hall-planning-is-forms-and-a-goal-made-there-carries-its-own-spec-task.md +85 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +7 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +301 -301
- package/docs/copy-registry.json +342 -342
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +687 -670
- package/modules/lifecycle/merge-lock.js +53 -9
- package/modules/lifecycle/ship-preflight.js +40 -22
- package/modules/platform-identity/guilds.js +32 -0
- package/modules/platform-identity/routes/guilds.js +10 -3
- package/modules/public-landing/public/assets/cosmos.css +29 -3
- package/modules/public-landing/public/projects.html +294 -4
- package/modules/public-landing/public/projects.probes.json +18 -0
- package/modules/public-landing/public/projects.states.json +3 -0
- package/modules/ui-design/kit/fixtures/guilds.json +7 -0
- package/modules/ui-design/kit/fixtures/me__guild-requests.json +9 -0
- package/modules/ui-design/kit/serve.js +41 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/ship-grade.js +13 -6
- package/scripts/gds/ship-merge-gate.js +26 -0
- package/scripts/gds/ship-merge.js +12 -3
- package/scripts/gds/ship-state.js +42 -5
- package/src/module-api.js +1 -1
- package/tests/guilds_api.mjs +31 -0
- package/tests/guilds_db.mjs +7 -0
- package/tests/hub_engagement_cards.mjs +9 -6
- package/tests/hub_guild_cards.mjs +392 -0
- package/tests/merge_lock.mjs +58 -0
- package/tests/ship_merge_gate.mjs +63 -0
- package/tests/ship_preflight.mjs +46 -1
- package/tests/ship_vendored_instance.mjs +259 -0
|
@@ -187,6 +187,24 @@
|
|
|
187
187
|
{ "name": "guild cards: the invited card applies on its own, with no note", "continue": true,
|
|
188
188
|
"steps": [["click","#engageList .engageRow:nth-child(2) .btn"],["wait",400]],
|
|
189
189
|
"expect": { "text": [["#engageList .engageRow:nth-child(2) .btn", "Applied"]], "hasClass": [["#engageList .engageRow:nth-child(2) .projMsg", "ok"]] } },
|
|
190
|
+
{"name": "guild invite cards (task 1004583): each says what joining shows and, before accept, that your numbers count — with the switch right there, set to count", "auth": true, "width": 1440, "url": "/projects?view=mine",
|
|
191
|
+
"steps": [["wait",900]],
|
|
192
|
+
"expect": {"visible": ["#guildInviteList", "#guildInviteList .guildRow:first-child .guildCount"], "count": [["#guildInviteList .guildRow", 2], ["#guildInviteList .guildRow:first-child a.linklike:not([hidden])", 1], ["#guildInviteList .guildRow:nth-child(2) a.linklike:not([hidden])", 0]], "text": [["#guildInviteList .guildRow:first-child .mineRow__name", "You’re invited to join Open Hands"], ["#guildInviteList .guildRow:first-child .guildCount__what", "Your numbers count toward this guild’s rounded totals — even while your record is hidden or your account is private. They are never shown on their own: only inside totals rounded down to a band (10+, 20+, 50+ …) and counted once a week. Leave them out of any guild you’d rather not share them with."]], "checked": "#guildInviteList .guildRow:first-child input[value=\"on\"]"} },
|
|
193
|
+
{"name": "guild invite: Leave them out says what that means before anything is sent", "continue": true,
|
|
194
|
+
"steps": [["click","#guildInviteList .guildRow:first-child .guildCount__opt:nth-child(2)"],["wait",150]],
|
|
195
|
+
"expect": {"checked": "#guildInviteList .guildRow:first-child input[value=\"off\"]", "text": [["#guildInviteList .guildRow:first-child .guildCount__what", "Your numbers are left out of this guild’s totals, from its next weekly count."]]} },
|
|
196
|
+
{"name": "guild invite: Accept sends the card's visibility and the switch (the stub refuses an accept without the card's visibility) and the card says where your numbers went", "continue": true,
|
|
197
|
+
"steps": [["click","#guildInviteList .guildRow:first-child .btn"],["wait",400]],
|
|
198
|
+
"expect": {"hasClass": [["#guildInviteList .guildRow:first-child .projMsg", "ok"], ["#guildInviteList .guildRow:first-child .btn", "ghost"]], "text": [["#guildInviteList .guildRow:first-child .btn", "Joined"], ["#guildInviteList .guildRow:first-child .projMsg", "You’ve joined Open Hands, and your numbers are left out of its totals."]]} },
|
|
199
|
+
{"name": "guild invite: Decline drops the card, silently", "continue": true,
|
|
200
|
+
"steps": [["click","#guildInviteList .guildRow:nth-child(2) .btn.ghost"],["wait",400]],
|
|
201
|
+
"expect": {"count": [["#guildInviteList .guildRow", 1]]} },
|
|
202
|
+
{"name": "join request (task 1004583): the owner of Night Shift accepts one and declines the other, from the hub", "continue": true,
|
|
203
|
+
"steps": [["click","#guildRequestList .guildRow:first-child .btn"],["wait",400],["click","#guildRequestList .guildRow:nth-child(2) .btn.ghost"],["wait",400]],
|
|
204
|
+
"expect": {"count": [["#guildRequestList .guildRow", 1]], "hasClass": [["#guildRequestList .guildRow:first-child .projMsg", "ok"]], "text": [["#guildRequestList .guildRow:first-child .btn", "Accepted"], ["#guildRequestList .guildRow:first-child .projMsg", "Tomás Ek is in Night Shift now."]]} },
|
|
205
|
+
{"name": "guild cards at 320: the switch stacks and nothing scrolls sideways", "auth": true, "width": 320, "url": "/projects?view=mine",
|
|
206
|
+
"steps": [["wait",900]],
|
|
207
|
+
"expect": {"noOverflow": true, "visible": ["#guildInviteList .guildRow:first-child .guildCount"]} },
|
|
190
208
|
{ "name": "invite a guild (task 1002303, R27): a /g/ address gives one pending line per member on its public page, each a link to the hall", "auth": true, "width": 1440, "url": "/projects?view=manage&id=101",
|
|
191
209
|
"steps": [["wait",900],["fill","#mInviteGuild","/g/forge-crew"],["click","#mInvite [data-invite-guild]"],["wait",400]],
|
|
192
210
|
"expect": { "count": [["#mInviteList li", 4]], "value": [["#mInviteGuild", ""]],
|
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
"in-mine-engagements": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["focus","#engageList .engageRow:first-child .btn"],["wait",200]], "expect": { "visible": ["#mineInvites", "#invitesList", "#engageList"], "count": [["#engageList .engageRow", 2], ["#engageList .engageRow .mineJoinNote:not([hidden])", 0]], "text": [["#engageList .engageRow:nth-child(1) .mineRow__name", "Forge Crew is applying to Tidepool — apply with them?"], ["#engageList .engageRow:nth-child(1) .mineRow__host", "tidepool.cloudbongos.com"], ["#engageList .engageRow:nth-child(2) .mineRow__name", "Lantern Works invited Forge Crew"], ["#engageList .engageRow:nth-child(1) .mineRow__hint", "You apply as yourself — add a note for the owner if you like."], ["#engageList .engageRow:nth-child(1) .btn", "Apply"]] } },
|
|
31
31
|
"in-mine-engagement-note": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","I like the boring parts too: migrations and the deploy path."],["wait",150]], "expect": { "visible": ["#engageList .engageRow:first-child .mineJoinNote"], "hidden": ["#engageList .engageRow:first-child .linklike"], "value": [["#engageList .engageRow:first-child .mineJoinNote", "I like the boring parts too: migrations and the deploy path."]] } },
|
|
32
32
|
"in-mine-engagement-applied": { "auth": true, "url": "/projects?view=mine", "actions": [["wait",900],["click","#engageList .engageRow:first-child .linklike"],["fill","#engageList .engageRow:first-child .mineJoinNote","I like the boring parts too: migrations and the deploy path."],["click","#engageList .engageRow:first-child .btn"],["wait",400]], "expect": { "hidden": ["#engageList .engageRow:first-child .mineJoinNote", "#engageList .engageRow:first-child .linklike"], "hasClass": [["#engageList .engageRow:first-child .projMsg", "ok"], ["#engageList .engageRow:first-child .btn", "ghost"]], "text": [["#engageList .engageRow:first-child .btn", "Applied"], ["#engageList .engageRow:first-child .projMsg", "Requested access. The project’s owner admits new builders; once approved, sign in to enter."], ["#engageList .engageRow:nth-child(2) .btn", "Apply"]] } },
|
|
33
|
+
"in-mine-guild-cards": {"auth": true, "url": "/projects?view=mine", "actions": [["wait", 900], ["focus", "#guildInviteList .guildRow:first-child .btn"], ["wait", 200]], "expect": {"visible": ["#mineInvites", "#guildInviteList", "#guildRequestList", "#guildInviteList .guildRow:first-child .guildCount"], "count": [["#guildInviteList .guildRow", 2], ["#guildRequestList .guildRow", 2], ["#guildInviteList .guildRow .guildCount", 2], ["#guildRequestList .guildRow .guildCount", 0]], "text": [["#guildInviteList .guildRow:nth-child(1) .mineRow__name", "You’re invited to join Open Hands"], ["#guildInviteList .guildRow:nth-child(2) .mineRow__name", "You’re invited to join Quiet Room"], ["#guildInviteList .guildRow:nth-child(1) .guildCount__what", "Your numbers count toward this guild’s rounded totals — even while your record is hidden or your account is private. They are never shown on their own: only inside totals rounded down to a band (10+, 20+, 50+ …) and counted once a week. Leave them out of any guild you’d rather not share them with."], ["#guildRequestList .guildRow:nth-child(1) .mineRow__name", "Tomás Ek asked to join Night Shift"], ["#guildRequestList .guildRow:nth-child(2) .mineRow__name", "@kai-lowe asked to join Night Shift"]], "checked": "#guildInviteList .guildRow:first-child input[value=\"on\"]"}},
|
|
34
|
+
"in-mine-guild-count-off": {"auth": true, "url": "/projects?view=mine", "actions": [["wait", 900], ["click", "#guildInviteList .guildRow:first-child .guildCount__opt:nth-child(2)"], ["wait", 150]], "expect": {"checked": "#guildInviteList .guildRow:first-child input[value=\"off\"]", "text": [["#guildInviteList .guildRow:first-child .guildCount__what", "Your numbers are left out of this guild’s totals, from its next weekly count."], ["#guildInviteList .guildRow:first-child .btn", "Accept"]]}},
|
|
35
|
+
"in-mine-guild-joined": {"auth": true, "url": "/projects?view=mine", "actions": [["wait", 900], ["click", "#guildInviteList .guildRow:first-child .guildCount__opt:nth-child(2)"], ["click", "#guildInviteList .guildRow:first-child .btn"], ["wait", 400]], "expect": {"hidden": ["#guildInviteList .guildRow:first-child .btn.ghost + .btn"], "hasClass": [["#guildInviteList .guildRow:first-child .projMsg", "ok"], ["#guildInviteList .guildRow:first-child .btn", "ghost"]], "text": [["#guildInviteList .guildRow:first-child .btn", "Joined"], ["#guildInviteList .guildRow:first-child .projMsg", "You’ve joined Open Hands, and your numbers are left out of its totals."]]}},
|
|
33
36
|
"in-manage-app-live": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStat","#mApp .appVars"],"hidden":[]} },
|
|
34
37
|
"in-manage-app-building": { "auth": true, "url": "/projects?view=manage&id=102", "actions": [["wait",900],["focus","#mApp a"]], "expect": {"visible":["#mAppCard","#mApp .appStages"],"hidden":[]} },
|
|
35
38
|
"in-manage-app-failed": { "auth": true, "url": "/projects?view=manage&id=105", "actions": [["wait",900],["focus","#mApp .appRetryBtn"]], "expect": {"visible":["#mAppCard","#mApp p.err","#mApp .appRetryBtn"],"hidden":["#mApp .appRetry"]} },
|
|
@@ -51,6 +51,13 @@
|
|
|
51
51
|
{ "handle": "sol-reyes", "display_name": "Sol Reyes", "avatar_url": null, "membership_kind": "member" }
|
|
52
52
|
],
|
|
53
53
|
"count": 3
|
|
54
|
+
},
|
|
55
|
+
"requests": {
|
|
56
|
+
"incoming": [
|
|
57
|
+
{ "id": "601", "created_at": "2026-10-04T19:20:00.000Z", "seen_at": null, "handle": "tomas-ek", "display_name": "Tomás Ek", "avatar_url": null },
|
|
58
|
+
{ "id": "602", "created_at": "2026-10-02T11:05:00.000Z", "seen_at": null, "handle": "kai-lowe", "display_name": null, "avatar_url": null }
|
|
59
|
+
],
|
|
60
|
+
"outgoing": []
|
|
54
61
|
}
|
|
55
62
|
},
|
|
56
63
|
"open-hands": {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"incoming": [
|
|
3
|
+
{ "id": "501", "created_at": "2026-10-04T15:10:00.000Z", "seen_at": null, "slug": "open-hands", "name": "Open Hands", "description": "A newer guild for first-time builders. Ask anything.", "visibility": "public" },
|
|
4
|
+
{ "id": "502", "created_at": "2026-10-03T08:45:00.000Z", "seen_at": "2026-10-03T09:00:00.000Z", "slug": "quiet-room", "name": "Quiet Room", "description": "", "visibility": "unlisted" }
|
|
5
|
+
],
|
|
6
|
+
"outgoing": [],
|
|
7
|
+
"incoming_count": 2,
|
|
8
|
+
"outgoing_count": 0
|
|
9
|
+
}
|
|
@@ -512,6 +512,44 @@ function guildInvitePost(res, body) {
|
|
|
512
512
|
const invites = (g.public.members || []).map((m) => ({ handle: m.handle, display_name: m.display_name || null, invite_url: 'https://mercury.example.test/builders/watch?invite=' + encodeURIComponent(m.handle) }));
|
|
513
513
|
return json(res, 200, { invited: false, guild: { slug: g.public.guild.slug, name: g.public.guild.name }, invites, count: invites.length });
|
|
514
514
|
}
|
|
515
|
+
// The guild cards in the mine view's invitations (task 1004583): GET /me/guild-requests is
|
|
516
|
+
// the invites to the builder (me__guild-requests.json, each card with the guild's CURRENT
|
|
517
|
+
// visibility) plus `owner_incoming`, the join requests to every held guild they own (its
|
|
518
|
+
// `requests` in guilds.json), each naming its guild — one read, as the route answers it. The
|
|
519
|
+
// stub mirrors the routes' refusals: an invitee's accept must echo the card's visibility (400
|
|
520
|
+
// without one, 409 with the guild's current one when it differs), only `visibility` and
|
|
521
|
+
// `counted_in_totals` are taken, a row the caller may not answer is one 404, and a decline is
|
|
522
|
+
// a SILENT 204. Nothing is written: a reload draws the same cards, as the join relay's
|
|
523
|
+
// 'requested' does.
|
|
524
|
+
function ownedRequests() {
|
|
525
|
+
return Object.values(guildsHeld()).filter((g) => g.members && g.members.you.membership_kind === 'owner')
|
|
526
|
+
.flatMap((g) => ((g.requests && g.requests.incoming) || []).map((r) => ({ ...r, guild: { slug: g.members.guild.slug, name: g.members.guild.name } })));
|
|
527
|
+
}
|
|
528
|
+
function myGuildRequests(res) {
|
|
529
|
+
const q = heldFixture('me__guild-requests');
|
|
530
|
+
const owner = ownedRequests();
|
|
531
|
+
return json(res, 200, { ...q, owner_incoming: owner, owner_incoming_count: owner.length });
|
|
532
|
+
}
|
|
533
|
+
function guildRequestAnswer(res, id, verb, body) {
|
|
534
|
+
const notFound = () => refuse(res, 404, 'guild_request_not_found', 'guild_request_not_found');
|
|
535
|
+
if (!/^\d{1,19}$/.test(id)) return notFound();
|
|
536
|
+
const bad = Object.keys(body).find((k) => !['visibility', 'counted_in_totals'].includes(k));
|
|
537
|
+
if (bad) return refuse(res, 400, 'validation_failed', `unknown field: ${bad}`);
|
|
538
|
+
if (body.visibility !== undefined && !['public', 'unlisted'].includes(body.visibility)) return refuse(res, 400, 'validation_failed', 'visibility must be public or unlisted');
|
|
539
|
+
if (body.counted_in_totals !== undefined && typeof body.counted_in_totals !== 'boolean') return refuse(res, 400, 'validation_failed', 'counted_in_totals must be a boolean');
|
|
540
|
+
const invite = (heldFixture('me__guild-requests').incoming || []).find((r) => r.id === id);
|
|
541
|
+
const request = Object.values(guildsHeld()).some((g) => g.members && g.members.you.membership_kind === 'owner'
|
|
542
|
+
&& ((g.requests && g.requests.incoming) || []).some((r) => r.id === id));
|
|
543
|
+
if (!invite && !request) return notFound();
|
|
544
|
+
if (verb === 'decline') { res.writeHead(204, { 'cache-control': 'no-store' }); return res.end(); }
|
|
545
|
+
if (invite) {
|
|
546
|
+
if (!body.visibility) return refuse(res, 400, 'guild_card_visibility_required', 'send the visibility your invite card showed');
|
|
547
|
+
const held = guildsHeld()[invite.slug];
|
|
548
|
+
const now = (held && held.members && held.members.guild.visibility) || invite.visibility;
|
|
549
|
+
if (body.visibility !== now) return json(res, 409, { error: { code: 'guild_visibility_changed', message: 'guild_visibility_changed', details: { visibility: now } } });
|
|
550
|
+
}
|
|
551
|
+
return json(res, 200, { status: 'accepted' });
|
|
552
|
+
}
|
|
515
553
|
function guildEngagementLive(slug, origin) {
|
|
516
554
|
const g = typeof slug === 'string' && guildsHeld()[slug.toLowerCase()];
|
|
517
555
|
if (!g || !g.members) return false;
|
|
@@ -540,6 +578,9 @@ const server = http.createServer((req, res) => {
|
|
|
540
578
|
if (guild && guild[2] === '/members' && req.method === 'GET') return AUTH ? guildRead(res, guild[1], 'members', origin) : json(res, 401, UNAUTH);
|
|
541
579
|
if (guild && guild[2] === '/membership' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => guildMembershipPatch(res, guild[1], body)) : json(res, 401, UNAUTH);
|
|
542
580
|
if (guild && guild[2] === '/engagements' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildEngagementPost(res, guild[1], body)) : json(res, 401, UNAUTH);
|
|
581
|
+
if (rest === '/me/guild-requests' && req.method === 'GET') return AUTH ? myGuildRequests(res) : json(res, 401, UNAUTH);
|
|
582
|
+
const answer = rest.match(/^\/guild-requests\/([^/]+)\/(accept|decline)$/);
|
|
583
|
+
if (answer && req.method === 'POST') return AUTH ? readBody(req, (body) => guildRequestAnswer(res, answer[1], answer[2], body)) : json(res, 401, UNAUTH);
|
|
543
584
|
if (rest === '/provisioning/instances' && req.method === 'GET') return AUTH ? instances(res, null, false) : json(res, 401, UNAUTH);
|
|
544
585
|
// the planet-physics questions' pre-create check (task 1004418): nothing stops here —
|
|
545
586
|
// the kit carries no screen. Of the soft-limit table it mirrors only the three hide
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.63",
|
|
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.63",
|
|
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.63",
|
|
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
|
@@ -9138,5 +9138,21 @@
|
|
|
9138
9138
|
"id": "1004570",
|
|
9139
9139
|
"text": "A project owner now sees a Danger zone in their project's settings, with links straight to the place where they can take the project offline or delete it. Nobody else sees it, and self-hosted projects don't show it."
|
|
9140
9140
|
}
|
|
9141
|
+
],
|
|
9142
|
+
"1.21.62": [
|
|
9143
|
+
{
|
|
9144
|
+
"id": "1003461",
|
|
9145
|
+
"text": "Decided how planning works in the hall: simple forms for creating tasks, goals and criteria, while version planning stays a guided Claude session. A goal made in the hall now comes with its own 'spec this goal' task that the r"
|
|
9146
|
+
},
|
|
9147
|
+
{
|
|
9148
|
+
"id": "1004584",
|
|
9149
|
+
"text": "Shipping finished tasks now works for projects that install Bongos as a package, so their work lands instead of getting stuck halfway."
|
|
9150
|
+
}
|
|
9151
|
+
],
|
|
9152
|
+
"1.21.63": [
|
|
9153
|
+
{
|
|
9154
|
+
"id": "1004583",
|
|
9155
|
+
"text": "Builders can now accept or decline a guild invite from their projects page, choosing first whether their numbers count toward the guild's totals, and guild owners can answer join requests there too."
|
|
9156
|
+
}
|
|
9141
9157
|
]
|
|
9142
9158
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
11
|
const crypto = require('node:crypto');
|
|
12
|
+
const path = require('node:path');
|
|
12
13
|
const { spawn } = require('node:child_process');
|
|
13
14
|
const { cliClient } = require('./cli-lib');
|
|
14
15
|
const grader = require('../../modules/grading/grader');
|
|
@@ -22,7 +23,10 @@ const grader = require('../../modules/grading/grader');
|
|
|
22
23
|
const { hasCopyOrVisualWork } = require('../../modules/lifecycle/cascade.js');
|
|
23
24
|
const { resolveBuilderModel } = require('./ship-memory-sync.js');
|
|
24
25
|
const { argvOut, gitArgvOk, gitOk, shellOk } = require('./ship-git.js');
|
|
25
|
-
const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, REPO_ROOT, noteServerReward } = require('./ship-state.js');
|
|
26
|
+
const { GRADER_DIFF_CHAR_CAP, GRADER_TIMEOUT_MS, CORE_ROOT, REPO_ROOT, noteServerReward } = require('./ship-state.js');
|
|
27
|
+
|
|
28
|
+
// The core's smoke suite, by absolute path from the package dir (task 1004584).
|
|
29
|
+
const SMOKE_SCRIPT = path.join(CORE_ROOT, 'scripts', 'gds', 'smoke-gds.js');
|
|
26
30
|
|
|
27
31
|
// ---------- Step 1+2: local verification ----------
|
|
28
32
|
|
|
@@ -380,10 +384,13 @@ async function runNoArtifactGrader({ task, valueSummary, notes, runOpts: extraOp
|
|
|
380
384
|
}
|
|
381
385
|
|
|
382
386
|
function runSmoke() {
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
+
// The script is addressed from CORE_ROOT, never cwd-relative: in a vendored
|
|
388
|
+
// instance it lives under node_modules/@bongos/core, not under the instance
|
|
389
|
+
// checkout, and the relative form died MODULE_NOT_FOUND after a clean merge —
|
|
390
|
+
// which then reset main and stranded the task at confirmed (task 1004584).
|
|
391
|
+
// cwd stays the instance checkout so instance config resolves as before; the
|
|
392
|
+
// JSON.stringify quoting survives spaces in either path.
|
|
393
|
+
const okPms = shellOk(`"${process.execPath}" ${JSON.stringify(SMOKE_SCRIPT)}`, { cwd: REPO_ROOT });
|
|
387
394
|
return { ran: true, clean: okPms };
|
|
388
395
|
}
|
|
389
396
|
|
|
@@ -394,7 +401,7 @@ function runSmoke() {
|
|
|
394
401
|
// post-merge smoke call keeps the sync form (nothing overlaps there).
|
|
395
402
|
function runSmokeAsync() {
|
|
396
403
|
return new Promise((resolve) => {
|
|
397
|
-
const child = spawn(process.execPath, [
|
|
404
|
+
const child = spawn(process.execPath, [SMOKE_SCRIPT], {
|
|
398
405
|
cwd: REPO_ROOT, stdio: 'inherit', windowsHide: true,
|
|
399
406
|
});
|
|
400
407
|
child.on('error', () => resolve({ ran: true, clean: false }));
|
|
@@ -168,7 +168,32 @@ function removeProbe(repoRoot, dir) {
|
|
|
168
168
|
// tip as it stood BEFORE the branch was merged in (in ship-merge.js that is
|
|
169
169
|
// origin/main, since the ff-only step immediately above it guarantees the two
|
|
170
170
|
// are the same commit).
|
|
171
|
+
// gateApplies — does this repo carry the fitness gate at all? A VENDORED
|
|
172
|
+
// instance (core under node_modules/@bongos/core) has no scripts/gds/fitness.js
|
|
173
|
+
// of its own — fitness is the core's architecture gate, and the core ran it in
|
|
174
|
+
// its own CI before it was published. Without this the gate read the missing
|
|
175
|
+
// file as 'unmeasurable' on BOTH sides and blocked every vendored land
|
|
176
|
+
// (task 1004584). It only stands down when the pre-merge base lacks the file
|
|
177
|
+
// too, so a branch that DELETES fitness.js from the core is still measured
|
|
178
|
+
// (and blocked as unmeasurable) rather than waved through.
|
|
179
|
+
function gateApplies(repoRoot, baseSha) {
|
|
180
|
+
if (fs.existsSync(path.join(repoRoot, 'scripts', 'gds', 'fitness.js'))) return true;
|
|
181
|
+
if (!baseSha) return true;
|
|
182
|
+
try {
|
|
183
|
+
execFileSync('git', ['cat-file', '-e', `${baseSha}:scripts/gds/fitness.js`], {
|
|
184
|
+
cwd: repoRoot, stdio: 'ignore', shell: false,
|
|
185
|
+
});
|
|
186
|
+
return true; // the base had it — this merge removed it; measure (and block)
|
|
187
|
+
} catch (_) {
|
|
188
|
+
return false; // neither side carries the gate — nothing to run
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
171
192
|
function gateMergeResult({ repoRoot, baseSha, log = console.log }) {
|
|
193
|
+
if (!gateApplies(repoRoot, baseSha)) {
|
|
194
|
+
log(' fitness: this repo carries no scripts/gds/fitness.js (a vendored instance) — gate not applicable');
|
|
195
|
+
return { block: false, reason: 'not-applicable' };
|
|
196
|
+
}
|
|
172
197
|
log(' fitness against post-merge state...');
|
|
173
198
|
const mergeClean = runFitnessAt(repoRoot);
|
|
174
199
|
const baseClean = mergeClean ? true : probeBaseClean(repoRoot, baseSha);
|
|
@@ -179,6 +204,7 @@ function gateMergeResult({ repoRoot, baseSha, log = console.log }) {
|
|
|
179
204
|
|
|
180
205
|
module.exports = {
|
|
181
206
|
fitnessOutcome,
|
|
207
|
+
gateApplies,
|
|
182
208
|
gateMergeResult,
|
|
183
209
|
gateMessage,
|
|
184
210
|
mergeGateVerdict,
|
|
@@ -31,7 +31,7 @@ const { runSmoke } = require('./ship-grade.js');
|
|
|
31
31
|
const { gateMergeResult } = require('./ship-merge-gate.js');
|
|
32
32
|
const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
|
|
33
33
|
const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateCopyInventory, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
|
|
34
|
-
const { REPO_ROOT } = require('./ship-state.js');
|
|
34
|
+
const { MAIN_CHECKED_OUT, REPO_ROOT } = require('./ship-state.js');
|
|
35
35
|
const { envPrefix } = require('../../src/instance-config');
|
|
36
36
|
|
|
37
37
|
// zeroCommitVerdict — dbOnly is deliberately consulted ONLY at zero commits
|
|
@@ -265,9 +265,14 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
265
265
|
// — the branch lands fine via a PR. Tagged PR-recoverable so autoMerge falls
|
|
266
266
|
// back instead of parking the task at 'confirmed' unpaid. The reason +
|
|
267
267
|
// nextStep are kept for the case where the fallback itself bails.
|
|
268
|
+
// task 1004584: when NO worktree holds main at all, say exactly that and
|
|
269
|
+
// how to fix it — REPO_ROOT is then the primary checkout by fallback, and
|
|
270
|
+
// "work in your own worktree" is advice the builder already followed.
|
|
268
271
|
return landBailed(
|
|
269
|
-
|
|
270
|
-
|
|
272
|
+
MAIN_CHECKED_OUT
|
|
273
|
+
? `the main checkout is on ${onMain}, not main — work a task in its own worktree so the main checkout stays on main`
|
|
274
|
+
: `no worktree of this repo has main checked out (the main checkout ${REPO_ROOT} is on ${onMain}) — run \`git checkout main\` there, then re-ship`,
|
|
275
|
+
MAIN_CHECKED_OUT ? '/merge-mode' : `git -C "${REPO_ROOT}" checkout main && node scripts/gds/ship.js ${taskId}`,
|
|
271
276
|
LAND_BAIL_MAIN_CHECKOUT_ON_BRANCH,
|
|
272
277
|
);
|
|
273
278
|
}
|
|
@@ -448,6 +453,10 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
448
453
|
|
|
449
454
|
module.exports = {
|
|
450
455
|
autoMerge,
|
|
456
|
+
// task 1004584: the local merge leg on its own, so the vendored-instance
|
|
457
|
+
// regression test can read a bail's reason without the PR fallback (which
|
|
458
|
+
// needs an authed gh) swallowing it.
|
|
459
|
+
mergeLocally,
|
|
451
460
|
// task 1004322: the held page tweak's publish-only path.
|
|
452
461
|
publishHeld,
|
|
453
462
|
// task 1002572 (PART 2): the pure recovery command for a ship run from main —
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
'use strict';
|
|
19
19
|
|
|
20
20
|
const path = require('node:path');
|
|
21
|
-
const { findMainWorktree } = require('../../modules/lifecycle/ship-preflight');
|
|
21
|
+
const { findMainWorktree, findPrimaryWorktree } = require('../../modules/lifecycle/ship-preflight');
|
|
22
22
|
|
|
23
23
|
// The instance's reward policy, as reported by the server on GET /me (ADR 0146 /
|
|
24
24
|
// task 1002469). Cached module-level because the credits print + the completion
|
|
@@ -43,10 +43,43 @@ function getServerReward() {
|
|
|
43
43
|
const GRADER_DIFF_CHAR_CAP = 250_000;
|
|
44
44
|
const GRADER_TIMEOUT_MS = 240_000; // 4 min — Opus on a big diff with Read tool calls
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
46
|
+
// TWO ROOTS, not one (task 1004584). In the core's own repo they are the same
|
|
47
|
+
// folder, which is how a single REPO_ROOT served both jobs for so long. In a
|
|
48
|
+
// VENDORED instance — the core installed as node_modules/@bongos/core — they are
|
|
49
|
+
// not, and conflating them broke every land (mercury, core 1.21.20):
|
|
50
|
+
// CORE_ROOT — where the core's own scripts live (the package dir). Every
|
|
51
|
+
// core script ship spawns (smoke-gds.js, …) is addressed from
|
|
52
|
+
// here, never by a cwd-relative path.
|
|
53
|
+
// INSTANCE_ROOT — the instance repo's checkout that auto-merge runs in: the
|
|
54
|
+
// worktree with `main` checked out (primary OR linked). With no
|
|
55
|
+
// `main` anywhere it is the primary checkout, so the merge leg
|
|
56
|
+
// bails with "on X, not main" — never the package dir, which is
|
|
57
|
+
// not a repo and turned the bail into an ENOENT on the lock.
|
|
58
|
+
// Git is asked from __dirname first (unchanged for the core repo), then from the
|
|
59
|
+
// process cwd (the feature worktree ship is run from) as a backstop.
|
|
60
|
+
const CORE_ROOT = path.resolve(__dirname, '..', '..');
|
|
61
|
+
|
|
62
|
+
// resolveRoots — the pure resolution, with git injected so the vendored layouts
|
|
63
|
+
// are unit-testable. `mainCheckedOut` is false when no worktree holds `main`;
|
|
64
|
+
// ship-merge names that case in its bail instead of a generic "on X".
|
|
65
|
+
function resolveRoots({
|
|
66
|
+
dirname = __dirname,
|
|
67
|
+
cwd = process.cwd(),
|
|
68
|
+
findMain = findMainWorktree,
|
|
69
|
+
findPrimary = findPrimaryWorktree,
|
|
70
|
+
} = {}) {
|
|
71
|
+
const core = path.resolve(dirname, '..', '..');
|
|
72
|
+
const main = findMain(dirname) || findMain(cwd);
|
|
73
|
+
if (main) return { coreRoot: core, instanceRoot: main, mainCheckedOut: true };
|
|
74
|
+
const primary = findPrimary(dirname) || findPrimary(cwd);
|
|
75
|
+
return { coreRoot: core, instanceRoot: primary || core, mainCheckedOut: false };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { instanceRoot: INSTANCE_ROOT, mainCheckedOut: MAIN_CHECKED_OUT } = resolveRoots();
|
|
79
|
+
// The historical name every ship phase imports. It always meant "the checkout
|
|
80
|
+
// auto-merge runs in", i.e. the instance root — kept as an alias so the phases
|
|
81
|
+
// that only ever wanted that do not churn.
|
|
82
|
+
const REPO_ROOT = INSTANCE_ROOT;
|
|
50
83
|
|
|
51
84
|
// Sound playback is COSMETIC — the ship pipeline must NEVER depend on it. Guard
|
|
52
85
|
// the require so a missing/broken player (e.g. .claude/sounds/ absent on a fresh
|
|
@@ -68,6 +101,10 @@ module.exports = {
|
|
|
68
101
|
GRADER_DIFF_CHAR_CAP,
|
|
69
102
|
GRADER_TIMEOUT_MS,
|
|
70
103
|
REPO_ROOT,
|
|
104
|
+
CORE_ROOT,
|
|
105
|
+
INSTANCE_ROOT,
|
|
106
|
+
MAIN_CHECKED_OUT,
|
|
107
|
+
resolveRoots,
|
|
71
108
|
playSound,
|
|
72
109
|
BEHIND_MAIN_LIMIT,
|
|
73
110
|
};
|
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.63'; // 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');
|
package/tests/guilds_api.mjs
CHANGED
|
@@ -578,6 +578,37 @@ test('the owner\'s invite list names only invitees THIS owner could address (a t
|
|
|
578
578
|
'public, or connected to the viewing owner — the addressableAccount rule, reusing the one symmetric-edge fragment');
|
|
579
579
|
});
|
|
580
580
|
|
|
581
|
+
test('GET /me/guild-requests carries the requests to EVERY guild I own in one statement keyed on the session, each naming its guild (task 1004583)', async () => {
|
|
582
|
+
const OWNED = (t) => /WHERE r\.kind = 'request' AND r\.status = 'pending'/.test(t);
|
|
583
|
+
on(base((t) => (OWNED(t) && /^\s*SELECT/.test(t)
|
|
584
|
+
? result([{ id: '601', created_at: 'c', seen_at: null, slug: 'night-shift', name: 'Night Shift', handle: 'tomas-ek', display_name: 'Tomás Ek', avatar_url: null },
|
|
585
|
+
{ id: '602', created_at: 'c', seen_at: 's', slug: 'lamp-room', name: 'Lamp Room', handle: 'kai-lowe', display_name: null, avatar_url: null }])
|
|
586
|
+
: null)));
|
|
587
|
+
const res = await call('GET', '/api/bongos/me/guild-requests');
|
|
588
|
+
assert.equal(res.status, 200);
|
|
589
|
+
assert.equal(res.headers['cache-control'], 'no-store');
|
|
590
|
+
assert.deepEqual(res.json.owner_incoming[0], { id: '601', created_at: 'c', seen_at: null, handle: 'tomas-ek', display_name: 'Tomás Ek', avatar_url: null, guild: { slug: 'night-shift', name: 'Night Shift' } });
|
|
591
|
+
assert.equal(res.json.owner_incoming_count, res.json.owner_incoming.length);
|
|
592
|
+
assert.equal(res.json.incoming_count, res.json.incoming.length, 'the invite half is unchanged');
|
|
593
|
+
|
|
594
|
+
const reads = calls.filter((c) => OWNED(c.text) && /^\s*SELECT/.test(c.text));
|
|
595
|
+
assert.equal(reads.length, 1, 'one statement for every guild I own — never one per guild');
|
|
596
|
+
const [read] = reads;
|
|
597
|
+
assert.equal(read.params[0], ME, 'keyed on the SESSION’s github_id');
|
|
598
|
+
assert.equal(read.params[2], guilds.LIST_MAX, 'capped as every queue is');
|
|
599
|
+
assert.ok(read.text.includes(require('../modules/platform-identity/account-visibility.js').accountActiveSql('pa')),
|
|
600
|
+
'a requester floors on an active account, as the per-guild queue does');
|
|
601
|
+
assert.match(read.text, /o\.guild_id = r\.guild_id AND o\.github_id = \$1::bigint\s+AND o\.membership_kind = 'owner'/, 'the owner test, per row, in the statement');
|
|
602
|
+
assert.match(read.text, /JOIN platform_identity_accounts pa ON pa\.github_id = r\.github_id/);
|
|
603
|
+
assert.doesNotMatch(read.text, /github_login|pa\.github_id AS|r\.github_id AS/, 'no GitHub identity leaves the module');
|
|
604
|
+
assert.match(read.text, /pa\.handle, pa\.display_name, pa\.avatar_url/, 'the person columns a guild list carries, no more');
|
|
605
|
+
|
|
606
|
+
const stamp = calls.find((c) => /^\s*UPDATE platform_identity_guild_requests r SET seen_at = now\(\)/.test(c.text));
|
|
607
|
+
assert.ok(stamp, 'the unseen request is stamped, as the per-guild read stamps it');
|
|
608
|
+
assert.deepEqual(stamp.params, [['601'], ME], 'only the unseen row, by the same owner');
|
|
609
|
+
assert.match(stamp.text, /r\.kind = 'request'[\s\S]*o\.github_id = \$2::bigint\s+AND o\.membership_kind = 'owner'/, 'the stamp re-asserts the owner test');
|
|
610
|
+
});
|
|
611
|
+
|
|
581
612
|
// ===========================================================================
|
|
582
613
|
// 7. Nothing here is authority; counts are lengths
|
|
583
614
|
// ===========================================================================
|
package/tests/guilds_db.mjs
CHANGED
|
@@ -160,6 +160,13 @@ try {
|
|
|
160
160
|
'the requester\'s own counted_in_totals choice rides the request — the owner cannot override it');
|
|
161
161
|
|
|
162
162
|
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'created', 'Bob asks');
|
|
163
|
+
const ownerQ = await guilds.listOwnedRequests({ githubId: OWNER }, { pool });
|
|
164
|
+
const bobAsk = ownerQ.find((r) => r.handle === 'r24bob');
|
|
165
|
+
ok(bobAsk && bobAsk.guild.slug === 'r24-pub' && !('github_id' in bobAsk) && !('kind' in bobAsk),
|
|
166
|
+
'the owner\'s ONE read across every guild they own carries Bob\'s request, naming its guild, by handle never github_id (task 1004583)');
|
|
167
|
+
ok((await guilds.listOwnedRequests({ githubId: ALICE }, { pool })).every((r) => r.guild.slug !== 'r24-pub'),
|
|
168
|
+
'a member who is not the owner reads none of its requests (the owner gate is in the statement)');
|
|
169
|
+
ok((await openRows('r24-pub', BOB))[0].seen_at !== null, 'reading it stamps seen_at on the request, as the per-guild queue does');
|
|
163
170
|
ok(await guilds.declineRequest({ id: await pendingId('r24-pub', BOB), githubId: OWNER }, { pool }), 'the owner declines');
|
|
164
171
|
const before = (await openRows('r24-pub', BOB)).length;
|
|
165
172
|
ok((await guilds.requestToJoin({ slug: 'r24-pub', githubId: BOB }, { pool })).outcome === 'swallowed',
|
|
@@ -37,11 +37,13 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
|
37
37
|
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
38
38
|
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
39
39
|
|
|
40
|
-
// the rows region: mineEl/mineRow, the
|
|
40
|
+
// the rows region: mineEl/mineRow, the lists, the section, and the guild cards —
|
|
41
|
+
// through the guild invite and request cards (task 1004583), which loadInvites
|
|
42
|
+
// also reads; tests/hub_guild_cards.mjs drives those
|
|
41
43
|
const START = SCRIPT.indexOf(' function mineEl(tag, cls)');
|
|
42
|
-
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function
|
|
44
|
+
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function loadGuildQueue() {')) + 5;
|
|
43
45
|
assert.ok(START > 0, 'the region starts at mineEl');
|
|
44
|
-
assert.ok(END > START, 'and ends with
|
|
46
|
+
assert.ok(END > START, 'and ends with loadGuildQueue');
|
|
45
47
|
const REGION = SCRIPT.slice(START, END);
|
|
46
48
|
for (const fn of ['function mineRow(', 'function renderInvitesList(', 'function paintInvitesSection(', 'function loadInvites(',
|
|
47
49
|
'function engageLine(', 'function engageRow(', 'function renderEngagements(', 'function loadEngagements(']) {
|
|
@@ -81,7 +83,7 @@ const REQUESTED = { ok: true, status: 200, body: { ok: true, status: 'requested'
|
|
|
81
83
|
|
|
82
84
|
function boot({ answers = [] } = {}) {
|
|
83
85
|
const ids = new Map();
|
|
84
|
-
for (const id of ['mineInvites', 'invitesList', 'engageList', 'buildingList', 'buildingEmpty']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
86
|
+
for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList', 'buildingList', 'buildingEmpty']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
85
87
|
ids.get('mineInvites').hidden = true;
|
|
86
88
|
const requests = [];
|
|
87
89
|
const queued = answers.slice();
|
|
@@ -173,7 +175,8 @@ test('loadInvites reads both lists, and a guild read that fails is silent', asyn
|
|
|
173
175
|
] });
|
|
174
176
|
await ui.sandbox.loadInvites();
|
|
175
177
|
await ui.settle();
|
|
176
|
-
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
|
|
178
|
+
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`).sort(),
|
|
179
|
+
['GET /api/bongos/me/guild-engagements', 'GET /api/bongos/me/guild-requests', 'GET /api/bongos/my-projects/invites']);
|
|
177
180
|
for (const r of ui.requests) assert.equal(r.credentials, 'same-origin', 'own-scope reads, on the cookie session');
|
|
178
181
|
assert.equal(ui.cards().length, 0);
|
|
179
182
|
assert.equal(ui.section().hidden, true);
|
|
@@ -291,7 +294,7 @@ test('the cards are a real list inside the Invitations section, built with creat
|
|
|
291
294
|
assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/, 'no markup built from strings');
|
|
292
295
|
assert.match(SCRIPT, /if \(v === 'mine' && me\) \{ refreshList\(\); loadMemberships\(\); loadInvites\(\); \}/, 'the mine view re-reads both lists on entry, through loadInvites');
|
|
293
296
|
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
294
|
-
assert.match(COSMOS, /\.mineRows:not\(:empty\)
|
|
297
|
+
assert.match(COSMOS, /\.mineRows:not\(:empty\)~\.mineRows \.mineRow:first-child\{border-top:1px solid var\(--rule-soft\);\}/, 'one hairline between any two lists that have rows');
|
|
295
298
|
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /engageRow/, 'styled in cosmos.css, not in the page head');
|
|
296
299
|
});
|
|
297
300
|
|