@bongos/core 1.19.373 → 1.19.374
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 +46 -26
- 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 +4 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +75 -2
- package/docs/api-reference.md +5 -3
- package/docs/copy-inventory.md +6 -3
- package/docs/copy-registry.json +32 -5
- package/modules/builder-settings/profile-nudge-prefs.js +125 -0
- package/modules/builder-settings/routes/settings.js +48 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/hall-ui/public/goal-inbox.js +10 -0
- package/modules/hall-ui/public/index.html +4 -0
- package/modules/hall-ui/public/profile-nudge.js +122 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness-checks-write-validation.js +1 -0
- package/scripts/gds/ship-land.js +94 -3
- package/src/module-api.js +3 -1
- package/tests/profile_nudge_prefs.mjs +97 -0
- package/tests/ship_push_diagnosis.mjs +78 -0
- package/tests/sound_player.mjs +38 -0
|
@@ -28,6 +28,7 @@ const wanderingPrefs = require('../wandering-prefs');
|
|
|
28
28
|
const interactionPrefs = require('../interaction-prefs');
|
|
29
29
|
const modelAllocPrefs = require('../model-alloc-prefs');
|
|
30
30
|
const renderPrefs = require('../render-prefs');
|
|
31
|
+
const profileNudgePrefs = require('../profile-nudge-prefs');
|
|
31
32
|
const displayName = require('../display-name');
|
|
32
33
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
33
34
|
const log = api.logger('builder-settings');
|
|
@@ -378,6 +379,53 @@ module.exports = function buildBuilderSettingsRouter() {
|
|
|
378
379
|
}
|
|
379
380
|
});
|
|
380
381
|
|
|
382
|
+
// The "Complete your profile" nudge dismissal (task 1002802 / P06). The nudge
|
|
383
|
+
// itself is a DISMISSIBLE line item in the hall's needs-attention inbox — the
|
|
384
|
+
// replacement for the profile-setup wizard Decision 2 rejected. These two
|
|
385
|
+
// routes own only the dismissal FLAG; whether anything is actually missing is
|
|
386
|
+
// composed by the caller, which already holds both inputs (preferred_disciplines
|
|
387
|
+
// from GET /me, bio from GET /me/cross-project — bio lives on the platform
|
|
388
|
+
// identity account, and this module must never reach across to that one).
|
|
389
|
+
//
|
|
390
|
+
// rank: any-builder — own profile config; same posture as /me/render.
|
|
391
|
+
router.get('/me/profile-nudge', api.requireBuilder, async (req, res) => {
|
|
392
|
+
try {
|
|
393
|
+
// preferred_disciplines is NOT on req.builder (requireBuilder attaches an
|
|
394
|
+
// identity+rank subset), so read the row through the doorway — the same
|
|
395
|
+
// getBuilderById the wandering/interaction routes above use.
|
|
396
|
+
const [stored, builder] = await Promise.all([
|
|
397
|
+
db.getBuilderBehaviorPrefs(req.builder.id),
|
|
398
|
+
api.getBuilderById(req.builder.id),
|
|
399
|
+
]);
|
|
400
|
+
res.json(profileNudgePrefs.describeForApi(stored, {
|
|
401
|
+
preferredDisciplines: builder && builder.preferred_disciplines,
|
|
402
|
+
}));
|
|
403
|
+
} catch (err) {
|
|
404
|
+
log.error('GET /me/profile-nudge', err);
|
|
405
|
+
res.fail('read_failed', { status: 500, message: 'internal error' });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// PATCH the dismissal. Body: { dismissed: <bool> | null } — null clears the
|
|
410
|
+
// override so the nudge can come back (the undismiss path). Sibling behavior
|
|
411
|
+
// keys are preserved by applyPatch.
|
|
412
|
+
router.patch('/me/profile-nudge', api.requireBuilder, async (req, res) => {
|
|
413
|
+
try {
|
|
414
|
+
const v = profileNudgePrefs.validatePatch(req.body || {});
|
|
415
|
+
if (!v.ok) return res.fail('invalid_patch', 422, { errors: v.errors });
|
|
416
|
+
const existing = await db.getBuilderBehaviorPrefs(req.builder.id);
|
|
417
|
+
const next = profileNudgePrefs.applyPatch(existing, v.patch);
|
|
418
|
+
const saved = await db.setBuilderBehaviorPrefs(req.builder.id, next);
|
|
419
|
+
const builder = await api.getBuilderById(req.builder.id);
|
|
420
|
+
res.json(profileNudgePrefs.describeForApi(saved, {
|
|
421
|
+
preferredDisciplines: builder && builder.preferred_disciplines,
|
|
422
|
+
}));
|
|
423
|
+
} catch (err) {
|
|
424
|
+
log.error('PATCH /me/profile-nudge', err);
|
|
425
|
+
res.fail('write_failed', { status: 500, message: 'internal error' });
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
381
429
|
// Per-builder DISPLAY NAME (task 1669). The free label shown next to a
|
|
382
430
|
// builder's work in the hall (header, leaderboard, work feed — all already read
|
|
383
431
|
// display_name, so a DB update propagates). Seeded once from GitHub at account
|
|
@@ -500,6 +500,10 @@ function createClient(opts = {}) {
|
|
|
500
500
|
patchMeModelAllocation: (args) => request("PATCH", "/me/model-allocation", { hasBody: true }, args),
|
|
501
501
|
// PATCH /me/profile — rank: any-builder — PATCH /me/profile
|
|
502
502
|
patchMeProfile: (args) => request("PATCH", "/me/profile", { hasBody: true }, args),
|
|
503
|
+
// GET /me/profile-nudge — rank: any-builder — GET /me/profile-nudge
|
|
504
|
+
getMeProfileNudge: (args) => request("GET", "/me/profile-nudge", { hasBody: false }, args),
|
|
505
|
+
// PATCH /me/profile-nudge — rank: any-builder — PATCH /me/profile-nudge
|
|
506
|
+
patchMeProfileNudge: (args) => request("PATCH", "/me/profile-nudge", { hasBody: true }, args),
|
|
503
507
|
// GET /me/recent-ships — rank: any-builder — GET /me/recent-ships
|
|
504
508
|
getMeRecentShips: (args) => request("GET", "/me/recent-ships", { hasBody: false }, args),
|
|
505
509
|
// GET /me/render — rank: any-builder — GET /me/render
|
|
@@ -273,6 +273,13 @@
|
|
|
273
273
|
host.innerHTML =
|
|
274
274
|
'<h2 id="attention-h" class="scroll__h">Needs your attention<span class="count-pill" id="attention-count" hidden></span></h2>' +
|
|
275
275
|
'<div id="attention-goals"></div>' +
|
|
276
|
+
// task 1002802 (P06): "Complete your profile" — a DISMISSIBLE nudge when
|
|
277
|
+
// bio or preferred_disciplines are empty. Rendered by builders.js (which
|
|
278
|
+
// already holds the /me state the condition reads), exactly like the
|
|
279
|
+
// ideas slice. Placement rule: an action awaiting the builder is an inbox
|
|
280
|
+
// LINE, never a new Home card — that is why the rejected profile wizard
|
|
281
|
+
// (task 1002783) became this.
|
|
282
|
+
'<div id="attention-profile"></div>' +
|
|
276
283
|
'<div id="attention-ideas"></div>' +
|
|
277
284
|
// task 1003066 (BV1.R05): routed filings that SOFT-LANDED — a reported bug
|
|
278
285
|
// now sitting at 'backlog' in a real goal, one Metic+ nod from claimable.
|
|
@@ -284,6 +291,9 @@
|
|
|
284
291
|
host.addEventListener('click', onClick);
|
|
285
292
|
load();
|
|
286
293
|
loadNodQueue();
|
|
294
|
+
// task 1002802 (P06): the profile slice lives in its own file (builders.js is
|
|
295
|
+
// over the size budget); this card owns its div, so it boots it here.
|
|
296
|
+
window.OTBProfileNudge?.load();
|
|
287
297
|
}
|
|
288
298
|
|
|
289
299
|
// The card's shared visibility contract: any slice renderer calls refresh()
|
|
@@ -123,6 +123,10 @@
|
|
|
123
123
|
hall-widget tags the server injects still anchor on builders.js's tag
|
|
124
124
|
below (it matches by filename), so they land between the two. -->
|
|
125
125
|
<script src="/builders/hall-render.js?v=2026-08-24-hallrender1" defer></script>
|
|
126
|
+
<!-- task 1002802 (P06): the profile-completion nudge slice. Exposes
|
|
127
|
+
window.OTBProfileNudge; goal-inbox.js's mount() calls it once it has
|
|
128
|
+
created the #attention-profile div, so load order is not load-bearing. -->
|
|
129
|
+
<script src="/builders/profile-nudge.js?v=2026-08-25-profile-nudge" defer></script>
|
|
126
130
|
<script src="/builders/builders.js?v=2026-06-27-r75" defer></script>
|
|
127
131
|
<script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
|
|
128
132
|
</body>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// profile-nudge.js — the "Complete your profile" slice of the hall's
|
|
2
|
+
// "Needs your attention" card (task 1002802 / P06, goal 1000045).
|
|
3
|
+
//
|
|
4
|
+
// Its own file rather than a block inside builders.js: the slice needs no state
|
|
5
|
+
// from that file (both its inputs come from its own two fetches), and builders.js
|
|
6
|
+
// is already far over the 1,500-line budget the quality ratchets enforce.
|
|
7
|
+
//
|
|
8
|
+
// goal-inbox.js owns the CARD (id 'attention-scroll') and creates this slice's
|
|
9
|
+
// div (#attention-profile); card visibility + the headline count derive from
|
|
10
|
+
// every slice via window.OTBAttention.refresh().
|
|
11
|
+
(function () {
|
|
12
|
+
'use strict';
|
|
13
|
+
if (!window.OTB) return;
|
|
14
|
+
const { escapeHtml } = window.OTB;
|
|
15
|
+
const API = '/api/bongos';
|
|
16
|
+
// Result-mode client: a 401/404 must be inspectable control flow, never a throw
|
|
17
|
+
// that breaks a sibling slice (the goal-inbox.js pattern).
|
|
18
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
19
|
+
|
|
20
|
+
async function getJSON(path) {
|
|
21
|
+
const res = await api.request('GET', path);
|
|
22
|
+
if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; throw e; }
|
|
23
|
+
return res.data;
|
|
24
|
+
}
|
|
25
|
+
async function patchJSON(path, body) {
|
|
26
|
+
const res = await api.request('PATCH', path, { body: body || {} });
|
|
27
|
+
if (!res.ok) { const e = new Error(`${path} → ${res.status}`); e.status = res.status; throw e; }
|
|
28
|
+
return res.data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
// Renders the PROFILE slice of the "Needs your attention" card. goal-inbox.js
|
|
33
|
+
// owns the card (id 'attention-scroll') + the slice div; visibility and the
|
|
34
|
+
// headline count derive from every slice via OTBAttention.
|
|
35
|
+
//
|
|
36
|
+
// WHY A LINE AND NOT A CARD OR A WIZARD: spec Decision 2 rejected a
|
|
37
|
+
// profile-setup wizard (profiles are auto-created and enriched
|
|
38
|
+
// progressively; an interstitial after sign-in adds friction at the drop-off
|
|
39
|
+
// point). The hall's placement rule then puts an action awaiting the builder
|
|
40
|
+
// in the inbox as a LINE, never a new Home card.
|
|
41
|
+
//
|
|
42
|
+
// The two inputs live in different places — preferred_disciplines on the
|
|
43
|
+
// builder row (already in the /me payload this file holds) and bio on the
|
|
44
|
+
// platform-identity account (GET /me/cross-project) — so the SHOW decision is
|
|
45
|
+
// composed here. The server owns only the dismissal flag.
|
|
46
|
+
// Uses .ginbox__item so OTBAttention.refresh() counts it with no selector change.
|
|
47
|
+
function renderProfileNudge(state) {
|
|
48
|
+
const box = document.getElementById('attention-profile');
|
|
49
|
+
if (!box) return;
|
|
50
|
+
if (!state || !state.show) {
|
|
51
|
+
box.innerHTML = '';
|
|
52
|
+
window.OTBAttention?.refresh();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Name what is actually missing: "add a bio" is actionable, "complete your
|
|
56
|
+
// profile" is a chore of unknown size.
|
|
57
|
+
const missing = Array.isArray(state.missing) ? state.missing : [];
|
|
58
|
+
const parts = [];
|
|
59
|
+
if (missing.includes('bio')) parts.push('a short bio');
|
|
60
|
+
if (missing.includes('disciplines')) parts.push('the kinds of work you want');
|
|
61
|
+
const what = parts.length === 2 ? `${parts[0]} and ${parts[1]}` : (parts[0] || 'a detail or two');
|
|
62
|
+
box.innerHTML = '<h3 class="ginbox__sec-h">Complete your profile</h3>'
|
|
63
|
+
+ '<ul class="ginbox">'
|
|
64
|
+
+ '<li class="ginbox__item">'
|
|
65
|
+
// .ginbox__body / .ginbox__actions are the classes style.css actually
|
|
66
|
+
// defines (the body is the flex column that stacks label over sub-line).
|
|
67
|
+
+ '<div class="ginbox__body">'
|
|
68
|
+
+ `<a class="ginbox__goal" href="/settings">Add ${escapeHtml(what)}</a>`
|
|
69
|
+
+ '<span class="ginbox__sub">Shown on your public profile — it helps others know what you work on.</span>'
|
|
70
|
+
+ '</div>'
|
|
71
|
+
+ '<div class="ginbox__actions">'
|
|
72
|
+
+ '<button class="btn-ghost" type="button" data-profile-nudge-dismiss="1">Dismiss</button>'
|
|
73
|
+
+ '</div>'
|
|
74
|
+
+ '</li></ul>';
|
|
75
|
+
window.OTBAttention?.refresh();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fetch the dismissal flag + the bio in parallel, then compose. Any failure
|
|
79
|
+
// renders NOTHING (a nudge is the most skippable thing on the page — it must
|
|
80
|
+
// never surface an error card or break a sibling slice).
|
|
81
|
+
async function loadProfileNudge() {
|
|
82
|
+
try {
|
|
83
|
+
const [nudge, cross] = await Promise.all([
|
|
84
|
+
getJSON(`${API}/me/profile-nudge`),
|
|
85
|
+
getJSON(`${API}/me/cross-project`).catch(() => null),
|
|
86
|
+
]);
|
|
87
|
+
if (!nudge) return;
|
|
88
|
+
// The server computed `missing` from preferred_disciplines only (bio is not
|
|
89
|
+
// its to read — different module). Fold the bio in here.
|
|
90
|
+
const missing = Array.isArray(nudge.missing) ? nudge.missing.slice() : [];
|
|
91
|
+
const bio = cross && typeof cross.bio === 'string' ? cross.bio : '';
|
|
92
|
+
const bioMissing = bio.trim() === '';
|
|
93
|
+
// A null cross-project (older server, or a principal with no platform
|
|
94
|
+
// identity) means "cannot tell" — do not invent a missing bio from it.
|
|
95
|
+
if (bioMissing && cross && !missing.includes('bio')) missing.push('bio');
|
|
96
|
+
if (!bioMissing) { const i = missing.indexOf('bio'); if (i !== -1) missing.splice(i, 1); }
|
|
97
|
+
renderProfileNudge({ ...nudge, missing, show: !nudge.dismissed && missing.length > 0 });
|
|
98
|
+
} catch (_) {
|
|
99
|
+
/* signed out, older server, or a hiccup: the nudge simply does not appear */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// task 1002802 (P06): dismiss the profile nudge. Delegated for the same reason
|
|
104
|
+
// as the triage verb above — the slice re-renders. Optimistic: the line goes
|
|
105
|
+
// away immediately (a dismissal that appears to fail is worse than one that
|
|
106
|
+
// silently retries), and the server flag makes it stay gone on every device.
|
|
107
|
+
document.addEventListener('click', (e) => {
|
|
108
|
+
const btn = e.target.closest('[data-profile-nudge-dismiss]');
|
|
109
|
+
if (!btn) return;
|
|
110
|
+
btn.disabled = true;
|
|
111
|
+
renderProfileNudge({ show: false });
|
|
112
|
+
patchJSON(`${API}/me/profile-nudge`, { dismissed: true })
|
|
113
|
+
.catch(() => loadProfileNudge()); // write failed → put the line back, honestly
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// The CARD OWNER boots its slices: goal-inbox.js's mount() creates
|
|
117
|
+
// #attention-profile and then calls this, exactly as it calls its own load()
|
|
118
|
+
// and loadNodQueue(). No DOMContentLoaded race and no polling for the div —
|
|
119
|
+
// the widget registry mounts asynchronously after /me resolves, so a
|
|
120
|
+
// load-time call would reliably find nothing.
|
|
121
|
+
window.OTBProfileNudge = { load: loadProfileNudge, render: renderProfileNudge };
|
|
122
|
+
})();
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.374",
|
|
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.374",
|
|
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.374",
|
|
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",
|
|
@@ -41,6 +41,7 @@ const WRITE_VALIDATION_EXEMPT = new Set([
|
|
|
41
41
|
'PATCH /me/model-allocation', // per-slot tiers — modelAllocPrefs.validatePatch
|
|
42
42
|
'PATCH /me/render', // { widgets: <bool>|null } — renderPrefs.validatePatch
|
|
43
43
|
'PATCH /me/display-name', // { display_name } — displayName.validate
|
|
44
|
+
'PATCH /me/profile-nudge', // { dismissed: <bool>|null } — profileNudgePrefs.validatePatch
|
|
44
45
|
]);
|
|
45
46
|
|
|
46
47
|
// KNOWN GAPS, NOT EXEMPTIONS (task 1002551 / BV1.R109b → tracked by task 1002552).
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -406,6 +406,80 @@ function rerunGateReviewIfStale(branch, headSha) {
|
|
|
406
406
|
return ghArgvOk(['run', 'rerun', String(id)]);
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
// ---------- why the push failed, and whether retrying can ever help ----------
|
|
410
|
+
//
|
|
411
|
+
// A failed `git push` already parks the task at `confirmed` and opens no PR,
|
|
412
|
+
// which is correct. What was wrong (task 1003293) was the ADVICE: "Fix the push
|
|
413
|
+
// and re-ship" is useless for the most common cause, because re-shipping fails
|
|
414
|
+
// identically every time and the reconciler cannot heal a branch that was never
|
|
415
|
+
// pushed. The task then sits at `confirmed` with its code on one laptop, looking
|
|
416
|
+
// like an ordinary pending land. That happened twice in one session — both times
|
|
417
|
+
// on a commit touching .github/workflows/, both times invisible until someone
|
|
418
|
+
// checked git instead of the log.
|
|
419
|
+
//
|
|
420
|
+
// So the distinction that matters is TERMINAL vs transient. A terminal failure
|
|
421
|
+
// must name the actual remedy; a transient one may honestly say "re-ship".
|
|
422
|
+
// `argvOk` runs the push with stdio:'inherit', so git's own message reaches the
|
|
423
|
+
// log but nothing ever reads it — these patterns read it.
|
|
424
|
+
const PUSH_DIAGNOSES = [
|
|
425
|
+
{
|
|
426
|
+
id: 'workflow_scope',
|
|
427
|
+
// GitHub's wording: "refusing to allow an OAuth App to create or update
|
|
428
|
+
// workflow `.github/workflows/x.yml` without `workflow` scope".
|
|
429
|
+
match: /without .{0,3}workflow.{0,3} scope|refusing to allow (?:an?|the) \S+ (?:App|integration)/i,
|
|
430
|
+
terminal: true,
|
|
431
|
+
why: 'this branch changes a file under .github/workflows/, and the credential git used has no `workflow` scope — GitHub refuses that push regardless of how often it is retried',
|
|
432
|
+
fix: 'push over SSH once, then re-run the ship:\n git push git@github.com:<owner>/<repo>.git <branch>\n (or re-authenticate the CLI with the `workflow` scope: gh auth refresh -h github.com -s workflow)',
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
id: 'protected_branch',
|
|
436
|
+
match: /GH006|protected branch|required status check/i,
|
|
437
|
+
terminal: true,
|
|
438
|
+
why: 'branch protection refused the push outright',
|
|
439
|
+
fix: 'land through a pull request instead of pushing directly, or have an admin adjust the rule',
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
id: 'auth',
|
|
443
|
+
match: /Authentication failed|could not read Username|Permission denied \(publickey\)|remote: Invalid username or password|403 Forbidden/i,
|
|
444
|
+
terminal: true,
|
|
445
|
+
why: 'git could not authenticate to the remote at all',
|
|
446
|
+
fix: 'restore the credential (gh auth login, or an SSH key the remote accepts), then re-run the ship',
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
id: 'stale_branch',
|
|
450
|
+
match: /non-fast-forward|fetch first|behind its remote counterpart|Updates were rejected/i,
|
|
451
|
+
terminal: false,
|
|
452
|
+
why: 'the remote branch has commits this checkout does not',
|
|
453
|
+
fix: 'git fetch && git rebase origin/<branch>, then re-run the ship',
|
|
454
|
+
},
|
|
455
|
+
];
|
|
456
|
+
|
|
457
|
+
// Pure: classify a failed push from git's stderr. Exported + tested.
|
|
458
|
+
// An unrecognised failure is reported as NON-terminal on purpose — "try again"
|
|
459
|
+
// is the safe default when we do not know, and a wrong "this can never work"
|
|
460
|
+
// would send someone hunting a problem that does not exist.
|
|
461
|
+
function pushDiagnosis(stderr) {
|
|
462
|
+
const text = String(stderr == null ? '' : stderr);
|
|
463
|
+
for (const d of PUSH_DIAGNOSES) {
|
|
464
|
+
if (d.match.test(text)) return { id: d.id, terminal: d.terminal, why: d.why, fix: d.fix };
|
|
465
|
+
}
|
|
466
|
+
return { id: 'unknown', terminal: false, why: 'the push failed for a reason this lane does not recognise', fix: 'read git\'s message above, resolve it, then re-run the ship' };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Push, capturing stderr so it can be diagnosed — and echoing it so the log still
|
|
470
|
+
// shows exactly what git said, which stdio:'inherit' gave us for free.
|
|
471
|
+
function pushCapturing(branch, deps = {}) {
|
|
472
|
+
const exec = deps.execFileSync || require('node:child_process').execFileSync;
|
|
473
|
+
try {
|
|
474
|
+
exec('git', ['push', 'origin', branch], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
475
|
+
return { ok: true, stderr: '' };
|
|
476
|
+
} catch (e) {
|
|
477
|
+
const stderr = [e && e.stderr, e && e.stdout].filter(Boolean).join('\n');
|
|
478
|
+
if (stderr) process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
|
|
479
|
+
return { ok: false, stderr };
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
409
483
|
// ciLandLocal — today's local path: this machine pushes the branch + opens the PR
|
|
410
484
|
// + enables auto-merge via git + gh. async since task 1169 — it asks the server to
|
|
411
485
|
// post the gate-author-trust attestation between the push and the PR open.
|
|
@@ -413,9 +487,24 @@ async function ciLandLocal(taskId, branch, valueSummary) {
|
|
|
413
487
|
const headSha = gitOk('git rev-parse HEAD');
|
|
414
488
|
|
|
415
489
|
console.log(` pushing ${branch} to origin...`);
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
490
|
+
const pushed = pushCapturing(branch);
|
|
491
|
+
if (!pushed.ok) {
|
|
492
|
+
const d = pushDiagnosis(pushed.stderr);
|
|
493
|
+
console.log(` ci-land: push FAILED — ${d.why}.`);
|
|
494
|
+
console.log(` ci-land: nothing was pushed and no PR exists; task stays at confirmed.`);
|
|
495
|
+
if (d.terminal) {
|
|
496
|
+
// The important line. Re-shipping cannot fix this, and the reconciler
|
|
497
|
+
// cannot heal a branch that never reached the remote — so do not say
|
|
498
|
+
// "re-ship" and leave the work stranded on one machine.
|
|
499
|
+
console.log(' ci-land: RE-SHIPPING WILL FAIL THE SAME WAY. Do this instead:');
|
|
500
|
+
console.log(` ${d.fix}`);
|
|
501
|
+
} else {
|
|
502
|
+
console.log(` ci-land: to retry — ${d.fix}`);
|
|
503
|
+
}
|
|
504
|
+
return landBailed(
|
|
505
|
+
`could not push ${branch} to origin (${d.id})`,
|
|
506
|
+
d.terminal ? d.fix.split('\n')[0] : `node scripts/gds/ship.js ${taskId}`,
|
|
507
|
+
);
|
|
419
508
|
}
|
|
420
509
|
|
|
421
510
|
// task 1169 (ADR 0058): attest the author's trust tier on the head SHA BEFORE
|
|
@@ -545,6 +634,8 @@ function pollCiDeploy(taskId, branch, headSha) {
|
|
|
545
634
|
|
|
546
635
|
module.exports = {
|
|
547
636
|
localHistoryRewritten, // task 1003221 — the append-only discriminator
|
|
637
|
+
pushDiagnosis, // task 1003293 — a terminal push failure must not advise a retry
|
|
638
|
+
PUSH_DIAGNOSES,
|
|
548
639
|
ciLand,
|
|
549
640
|
classifyLandOutcome,
|
|
550
641
|
formatPublishFailure,
|
package/src/module-api.js
CHANGED
|
@@ -1176,8 +1176,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1176
1176
|
// landed since 1.19.371 with no explicit bump. run 32905166786. (task 1002620)
|
|
1177
1177
|
// 1.19.373 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1178
1178
|
// landed since 1.19.372 with no explicit bump. run 32906118616. (task 1002620)
|
|
1179
|
+
// 1.19.374 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1180
|
+
// landed since 1.19.373 with no explicit bump. run 32906477349. (task 1002620)
|
|
1179
1181
|
// ---------------------------------------------------------------------------
|
|
1180
|
-
const CORE_VERSION = '1.19.
|
|
1182
|
+
const CORE_VERSION = '1.19.374'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1181
1183
|
|
|
1182
1184
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1183
1185
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// tests/profile_nudge_prefs.mjs — the "Complete your profile" nudge shaper
|
|
2
|
+
// (task 1002802 / P06, goal 1000045).
|
|
3
|
+
//
|
|
4
|
+
// The nudge replaced the profile-setup wizard spec Decision 2 rejected: a
|
|
5
|
+
// DISMISSIBLE line item in the hall's needs-attention inbox, shown when bio or
|
|
6
|
+
// preferred_disciplines are empty. These pin the pure decision layer — what
|
|
7
|
+
// counts as missing, when the line shows, and that a dismissal is durable and
|
|
8
|
+
// reversible without disturbing the sibling knobs sharing behavior_prefs.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test tests/profile_nudge_prefs.mjs
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { test } from 'node:test';
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
const p = require('../modules/builder-settings/profile-nudge-prefs.js');
|
|
18
|
+
|
|
19
|
+
test('missingProfileFields: empty, whitespace, and absent all count as missing', () => {
|
|
20
|
+
assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: [] }), ['bio', 'disciplines']);
|
|
21
|
+
assert.deepEqual(p.missingProfileFields({ bio: ' \n\t ', preferredDisciplines: [] }), ['bio', 'disciplines']);
|
|
22
|
+
assert.deepEqual(p.missingProfileFields({}), ['bio', 'disciplines']);
|
|
23
|
+
assert.deepEqual(p.missingProfileFields({ bio: null, preferredDisciplines: null }), ['bio', 'disciplines']);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('missingProfileFields: a real value on either side drops only that entry', () => {
|
|
27
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'I build things', preferredDisciplines: [] }), ['disciplines']);
|
|
28
|
+
assert.deepEqual(p.missingProfileFields({ bio: '', preferredDisciplines: ['engineer'] }), ['bio']);
|
|
29
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: ['engineer'] }), []);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('missingProfileFields: a non-array disciplines value is missing, not a crash', () => {
|
|
33
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: 'engineer' }), ['disciplines']);
|
|
34
|
+
assert.deepEqual(p.missingProfileFields({ bio: 'x', preferredDisciplines: {} }), ['disciplines']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('nudgeDismissed: defaults to NOT dismissed; only a real boolean overrides', () => {
|
|
38
|
+
assert.equal(p.nudgeDismissed({}), false);
|
|
39
|
+
assert.equal(p.nudgeDismissed(null), false);
|
|
40
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: true }), true);
|
|
41
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: false }), false);
|
|
42
|
+
// Garbage in the blob must not read as dismissed — that would silence the
|
|
43
|
+
// nudge for a builder who never dismissed it.
|
|
44
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 'yes' }), false);
|
|
45
|
+
assert.equal(p.nudgeDismissed({ profile_nudge_dismissed: 1 }), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('shouldNudge: shows only when something is missing AND not dismissed', () => {
|
|
49
|
+
const missing = { bio: '', preferredDisciplines: [] };
|
|
50
|
+
const complete = { bio: 'x', preferredDisciplines: ['engineer'] };
|
|
51
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...missing }), true);
|
|
52
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...missing }), false);
|
|
53
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: {}, ...complete }), false);
|
|
54
|
+
assert.equal(p.shouldNudge({ behaviorPrefs: { profile_nudge_dismissed: true }, ...complete }), false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('describeForApi: reports what is missing even while dismissed (show is the gate)', () => {
|
|
58
|
+
const d = p.describeForApi({ profile_nudge_dismissed: true }, { bio: '', preferredDisciplines: [] });
|
|
59
|
+
assert.equal(d.dismissed, true);
|
|
60
|
+
assert.equal(d.show, false, 'a dismissed nudge never shows');
|
|
61
|
+
assert.deepEqual(d.missing, ['bio', 'disciplines'], 'but the API still says what is empty');
|
|
62
|
+
assert.equal(d.override, true);
|
|
63
|
+
assert.equal(d.default_dismissed, false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('validatePatch: accepts dismissed true/false/null, refuses everything else', () => {
|
|
67
|
+
assert.deepEqual(p.validatePatch({ dismissed: true }), { ok: true, patch: { profile_nudge_dismissed: true } });
|
|
68
|
+
assert.deepEqual(p.validatePatch({ dismissed: false }), { ok: true, patch: { profile_nudge_dismissed: false } });
|
|
69
|
+
assert.deepEqual(p.validatePatch({ dismissed: null }), { ok: true, patch: { profile_nudge_dismissed: null } });
|
|
70
|
+
assert.deepEqual(p.validatePatch({}), { ok: true, patch: {} }, 'empty body is a valid no-op');
|
|
71
|
+
assert.equal(p.validatePatch({ dismissed: 'yes' }).ok, false);
|
|
72
|
+
assert.equal(p.validatePatch({ nope: true }).ok, false);
|
|
73
|
+
assert.equal(p.validatePatch(null).ok, false);
|
|
74
|
+
assert.equal(p.validatePatch([]).ok, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('applyPatch: null clears the key; sibling behavior knobs are never disturbed', () => {
|
|
78
|
+
const existing = { wandering: 'focused', widgets: false, interaction: { technical_level: 'engineer' } };
|
|
79
|
+
const set = p.applyPatch(existing, { profile_nudge_dismissed: true });
|
|
80
|
+
assert.equal(set.profile_nudge_dismissed, true);
|
|
81
|
+
assert.equal(set.wandering, 'focused');
|
|
82
|
+
assert.equal(set.widgets, false);
|
|
83
|
+
assert.deepEqual(set.interaction, { technical_level: 'engineer' });
|
|
84
|
+
// Clearing removes the key entirely (back to the sparse default) and still
|
|
85
|
+
// leaves the siblings alone — the undismiss path.
|
|
86
|
+
const cleared = p.applyPatch(set, { profile_nudge_dismissed: null });
|
|
87
|
+
assert.equal('profile_nudge_dismissed' in cleared, false);
|
|
88
|
+
assert.equal(cleared.wandering, 'focused');
|
|
89
|
+
assert.equal(p.nudgeDismissed(cleared), false, 'cleared → the nudge can return');
|
|
90
|
+
// Pure: the input blob is not mutated.
|
|
91
|
+
assert.equal('profile_nudge_dismissed' in existing, false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('the reserved key is the documented one (behavior_prefs is shared)', () => {
|
|
95
|
+
assert.equal(p.RESERVED_KEY, 'profile_nudge_dismissed');
|
|
96
|
+
assert.equal(p.DEFAULT_DISMISSED, false);
|
|
97
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// tests/ship_push_diagnosis.mjs — a terminal push failure must not advise a retry
|
|
2
|
+
// (task 1003293).
|
|
3
|
+
//
|
|
4
|
+
// A failed `git push` in ciLandLocal already parks the task at `confirmed` and
|
|
5
|
+
// opens no PR, which is correct. The ADVICE was the defect: "Fix the push and
|
|
6
|
+
// re-ship" cannot work for the most common cause — a commit touching
|
|
7
|
+
// .github/workflows/ pushed with a credential lacking GitHub's `workflow` scope.
|
|
8
|
+
// Re-shipping fails identically, and the reconciler cannot heal a branch that
|
|
9
|
+
// never reached the remote, so the task sits at `confirmed` looking like an
|
|
10
|
+
// ordinary pending land while its code exists on exactly one machine. That
|
|
11
|
+
// happened twice in one session.
|
|
12
|
+
//
|
|
13
|
+
// These tests pin the distinction the fix turns on: TERMINAL (retrying is
|
|
14
|
+
// pointless — say what actually resolves it) vs transient (retrying is the
|
|
15
|
+
// answer).
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const { pushDiagnosis, PUSH_DIAGNOSES } = require(path.join(ROOT, 'scripts', 'gds', 'ship-land.js'));
|
|
25
|
+
|
|
26
|
+
test('THE case that stranded two tasks: the workflow scope rejection', () => {
|
|
27
|
+
// GitHub's real wording, verbatim from the run that stranded task 1003283.
|
|
28
|
+
const d = pushDiagnosis(
|
|
29
|
+
'remote: error: refusing to allow an OAuth App to create or update workflow '
|
|
30
|
+
+ '`.github/workflows/publish.yml` without `workflow` scope\n'
|
|
31
|
+
+ 'To https://github.com/owner/repo.git\n ! [remote rejected] branch -> branch');
|
|
32
|
+
assert.equal(d.id, 'workflow_scope');
|
|
33
|
+
assert.equal(d.terminal, true, 'retrying this can never succeed — saying "re-ship" is what stranded the work');
|
|
34
|
+
assert.match(d.fix, /ssh|workflow/i, 'the advice must name the remedy that actually works');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('branch protection and a dead credential are terminal too', () => {
|
|
38
|
+
assert.equal(pushDiagnosis('remote: error: GH006: Protected branch update failed').terminal, true);
|
|
39
|
+
assert.equal(pushDiagnosis('fatal: Authentication failed for https://github.com/o/r.git/').terminal, true);
|
|
40
|
+
assert.equal(pushDiagnosis('git@github.com: Permission denied (publickey).').terminal, true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a stale branch IS retryable — the honest "re-ship" case', () => {
|
|
44
|
+
const d = pushDiagnosis('error: failed to push some refs\nUpdates were rejected because the remote contains work that you do not have locally');
|
|
45
|
+
assert.equal(d.id, 'stale_branch');
|
|
46
|
+
assert.equal(d.terminal, false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('an unrecognised failure defaults to RETRYABLE, never to terminal', () => {
|
|
50
|
+
// The safe direction. A wrong "this can never work" sends someone hunting a
|
|
51
|
+
// problem that does not exist; a wrong "try again" costs one command.
|
|
52
|
+
const d = pushDiagnosis('fatal: the remote end hung up unexpectedly');
|
|
53
|
+
assert.equal(d.id, 'unknown');
|
|
54
|
+
assert.equal(d.terminal, false);
|
|
55
|
+
assert.equal(pushDiagnosis('').terminal, false);
|
|
56
|
+
assert.equal(pushDiagnosis(null).terminal, false);
|
|
57
|
+
assert.equal(pushDiagnosis(undefined).terminal, false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('every diagnosis carries both a cause and a remedy', () => {
|
|
61
|
+
// A classification with no `fix` is just a nicer way of saying "it broke",
|
|
62
|
+
// which is the state this replaced.
|
|
63
|
+
for (const d of PUSH_DIAGNOSES) {
|
|
64
|
+
assert.ok(d.id && d.why && d.fix, `incomplete diagnosis: ${JSON.stringify(d)}`);
|
|
65
|
+
assert.ok(d.why.length > 20, `${d.id}: "why" must explain, not label`);
|
|
66
|
+
assert.equal(typeof d.terminal, 'boolean');
|
|
67
|
+
}
|
|
68
|
+
const fallback = pushDiagnosis('anything at all');
|
|
69
|
+
assert.ok(fallback.why && fallback.fix);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the patterns do not collide — the first match is the RIGHT match', () => {
|
|
73
|
+
// A workflow-scope rejection also contains "error:" and often "403"; if the
|
|
74
|
+
// auth pattern matched it first, the advice would send someone to re-login
|
|
75
|
+
// instead of to SSH, and the push would fail again.
|
|
76
|
+
const workflowMsg = 'remote: error: refusing to allow an OAuth App to create or update workflow `.github/workflows/x.yml` without `workflow` scope';
|
|
77
|
+
assert.equal(pushDiagnosis(workflowMsg).id, 'workflow_scope');
|
|
78
|
+
});
|
package/tests/sound_player.mjs
CHANGED
|
@@ -343,3 +343,41 @@ test('playerFor never throws and returns null or a [cmd, args] pair', () => {
|
|
|
343
343
|
assert.ok(Array.isArray(got[1]));
|
|
344
344
|
}
|
|
345
345
|
});
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// readPrefs honours the path it was GIVEN (task 1003293).
|
|
349
|
+
//
|
|
350
|
+
// The legacy-path fallback used to fire for any unreadable path, so a caller
|
|
351
|
+
// naming an explicit file silently received a different one — the user's real
|
|
352
|
+
// prefs. That made the two fail-open tests above pass in CI (no prefs on a
|
|
353
|
+
// runner) and fail on any machine where the builder had saved sound settings:
|
|
354
|
+
// green where nobody looks, red where everybody does. The fallback is still
|
|
355
|
+
// right for the DEFAULT call; it is wrong the moment a caller names a file.
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
test('an explicit missing path fails open — it never substitutes another file', () => {
|
|
359
|
+
// The regression: write a legacy-shaped prefs file at the pre-rename location
|
|
360
|
+
// and prove an explicit path still ignores it. Skipped if the real file exists,
|
|
361
|
+
// because this test must never clobber a builder's own settings.
|
|
362
|
+
const legacy = player.LEGACY_PREFS_PATH;
|
|
363
|
+
if (!legacy || fs.existsSync(legacy)) {
|
|
364
|
+
// Nothing to plant, but the invariant is still checkable: an absent explicit
|
|
365
|
+
// path must yield the fail-open default, whatever the legacy file holds.
|
|
366
|
+
const p = player.readPrefs(path.join(os.tmpdir(), `otb-absent-${process.pid}.json`));
|
|
367
|
+
assert.equal(p.volume, 100);
|
|
368
|
+
assert.equal(p.enabled('ship'), true);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
|
372
|
+
fs.writeFileSync(legacy, JSON.stringify({ volume: 11, ship: false }));
|
|
373
|
+
try {
|
|
374
|
+
const p = player.readPrefs(path.join(os.tmpdir(), `otb-absent-${process.pid}.json`));
|
|
375
|
+
assert.equal(p.volume, 100, 'an explicit path must not inherit the legacy file volume');
|
|
376
|
+
assert.equal(p.enabled('ship'), true, 'an explicit path must not inherit legacy mutes');
|
|
377
|
+
// …while the DEFAULT call still honours it — the fallback is not removed.
|
|
378
|
+
const d = player.readPrefs();
|
|
379
|
+
assert.equal(d.volume, 11, 'the default path must still fall back to the legacy file');
|
|
380
|
+
} finally {
|
|
381
|
+
fs.unlinkSync(legacy);
|
|
382
|
+
}
|
|
383
|
+
});
|