@bongos/core 1.19.663 → 1.19.665
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 +40 -25
- package/docs/copy-inventory.md +28 -17
- package/docs/copy-registry.json +121 -22
- package/docs/module-api-changelog.md +14 -0
- package/modules/hall-ui/public/hall-kit.js +36 -2
- package/modules/hall-ui/public/studio.css +121 -1
- package/modules/hall-ui/public/studio.html +5 -1
- package/modules/hall-ui/public/studio.js +337 -13
- package/modules/hall-ui/public/studio.states.json +1 -1
- package/modules/provisioning/credential-preflight.js +170 -0
- package/modules/provisioning/routes/provisioning.js +16 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +11 -0
- package/scripts/hall-preview/task-visual.js +89 -0
- package/src/module-api.js +15 -1
- package/tests/hall_studio_world.mjs +435 -8
- package/tests/module_api.mjs +1 -0
- package/tests/provisioning_credential_preflight.mjs +153 -0
- package/tests/provisioning_onboard_mode.mjs +13 -0
- package/tests/rank_tier_single_source.mjs +8 -1
- package/tests/task_visuals.mjs +35 -0
|
@@ -16,21 +16,35 @@
|
|
|
16
16
|
// shows people. Best-effort: an instance without the copy-desk module has
|
|
17
17
|
// no queue, and the room degrades to reviews alone rather than erroring.
|
|
18
18
|
//
|
|
19
|
-
// It
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
19
|
+
// It gates NOTHING (ADR 0162 / ADR 0241): nothing here holds a ship or a deploy.
|
|
20
|
+
// And it writes NOTHING to any live string or asset — the goal's no-live-CMS
|
|
21
|
+
// rule, unchanged. What it DOES write, since task 1003829, is the artist's own
|
|
22
|
+
// VERDICT on their own queue: a review is a task in the ledger, and the three
|
|
23
|
+
// weights below move that task the way triage always could. The craft itself
|
|
24
|
+
// still happens on the surface that owns it (the task record, the copy desk),
|
|
25
|
+
// and every change to the product still lands as a claimed, graded, shipped
|
|
26
|
+
// task. The allowlist of what may be written is WRITE_ROUTES below, pinned in
|
|
27
|
+
// tests/hall_studio_world.mjs.
|
|
28
|
+
//
|
|
29
|
+
// THE PICTURE (task 1003829). A review names the shipped task it is about, and
|
|
30
|
+
// that task may carry a ship-time visual. The room reads it for the card in
|
|
31
|
+
// focus and renders the image AS the card, because "look at it" is the whole of
|
|
32
|
+
// what an artist is being asked to do here. One read per card, lazily, so a
|
|
33
|
+
// 50-deep queue still costs one /tasks/:id.
|
|
23
34
|
|
|
24
35
|
(() => {
|
|
25
36
|
'use strict';
|
|
26
37
|
|
|
27
38
|
const API = '/api/bongos';
|
|
28
39
|
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
29
|
-
const { escapeHtml,
|
|
40
|
+
const { escapeHtml, $, toast, errMessage } = window.OTB;
|
|
41
|
+
const kit = window.OTBKit;
|
|
30
42
|
|
|
31
43
|
let viewer = null;
|
|
32
44
|
let pieces = []; // the combined garden: [{ type:'review'|'flag', … }]
|
|
33
45
|
let idx = 0; // which stone is in focus
|
|
46
|
+
let busy = false; // a verdict is in flight — one at a time, so a double-click cannot file twice
|
|
47
|
+
let tended = false; // at least one verdict landed this sitting, so an emptied garden reads as finished
|
|
34
48
|
|
|
35
49
|
// Bound the reviews read. cascade.js files a review per qualifying ship and they
|
|
36
50
|
// sit at backlog until claimed or dismissed ("dismissable rather than nagging"),
|
|
@@ -42,6 +56,43 @@
|
|
|
42
56
|
// keeps the most-prioritised waiting reviews.
|
|
43
57
|
const REVIEW_LIMIT = 50;
|
|
44
58
|
|
|
59
|
+
// The ONLY writes this room may issue, as an explicit allowlist rather than a
|
|
60
|
+
// convention (tests/hall_studio_world.mjs asserts studio.js writes nothing
|
|
61
|
+
// outside it). Each moves the REVIEW TASK — the artist's own queue item — and
|
|
62
|
+
// none of them touches a live string, an asset, a claim, a grade or a deploy:
|
|
63
|
+
//
|
|
64
|
+
// abandon looks good → the review is dismissed, which cascade.js's own
|
|
65
|
+
// body calls "a real, expected outcome".
|
|
66
|
+
// patch note it → the artist's words are appended to the review's
|
|
67
|
+
// brief, which is what whoever claims it reads.
|
|
68
|
+
// create needs work → the words become a new task in the reviewed
|
|
69
|
+
// work's goal…
|
|
70
|
+
// merge → …and the review is closed against that task, so
|
|
71
|
+
// the follow-up carries the lineage instead of the
|
|
72
|
+
// review just vanishing.
|
|
73
|
+
//
|
|
74
|
+
// All four floor at METIC server-side (modules/government/catalog.js:
|
|
75
|
+
// task.abandon / task.edit / task.create / task.merge). The room therefore
|
|
76
|
+
// OFFERS them only to a viewer who holds that floor — the task 1003751 lesson,
|
|
77
|
+
// that a card must not print a verb its route will refuse. ADR 0016 still
|
|
78
|
+
// stands: this is cosmetic, and the routes are the wall.
|
|
79
|
+
const WRITE_ROUTES = Object.freeze({
|
|
80
|
+
abandon: (reviewId) => `${API}/tasks/${reviewId}/abandon`,
|
|
81
|
+
patch: (reviewId) => `${API}/tasks/${reviewId}`,
|
|
82
|
+
create: () => `${API}/tasks`,
|
|
83
|
+
merge: (reviewId) => `${API}/tasks/${reviewId}/merge`,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The verdict floor, read off /me. Local like goals-render.js's copy — four
|
|
87
|
+
// ranks, one comparison, and no reason for the kit to own a ladder.
|
|
88
|
+
const RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
|
|
89
|
+
const MAX_WORDS = 2000; // the abandon/merge `reason` cap the routes apply anyway
|
|
90
|
+
|
|
91
|
+
function canVerdict() {
|
|
92
|
+
const rank = String((viewer && viewer.rank) || '').toLowerCase();
|
|
93
|
+
return (RANK_ORDER[rank] ?? 0) >= RANK_ORDER.metic;
|
|
94
|
+
}
|
|
95
|
+
|
|
45
96
|
// ---------------------------------------------------------------------------
|
|
46
97
|
// helpers
|
|
47
98
|
// ---------------------------------------------------------------------------
|
|
@@ -80,18 +131,81 @@
|
|
|
80
131
|
// the focus card, one shape per kind of work
|
|
81
132
|
// ---------------------------------------------------------------------------
|
|
82
133
|
|
|
134
|
+
// THE PLATE — the reviewed work's ship-time visual, at the size the card can
|
|
135
|
+
// give it. Through the kit's one renderer (OTBKit.taskVisualFigureHtml, which
|
|
136
|
+
// carries the server-minted-URL guard and the escaping) with the Studio's own
|
|
137
|
+
// class, because the size is this surface's decision: here the picture is the
|
|
138
|
+
// subject, not an illustration beside a record.
|
|
139
|
+
//
|
|
140
|
+
// Draws nothing when there is no visual, when the reviewed task could not be
|
|
141
|
+
// read, or when the kit is absent — a review with no picture must read as a
|
|
142
|
+
// review, never as an empty frame (the degrade rule).
|
|
143
|
+
function plateHtml(p) {
|
|
144
|
+
if (!p.shipped || !kit || typeof kit.taskVisualFigureHtml !== 'function') return '';
|
|
145
|
+
return kit.taskVisualFigureHtml(p.shipped, { className: 'studio-plate' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The three weights, and the one field they share. Offered only to a viewer
|
|
149
|
+
// whose rank clears the routes' floor (see WRITE_ROUTES) — otherwise the card
|
|
150
|
+
// says plainly where the verdict is recorded instead of printing three
|
|
151
|
+
// buttons that would 403.
|
|
152
|
+
//
|
|
153
|
+
// "looks good" takes words or no words; the other two require them, because a
|
|
154
|
+
// note with no note and a follow-up task with no brief are both worse than
|
|
155
|
+
// nothing. The check is in the handler, and it answers inline.
|
|
156
|
+
function verdictHtml(p) {
|
|
157
|
+
if (!canVerdict()) {
|
|
158
|
+
return `<p class="studio-verdict__sealed">Your verdict on a review is recorded from the review itself — open it to dismiss it, note what you saw, or file the follow-up.</p>`;
|
|
159
|
+
}
|
|
160
|
+
const id = escapeHtml(String(p.taskId));
|
|
161
|
+
return `
|
|
162
|
+
<div class="studio-verdict">
|
|
163
|
+
<label class="studio-verdict__label" for="studio-words">What you saw <small>— your words, in your voice. Needed for a note or a follow-up.</small></label>
|
|
164
|
+
<textarea class="studio-verdict__words" id="studio-words" rows="3" maxlength="${MAX_WORDS}"
|
|
165
|
+
placeholder="The headline lands, but the sub-line reads like a brochure."></textarea>
|
|
166
|
+
<p class="studio-verdict__err" id="studio-verdict-err" hidden></p>
|
|
167
|
+
<!-- Three ghosts, no accent: the weights are three equal answers, and
|
|
168
|
+
the One Warm Thing Rule spends the accent on the door to the craft
|
|
169
|
+
below. Nothing here should push an artist toward filing work. -->
|
|
170
|
+
<div class="studio-verdict__weights">
|
|
171
|
+
<button type="button" class="btn-ghost" data-verdict="good" data-review="${id}">Looks good</button>
|
|
172
|
+
<button type="button" class="btn-ghost" data-verdict="note" data-review="${id}">Note it</button>
|
|
173
|
+
<button type="button" class="btn-ghost" data-verdict="work" data-review="${id}">Needs work</button>
|
|
174
|
+
</div>
|
|
175
|
+
</div>`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The two steps of the craft (R04) — what happens INSIDE a review once it is
|
|
179
|
+
// opened. Today's copy, and the whole of what a review card could say before
|
|
180
|
+
// there was a picture on it.
|
|
181
|
+
const STEPS = `
|
|
182
|
+
<ol class="studio-steps">
|
|
183
|
+
<li class="studio-step"><span class="studio-step__n">01</span><span>Coach the grader<small>Review how it judged this work, and teach it what good looked like.</small></span></li>
|
|
184
|
+
<li class="studio-step"><span class="studio-step__n">02</span><span>Remake or tweak<small>Only if it needs it — with the AI at your side, or on your own.</small></span></li>
|
|
185
|
+
</ol>`;
|
|
186
|
+
|
|
83
187
|
function reviewCard(p, n) {
|
|
84
188
|
const title = p.shippedId
|
|
85
189
|
? `The copy and visuals that shipped in task #${escapeHtml(p.shippedId)}`
|
|
86
190
|
: escapeHtml(p.title || 'A review of what shipped');
|
|
191
|
+
const plate = plateHtml(p);
|
|
192
|
+
// WITH A PICTURE the card is the picture: one line of framing, then the
|
|
193
|
+
// verdict, so the thing being judged and the answer to it are on one screen.
|
|
194
|
+
// The two steps describe the work done AFTER opening the review, and they
|
|
195
|
+
// belong behind that door rather than between the artist and their answer.
|
|
196
|
+
//
|
|
197
|
+
// WITHOUT ONE the card degrades to exactly the copy it has always shown —
|
|
198
|
+
// the framing paragraph and both steps — so a review with no visual still
|
|
199
|
+
// reads as a review and never as an empty frame.
|
|
200
|
+
const body = plate
|
|
201
|
+
? `<p class="studio-card__context">It shipped and it is live. Say what you see — your review coaches the grader and never blocks what shipped.</p>`
|
|
202
|
+
: `<p class="studio-card__context">It shipped and it is live. Your review coaches the grader on how it judged the work, then remakes or tweaks the words or art only if they need it — it never blocks what shipped.</p>${STEPS}`;
|
|
87
203
|
return `
|
|
88
204
|
<p class="studio-card__eyebrow">to tend now · ${escapeHtml(n)}</p>
|
|
89
205
|
<h2 class="studio-card__title">${title}</h2>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<li class="studio-step"><span class="studio-step__n">02</span><span>Remake or tweak<small>Only if it needs it — with the AI at your side, or on your own.</small></span></li>
|
|
94
|
-
</ol>
|
|
206
|
+
${plate}
|
|
207
|
+
${body}
|
|
208
|
+
${verdictHtml(p)}
|
|
95
209
|
<div class="studio-card__actions">
|
|
96
210
|
<a class="btn-accent" href="/builders/task/${escapeHtml(String(p.taskId))}">Open this review</a>
|
|
97
211
|
<button type="button" class="btn-ghost" data-skip>Let it be for now</button>
|
|
@@ -139,11 +253,17 @@
|
|
|
139
253
|
const focus = $('#studio-focus');
|
|
140
254
|
const rest = $('#studio-rest');
|
|
141
255
|
|
|
142
|
-
|
|
256
|
+
// An emptied garden reads as FINISHED once the reader has actually tended
|
|
257
|
+
// something this sitting, and as quiet when there was nothing to begin with.
|
|
258
|
+
if (!pieces.length) { focus.innerHTML = emptyCard(tended); rest.hidden = true; return; }
|
|
143
259
|
if (idx >= pieces.length) { focus.innerHTML = emptyCard(true); rest.hidden = true; return; }
|
|
144
260
|
|
|
145
261
|
const p = pieces[idx];
|
|
146
262
|
focus.innerHTML = p.type === 'review' ? reviewCard(p, `${idx + 1} of ${pieces.length}`) : flagCard(p, `${idx + 1} of ${pieces.length}`);
|
|
263
|
+
// The reviewed work is read for the card in FOCUS, and only once. hydrate()
|
|
264
|
+
// repaints when it lands, so the card appears immediately and the picture
|
|
265
|
+
// arrives a beat later rather than the room waiting on a second request.
|
|
266
|
+
hydrate(p);
|
|
147
267
|
|
|
148
268
|
// The rest of the garden: one stone per remaining piece, quiet and
|
|
149
269
|
// non-interactive — the room asks the reader to tend one thing, not to scan.
|
|
@@ -160,6 +280,190 @@
|
|
|
160
280
|
}
|
|
161
281
|
}
|
|
162
282
|
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
// the reviewed work — read for the card in focus, once
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
// Hydrate ONE review with the task it is about, then repaint. Lazy on purpose:
|
|
288
|
+
// the queue is capped at 50 and the room shows one piece at a time, so eagerly
|
|
289
|
+
// reading every reviewed task would be 50 requests to render one card — the
|
|
290
|
+
// unbounded-read shape modules/hall-ui/CLAUDE.md flags. `hydrated` is set
|
|
291
|
+
// whether or not the read succeeded, so a 404 or a 403 is asked once and the
|
|
292
|
+
// card then simply carries no picture.
|
|
293
|
+
async function hydrate(p) {
|
|
294
|
+
if (!p || p.type !== 'review' || p.hydrated || !p.shippedId) return;
|
|
295
|
+
p.hydrated = true;
|
|
296
|
+
const res = await api.request('GET', `${API}/tasks/${encodeURIComponent(p.shippedId)}`);
|
|
297
|
+
if (!res.ok) return;
|
|
298
|
+
const t = (res.data && res.data.task) || null;
|
|
299
|
+
if (!t) return;
|
|
300
|
+
// The security_sensitive narrowing, taken from the server's own audience
|
|
301
|
+
// predicate (modules/lifecycle/task-visuals.js isPubliclyViewable, and the
|
|
302
|
+
// public feed's SQL): a security_sensitive task's image is the one visual
|
|
303
|
+
// the platform does not re-publish, so this room does not re-publish it
|
|
304
|
+
// either. The review still reads as a review — it degrades to the copy.
|
|
305
|
+
//
|
|
306
|
+
// BE CLEAR WHAT THIS IS: a UI choice, not a wall. isPubliclyViewable gates
|
|
307
|
+
// the UNAUTHENTICATED path, so GET /tasks/:id hands the full visual_url and
|
|
308
|
+
// visual_alt to any signed-in builder and the response in this very browser
|
|
309
|
+
// already carries them. Dropping them here keeps a sensitive image off a
|
|
310
|
+
// room that exists for looking at things, and keeps the URL out of the DOM
|
|
311
|
+
// and out of a screenshot of it. It secures nothing on its own, and nothing
|
|
312
|
+
// here should be read as a substitute for the server's gate.
|
|
313
|
+
p.shipped = t.security_sensitive === true ? { id: t.id } : t;
|
|
314
|
+
if (pieces[idx] === p) paint();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// the verdict — the artist's answer, recorded against the review
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
function words() {
|
|
322
|
+
const el = $('#studio-words');
|
|
323
|
+
return el ? String(el.value || '').trim().slice(0, MAX_WORDS) : '';
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function verdictError(msg) {
|
|
327
|
+
const el = $('#studio-verdict-err');
|
|
328
|
+
if (!el) return;
|
|
329
|
+
el.textContent = msg || '';
|
|
330
|
+
el.hidden = !msg;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The review is done with: drop it from the garden and move to the next stone
|
|
334
|
+
// without renumbering what the reader already passed.
|
|
335
|
+
function retire(p) {
|
|
336
|
+
const at = pieces.indexOf(p);
|
|
337
|
+
if (at === -1) return;
|
|
338
|
+
tended = true;
|
|
339
|
+
pieces.splice(at, 1);
|
|
340
|
+
if (idx > at) idx -= 1;
|
|
341
|
+
paintHead();
|
|
342
|
+
paint();
|
|
343
|
+
window.scrollTo({ top: 0 });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// "Looks good" — dismiss. cascade.js's own body says this is a real and
|
|
347
|
+
// expected outcome, so it is a first-class button and not a hidden one. The
|
|
348
|
+
// words are optional here and ride along as the retire reason, which
|
|
349
|
+
// db.abandonTask stores on the task as "Abandoned: <reason>".
|
|
350
|
+
async function verdictGood(p) {
|
|
351
|
+
const said = words();
|
|
352
|
+
const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
|
|
353
|
+
const reason = `Reviewed in the studio by ${who} — the copy and visuals are good as shipped.${said ? ` ${said}` : ''}`;
|
|
354
|
+
const res = await api.request('POST', WRITE_ROUTES.abandon(p.taskId), { reason });
|
|
355
|
+
if (!res.ok) return verdictError(errMessage(res.data) || 'That could not be recorded. Nothing changed.');
|
|
356
|
+
toast('Dismissed — the copy and visuals are good as shipped.');
|
|
357
|
+
retire(p);
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// "Note it" — the artist's words, recorded against the review, which stays
|
|
362
|
+
// open. They are APPENDED to the review's own description because that is the
|
|
363
|
+
// text whoever claims this review actually reads; the route audit-logs every
|
|
364
|
+
// description edit, so the note has a trail as well as a home.
|
|
365
|
+
//
|
|
366
|
+
// THE BASE IS RE-READ, not taken from the queue row, and both halves of that
|
|
367
|
+
// matter. PATCH description REPLACES the field, so appending to a stale or
|
|
368
|
+
// absent base does not add a note — it DELETES the cascade's brief. The list
|
|
369
|
+
// read happens to carry `description` today (db-tasks.js listTasks is
|
|
370
|
+
// SELECT t.*), but that projection is already under pressure from the board's
|
|
371
|
+
// read sizes, and a narrowing there must not silently blank a review body
|
|
372
|
+
// here. The re-read also means a second artist noting on the same review
|
|
373
|
+
// appends after the first instead of overwriting them. No base, no write.
|
|
374
|
+
async function verdictNote(p) {
|
|
375
|
+
const said = words();
|
|
376
|
+
if (!said) return verdictError('Write what you saw first — a note with no note is worse than none.');
|
|
377
|
+
const cur = await api.request('GET', `${API}/tasks/${p.taskId}`);
|
|
378
|
+
const base = cur.ok && cur.data && cur.data.task ? String(cur.data.task.description || '') : null;
|
|
379
|
+
if (base === null) {
|
|
380
|
+
return verdictError('The review could not be re-read just now, so nothing was written — try again in a moment.');
|
|
381
|
+
}
|
|
382
|
+
const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
|
|
383
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
384
|
+
const block = `\n\n---\n\nARTIST NOTE (${stamp}, ${who}, from the studio):\n\n${said}`;
|
|
385
|
+
const res = await api.request('PATCH', WRITE_ROUTES.patch(p.taskId), { description: `${base}${block}` });
|
|
386
|
+
if (!res.ok) return verdictError(errMessage(res.data) || 'That could not be recorded. Nothing changed.');
|
|
387
|
+
p.description = `${base}${block}`;
|
|
388
|
+
toast('Noted on the review — it stays open.');
|
|
389
|
+
const el = $('#studio-words');
|
|
390
|
+
if (el) el.value = '';
|
|
391
|
+
verdictError('');
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// "Needs work" — the words become a NEW task in the reviewed work's goal, and
|
|
396
|
+
// the review is closed AGAINST it.
|
|
397
|
+
//
|
|
398
|
+
// No live edit happens here and none can: what this files is an ordinary
|
|
399
|
+
// backlog task that somebody has to claim, build, have graded and ship, which
|
|
400
|
+
// is goal 1000074's hard rule stated as a mechanism rather than a promise.
|
|
401
|
+
//
|
|
402
|
+
// The review's own goal_id/version_id are the reviewed work's — cascade.js
|
|
403
|
+
// property 3: a generated review inherits the source task's goal and version.
|
|
404
|
+
// So the follow-up lands beside the work it is about with no second read.
|
|
405
|
+
//
|
|
406
|
+
// source_ref makes the pair idempotent: POST /tasks keys on (source,
|
|
407
|
+
// source_ref), so a double-click or a retry resolves to the SAME follow-up
|
|
408
|
+
// rather than minting a second one, and the merge below is idempotent when it
|
|
409
|
+
// re-merges into the target already recorded.
|
|
410
|
+
async function verdictWork(p) {
|
|
411
|
+
const said = words();
|
|
412
|
+
if (!said) return verdictError('Write what needs work first — a follow-up with no brief cannot be claimed.');
|
|
413
|
+
const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
|
|
414
|
+
const about = p.shippedId ? `task ${p.shippedId}` : `review ${p.taskId}`;
|
|
415
|
+
const created = await api.request('POST', WRITE_ROUTES.create(), {
|
|
416
|
+
version_id: p.versionId,
|
|
417
|
+
goal_id: p.goalId ? Number(p.goalId) : undefined,
|
|
418
|
+
title: `Artist verdict: the copy or visuals shipped in ${about} need work`,
|
|
419
|
+
description: [
|
|
420
|
+
`Filed from the studio by ${who}, as the verdict on artist review ${p.taskId} of ${about}.`,
|
|
421
|
+
'',
|
|
422
|
+
'WHAT THE ARTIST SAW:',
|
|
423
|
+
'',
|
|
424
|
+
said,
|
|
425
|
+
'',
|
|
426
|
+
'This is ordinary work: claim it, make the change, have it graded and ship it. The studio files the judgement and never edits a live string or asset (goal 1000074\'s rule).',
|
|
427
|
+
].join('\n'),
|
|
428
|
+
discipline: 'artist',
|
|
429
|
+
source: 'studio-verdict',
|
|
430
|
+
source_ref: `studio-needs-work-on-review-${p.taskId}`,
|
|
431
|
+
});
|
|
432
|
+
if (!created.ok) return verdictError(errMessage(created.data) || 'The follow-up could not be filed. Nothing changed.');
|
|
433
|
+
const newId = created.data && created.data.task && created.data.task.id;
|
|
434
|
+
if (!newId) return verdictError('The follow-up came back without an id, so the review was left open.');
|
|
435
|
+
// Close the review against the follow-up. If this half fails the follow-up
|
|
436
|
+
// still exists and is reported as such — a stranded-but-visible task beats
|
|
437
|
+
// a silent success, and re-running the same verdict is safe.
|
|
438
|
+
const closed = await api.request('POST', WRITE_ROUTES.merge(p.taskId), {
|
|
439
|
+
into_task_id: Number(newId),
|
|
440
|
+
reason: `Artist verdict from the studio: the review's answer is task ${newId}, which carries the work.`,
|
|
441
|
+
});
|
|
442
|
+
if (!closed.ok) {
|
|
443
|
+
return verdictError(`Task ${newId} was filed, but this review could not be closed against it — close it from the review itself.`);
|
|
444
|
+
}
|
|
445
|
+
toast(`Filed as task ${newId} — this review is closed against it.`);
|
|
446
|
+
retire(p);
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const VERDICTS = { good: verdictGood, note: verdictNote, work: verdictWork };
|
|
451
|
+
|
|
452
|
+
async function runVerdict(kind) {
|
|
453
|
+
const p = pieces[idx];
|
|
454
|
+
if (busy || !p || p.type !== 'review' || !VERDICTS[kind]) return;
|
|
455
|
+
if (!canVerdict()) return;
|
|
456
|
+
busy = true;
|
|
457
|
+
verdictError('');
|
|
458
|
+
try {
|
|
459
|
+
await VERDICTS[kind](p);
|
|
460
|
+
} catch (_err) {
|
|
461
|
+
verdictError('That could not be recorded. Nothing changed.');
|
|
462
|
+
} finally {
|
|
463
|
+
busy = false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
163
467
|
// ---------------------------------------------------------------------------
|
|
164
468
|
// load
|
|
165
469
|
// ---------------------------------------------------------------------------
|
|
@@ -174,7 +478,19 @@
|
|
|
174
478
|
return tasks
|
|
175
479
|
.filter((t) => t.source === 'cascade' || /^artist review\b/i.test(t.title || ''))
|
|
176
480
|
.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0))
|
|
177
|
-
|
|
481
|
+
// goalId/versionId are the REVIEWED work's — cascade.js property 3: a
|
|
482
|
+
// generated review inherits its source task's goal and version. They ride
|
|
483
|
+
// here so the "needs work" follow-up needs no second read, and
|
|
484
|
+
// `description` rides here so "note it" appends rather than overwrites.
|
|
485
|
+
.map((t) => ({
|
|
486
|
+
type: 'review',
|
|
487
|
+
taskId: t.id,
|
|
488
|
+
title: t.title,
|
|
489
|
+
description: t.description,
|
|
490
|
+
goalId: t.goal_id,
|
|
491
|
+
versionId: t.version_id,
|
|
492
|
+
shippedId: reviewShippedId(t),
|
|
493
|
+
}));
|
|
178
494
|
}
|
|
179
495
|
|
|
180
496
|
// The open flags. Best-effort: no copy-desk module → 404 → no flags, and the
|
|
@@ -198,7 +514,15 @@
|
|
|
198
514
|
|
|
199
515
|
document.addEventListener('click', (ev) => {
|
|
200
516
|
if (ev.target.closest('[data-skip]')) { idx += 1; paint(); window.scrollTo({ top: 0 }); return; }
|
|
201
|
-
if (ev.target.closest('[data-restart]')) { idx = 0; paint(); window.scrollTo({ top: 0 }); }
|
|
517
|
+
if (ev.target.closest('[data-restart]')) { idx = 0; paint(); window.scrollTo({ top: 0 }); return; }
|
|
518
|
+
const weight = ev.target.closest('[data-verdict]');
|
|
519
|
+
if (weight) runVerdict(weight.dataset.verdict);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Clear the inline error as soon as the artist edits the field — the design
|
|
523
|
+
// system's rule: an error stands until the input that caused it changes.
|
|
524
|
+
document.addEventListener('input', (ev) => {
|
|
525
|
+
if (ev.target && ev.target.id === 'studio-words') verdictError('');
|
|
202
526
|
});
|
|
203
527
|
|
|
204
528
|
(async () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_": "The Studio (artist mode), R06 of goal 1000074 (task 1003117). A calm room that surfaces the artist's real work — backlog review requests (cascade, R04) and open copy flags (R02) — one piece at a time, never a table. Against the kit's own stub the page renders its EMPTY state (no /tasks or /copy-desk feed), which is a state the floors must hold in
|
|
2
|
+
"_": "The Studio (artist mode), R06 of goal 1000074 (task 1003117). A calm room that surfaces the artist's real work — backlog review requests (cascade, R04) and open copy flags (R02) — one piece at a time, never a table. Against the kit's own stub the page renders its EMPTY state (no /tasks or /copy-desk feed), which is a state the floors must hold in. TO SEE IT POPULATED, point the kit at the hall-preview harness instead: `node scripts/hall-preview/server.js --port 4629 --fixture-me` then `render.js --base-in http://builders.localhost:4629 --base-out http://builders.localhost:4629`. That harness answers /tasks from fixtures/tasks.json (whose first row IS a cascade artist review), the reviewed task from fixtures/tasks__detail.json (which carries a visual_url), and the visual itself as a generated stand-in — task 1003829, where the review card's picture became the subject of the card and a broken frame stopped being reviewable. An earlier version of this note named a studio__reviews.json fixture; there is no such file and there never was.",
|
|
3
3
|
"page": "/builders/studio",
|
|
4
4
|
"surface": "hall-ui",
|
|
5
5
|
"stub": {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// modules/provisioning/credential-preflight.js — does the caller hold a credential
|
|
2
|
+
// that can actually stand up the repo they are adopting? (task 1002909, 1002898 N3)
|
|
3
|
+
//
|
|
4
|
+
// THE GAP THIS CLOSES. `POST /provisioning/instances` validated that an `adopt`
|
|
5
|
+
// carried a target_ref, but never that the owner could READ it — so an API caller
|
|
6
|
+
// could queue a standup that was doomed at request time and only learned about it
|
|
7
|
+
// ~2 minutes later, as raw git stderr on the runner (the task-1002697 acceptance
|
|
8
|
+
// walk did exactly that). The web wizard was only incidentally protected: it 403s
|
|
9
|
+
// earlier on its own repo picker. The API had nothing.
|
|
10
|
+
//
|
|
11
|
+
// WHY IT WAITED FOR ADR 0176 (task 1002735). A preflight is only as good as its
|
|
12
|
+
// definition of "usable credential", and 1002898 carved this out rather than guess
|
|
13
|
+
// at one: written against the wrong definition it would either refuse valid creates
|
|
14
|
+
// or keep admitting doomed ones. 1002735 settled it, so this file encodes THAT
|
|
15
|
+
// definition and nothing else — what `ensurePrivateRepoAccess` (scripts/gds/
|
|
16
|
+
// provision-repo.js) will demand at standup time, asked one round-trip earlier:
|
|
17
|
+
//
|
|
18
|
+
// public target_ref → any readable credential is enough. The recurring deploy
|
|
19
|
+
// pull rides the shared SSH alias, which reads public repos
|
|
20
|
+
// without being registered on them.
|
|
21
|
+
// private target_ref → the runner must register a READ-ONLY deploy key on the
|
|
22
|
+
// repo, and GitHub requires repository ADMIN for that. A
|
|
23
|
+
// credential that can merely read a private repo is NOT
|
|
24
|
+
// enough, and discovering that mid-standup is the loud
|
|
25
|
+
// version of this same refusal.
|
|
26
|
+
//
|
|
27
|
+
// GREENFIELD IS EXEMPT, and that exemption is load-bearing (the constraint carried
|
|
28
|
+
// over from 1002898): the platform CREATES that repo itself, so there is no
|
|
29
|
+
// pre-existing repo for a credential to cover and nothing here to check.
|
|
30
|
+
//
|
|
31
|
+
// A TRANSIENT GITHUB FAILURE ADMITS. This refuses only on POSITIVE evidence that a
|
|
32
|
+
// standup cannot succeed. GitHub being unreachable is not that evidence — and the
|
|
33
|
+
// runner takes the same posture (`skipped: 'github_unreachable'` continues rather
|
|
34
|
+
// than throwing), so a GitHub hiccup must not become an outage of project creation.
|
|
35
|
+
//
|
|
36
|
+
// WHY IT READS THE TOKEN ITSELF rather than calling core's db.getGithubToken /
|
|
37
|
+
// github-repos.getUserRepo. `src/module-api.js` is a KERNEL file (ADR 0091 §1) and
|
|
38
|
+
// may depend only on kernel files; `src/bongos/db.js` and `src/bongos/github-repos.js`
|
|
39
|
+
// are domain, so routing either through the doorway fails the boundary check in
|
|
40
|
+
// scripts/gds/fitness.js. Modules own their data access through the exposed `pool`
|
|
41
|
+
// (the module contract's own rule), so the two small reads below are that ownership,
|
|
42
|
+
// not a shortcut around it. Both are DELIBERATE TWINS of core's versions and say so;
|
|
43
|
+
// `grantedScopeCoversRepos` is reached through the doorway instead of re-implemented
|
|
44
|
+
// because it is kernel (src/bongos/auth-config.js) AND because its subtlety —
|
|
45
|
+
// `public_repo` CONTAINS "repo", so a substring match accepts exactly the stale
|
|
46
|
+
// pre-ADR-0155 token this guards against — is the kind that must never be re-typed.
|
|
47
|
+
//
|
|
48
|
+
// Its own file, like ../paid-shape-gate.js, for the same two mechanical reasons:
|
|
49
|
+
// `routes/provisioning.js` sits at its 1500-line fitness ratchet, and every
|
|
50
|
+
// dependency here is INJECTED so the check is exercisable without standing up the
|
|
51
|
+
// auth stack or touching the network.
|
|
52
|
+
'use strict';
|
|
53
|
+
|
|
54
|
+
// Mirrors scripts/gds/provision-repo.js parseTargetRef EXACTLY. The preflight and
|
|
55
|
+
// the runner must agree on what a target_ref means, or this admits refs the runner
|
|
56
|
+
// cannot parse (and refuses ones it can) — a divergence that would be silent.
|
|
57
|
+
function parseTargetRef(ref) {
|
|
58
|
+
const m = String(ref || '').trim()
|
|
59
|
+
.replace(/^git@[^:]+:/, '')
|
|
60
|
+
.replace(/^https?:\/\/[^/]+\//, '')
|
|
61
|
+
.match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9][A-Za-z0-9_.-]*?)(?:\.git)?$/);
|
|
62
|
+
return m ? { owner: m[1], name: m[2] } : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Twin of src/bongos/db.js getGithubToken, kept in step with it deliberately (see
|
|
66
|
+
// the header). Returns { token, scope } or null when there is none, it expired, or
|
|
67
|
+
// the blob will not decrypt under the current master key. Unlike core's version it
|
|
68
|
+
// does NOT lazily delete an expired row — a read-only preflight must not mutate, and
|
|
69
|
+
// core's own reaper still runs on the endpoints that own that table.
|
|
70
|
+
async function readOwnerRepoToken({ builderId }, { pool, secretBox }) {
|
|
71
|
+
if (!secretBox.isConfigured()) return null;
|
|
72
|
+
const { rows } = await pool.query(
|
|
73
|
+
`SELECT token_enc, scope, expires_at FROM github_oauth_tokens WHERE builder_id = $1`,
|
|
74
|
+
[builderId],
|
|
75
|
+
);
|
|
76
|
+
const row = rows[0];
|
|
77
|
+
if (!row) return null;
|
|
78
|
+
if (new Date(row.expires_at).getTime() <= Date.now()) return null;
|
|
79
|
+
try { return { token: secretBox.decrypt(row.token_enc), scope: row.scope }; }
|
|
80
|
+
catch { return null; } // a blob that won't decrypt reads as "none", as in core
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Twin of src/bongos/github-repos.js getUserRepo, narrowed to the two fields this
|
|
84
|
+
// decision needs. Throws { code } on the cases the caller branches on; every other
|
|
85
|
+
// status is a transient the caller admits on.
|
|
86
|
+
async function fetchRepoVisibility(token, { owner, name, fetchImpl, userAgent }) {
|
|
87
|
+
const res = await (fetchImpl || fetch)(
|
|
88
|
+
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`,
|
|
89
|
+
{ headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent, Accept: 'application/vnd.github+json' } },
|
|
90
|
+
);
|
|
91
|
+
if (res.status === 401) throw Object.assign(new Error('github rejected the token'), { code: 'github_unauthorized' });
|
|
92
|
+
if (res.status === 404) throw Object.assign(new Error('repo not found'), { code: 'repo_not_found' });
|
|
93
|
+
if (!res.ok) throw Object.assign(new Error(`github ${res.status}`), { code: 'github_error' });
|
|
94
|
+
const r = await res.json();
|
|
95
|
+
return { private: !!r.private, admin: !!(r.permissions && r.permissions.admin) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// adoptCredentialFault — null to ADMIT the create, or the refusal the route should
|
|
99
|
+
// send: { code, status, message }. Returning a value rather than writing to `res`
|
|
100
|
+
// keeps this directly testable and leaves the HTTP shape with the route that owns it.
|
|
101
|
+
//
|
|
102
|
+
// deps: { pool, secretBox, grantedScopeCoversRepos, userAgent, fetchImpl?,
|
|
103
|
+
// readToken?, fetchRepo? } — the last two are test seams.
|
|
104
|
+
async function adoptCredentialFault({ builderId, onboardMode, targetRef }, deps) {
|
|
105
|
+
if (onboardMode !== 'adopt') return null; // greenfield: nothing to check
|
|
106
|
+
const { grantedScopeCoversRepos, secretBox, fetchImpl, userAgent } = deps;
|
|
107
|
+
const readToken = deps.readToken || readOwnerRepoToken;
|
|
108
|
+
const fetchRepo = deps.fetchRepo || fetchRepoVisibility;
|
|
109
|
+
|
|
110
|
+
// An unparseable ref cannot be checked, and the runner's identical parse would
|
|
111
|
+
// fail on it too — so this is a doomed standup, refused for the reason it is
|
|
112
|
+
// doomed rather than admitted because the credential question never got asked.
|
|
113
|
+
const repo = parseTargetRef(targetRef);
|
|
114
|
+
if (!repo) {
|
|
115
|
+
return { code: 'bad_target_ref', status: 400,
|
|
116
|
+
message: 'Name the repository to adopt as owner/repo.' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// No secret-box means no token can be STORED at all, so every adopt would fail at
|
|
120
|
+
// the push. 503, not 403: the fault is the server's configuration, not the caller's.
|
|
121
|
+
if (!secretBox.isConfigured()) {
|
|
122
|
+
return { code: 'github_credential_unavailable', status: 503,
|
|
123
|
+
message: 'Repo access is not configured on this server, so an existing repository cannot be adopted yet.' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const row = await readToken({ builderId }, deps);
|
|
127
|
+
if (!row || !row.token) {
|
|
128
|
+
return { code: 'repo_scope_required', status: 403,
|
|
129
|
+
message: 'Connect your GitHub account before adopting an existing repository.' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let info;
|
|
133
|
+
try {
|
|
134
|
+
info = await fetchRepo(row.token, { owner: repo.owner, name: repo.name, fetchImpl, userAgent });
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const code = e && e.code;
|
|
137
|
+
if (code === 'github_unauthorized') {
|
|
138
|
+
return { code: 'repo_scope_required', status: 403,
|
|
139
|
+
message: 'GitHub rejected your stored credential — reconnect your GitHub account and try again.' };
|
|
140
|
+
}
|
|
141
|
+
if (code === 'repo_not_found') {
|
|
142
|
+
// A `public_repo`-scoped token cannot SEE a private repo: GitHub answers 404
|
|
143
|
+
// for "absent" and for "private and invisible to you" identically. So the
|
|
144
|
+
// message covers both honestly rather than asserting the repo does not exist —
|
|
145
|
+
// a pre-ADR-0155 token reaching its own private repo lands exactly here, which
|
|
146
|
+
// is the hermeslines-marketing failure told at request time instead of at the pull.
|
|
147
|
+
if (!grantedScopeCoversRepos(row.scope)) {
|
|
148
|
+
return { code: 'repo_scope_required', status: 403,
|
|
149
|
+
message: `We cannot see ${repo.owner}/${repo.name}. If it is private, reconnect GitHub to include your private repositories; if it is public, check the name.` };
|
|
150
|
+
}
|
|
151
|
+
return { code: 'target_repo_not_found', status: 404,
|
|
152
|
+
message: `GitHub has no repository ${repo.owner}/${repo.name} that your account can reach — check the name, or that you still have access.` };
|
|
153
|
+
}
|
|
154
|
+
return null; // unreachable / 5xx: no positive evidence of doom, so admit
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Private repos need repository ADMIN, because the runner registers a read-only
|
|
158
|
+
// deploy key to keep the deploy pull working after the one-shot OAuth token dies
|
|
159
|
+
// (ADR 0176). Read-only collaborator access passes the read above and then fails at
|
|
160
|
+
// key registration — the exact "looked like it worked" shape this exists to end.
|
|
161
|
+
// The sentence matches the runner's own so the two can never disagree.
|
|
162
|
+
if (info.private && !info.admin) {
|
|
163
|
+
return { code: 'repo_admin_required', status: 403,
|
|
164
|
+
message: `Your GitHub account can read ${repo.owner}/${repo.name}, but adopting a private repository needs repository ADMIN so its deploy key can be registered. Ask a repository admin to raise your access, or make the repository public.` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = { adoptCredentialFault, parseTargetRef, readOwnerRepoToken, fetchRepoVisibility };
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
// POST /provisioning/instances — request a new instance
|
|
15
15
|
// (a `dedicated` shape ALSO needs
|
|
16
16
|
// provisioning.fleet.manage — it is the
|
|
17
|
-
// only shape that bills; ../paid-shape-gate.js
|
|
17
|
+
// only shape that bills; ../paid-shape-gate.js.
|
|
18
|
+
// An `adopt` ALSO passes the credential
|
|
19
|
+
// preflight — ../credential-preflight.js,
|
|
20
|
+
// task 1002909; greenfield is exempt)
|
|
18
21
|
// GET /provisioning/instances — list the caller's instances
|
|
19
22
|
// GET /provisioning/instances/:id — read one of the caller's instances
|
|
20
23
|
// (also SELF-HEALS its catalog projection,
|
|
@@ -49,6 +52,7 @@ const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModu
|
|
|
49
52
|
const { recommendationsForDetails } = require('../recommendations');
|
|
50
53
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
51
54
|
const { requireAuthorityForMeteredShape } = require('../paid-shape-gate');
|
|
55
|
+
const { adoptCredentialFault } = require('../credential-preflight');
|
|
52
56
|
const catalogBridge = require('../catalog-bridge');
|
|
53
57
|
const { callbackPage } = require('./callback-page');
|
|
54
58
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
@@ -409,17 +413,18 @@ module.exports = function provisioningRoutes() {
|
|
|
409
413
|
reason: 'adopt layers onto an existing repo, so target_ref (owner/repo) is required',
|
|
410
414
|
});
|
|
411
415
|
}
|
|
412
|
-
// KNOWN GAP — the credential preflight belongs HERE, and is deliberately absent.
|
|
413
|
-
// This route validates that target_ref is PRESENT but never that the owner holds a
|
|
414
|
-
// usable repo credential for it, so an API caller can queue a standup that dies
|
|
415
|
-
// ~2 minutes later in raw git stderr (the task-1002697 walk did exactly that; the
|
|
416
|
-
// web wizard is only incidentally protected because it 403s earlier). Carved out of
|
|
417
|
-
// task 1002898 as N3 and tracked by task 1002909, which is BLOCKED on task 1002735
|
|
418
|
-
// deciding what credential a private-repo standup actually needs: written against
|
|
419
|
-
// the wrong definition, a preflight would refuse valid creates or keep admitting
|
|
420
|
-
// doomed ones. Greenfield must stay exempt when it lands — the platform creates
|
|
421
|
-
// that repo itself, so there is no pre-existing repo to hold a credential for.
|
|
422
416
|
try {
|
|
417
|
+
// The credential preflight (task 1002909, 1002898 N3). An `adopt` names a repo
|
|
418
|
+
// the platform must read forever, so ask ONE round-trip early whether this
|
|
419
|
+
// caller's credential can actually carry that standup — rather than queueing a
|
|
420
|
+
// doomed intent the runner discovers two minutes later as raw git stderr (the
|
|
421
|
+
// task-1002697 walk). Greenfield is exempt by construction. Encodes ADR 0176's
|
|
422
|
+
// definition of a usable credential; ../credential-preflight.js says why.
|
|
423
|
+
const credFault = await adoptCredentialFault(
|
|
424
|
+
{ builderId: req.builder.id, onboardMode, targetRef: body.target_ref },
|
|
425
|
+
{ pool, secretBox: api.secretBox, grantedScopeCoversRepos: api.grantedScopeCoversRepos, userAgent: api.userAgent() },
|
|
426
|
+
);
|
|
427
|
+
if (credFault) return res.fail(credFault.code, { status: credFault.status, message: credFault.message });
|
|
423
428
|
// A re-request must not re-litigate the address: createInstance never updates
|
|
424
429
|
// an existing row's domain, so for one the guards below could only misfire —
|
|
425
430
|
// a "Try again" on a pre-existing reserved-label row, or domain_taken fired
|