@bongos/core 1.19.1062 → 1.19.1064
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 +123 -48
- package/.claude/skills/grade-recover/SKILL.md +1 -0
- package/.claude/skills/tweak/SKILL.md +70 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +301 -3
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +44 -7
- package/docs/copy-registry.json +393 -26
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +36 -4
- package/docs/page-readings.json +34 -1
- package/modules/copy-desk/page-status.js +63 -0
- package/modules/copy-desk/pages.js +1 -0
- package/modules/copy-desk/routes/copy-desk.js +30 -0
- package/modules/hall-ui/public/tweak-editor-lib.js +137 -0
- package/modules/hall-ui/public/tweak-editor.css +437 -0
- package/modules/hall-ui/public/tweak-editor.html +111 -0
- package/modules/hall-ui/public/tweak-editor.js +447 -0
- package/modules/hall-ui/public/tweak-editor.states.json +99 -0
- package/modules/hall-ui/records/tweak-editor.md +32 -0
- package/modules/lifecycle/db-grade.js +16 -0
- package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
- package/modules/lifecycle/page-tweak-hold.js +74 -0
- package/modules/lifecycle/page-tweak-reads.js +33 -1
- package/modules/lifecycle/routes/tasks.js +4 -2
- package/modules/lifecycle/routes/visuals.js +82 -0
- package/modules/lifecycle/task-visual-db.js +40 -1
- package/modules/lifecycle/task-visuals.js +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/copy-apply.js +277 -1
- package/scripts/gds/page-reader.js +2 -0
- package/scripts/gds/ship-finish.js +27 -2
- package/scripts/gds/ship-flow.js +17 -1
- package/scripts/gds/ship-land.js +30 -7
- package/scripts/gds/ship-merge.js +43 -15
- package/scripts/gds/strand-watch.js +41 -1
- package/scripts/gds/task.js +23 -5
- package/scripts/gds/tweak-renders.js +200 -0
- package/scripts/hall-preview/server.js +21 -0
- package/src/bongos/serve-internal.js +5 -1
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_editor.mjs +181 -0
- package/tests/hall_audit.mjs +5 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_tweak_editor.mjs +432 -0
- package/tests/page_tweak_hold.mjs +265 -0
- package/tests/publish_status_branch.mjs +23 -0
- package/tests/task_visual_slots.mjs +161 -0
- package/tests/tweak_batch_apply.mjs +265 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- lifecycle_013_task_visual_slots.sql — a task can carry NAMED visuals, not only
|
|
2
|
+
-- the one (task 1004322 / BV2.TW11, goal 1000095; ADR 0341 D7 and its builder
|
|
3
|
+
-- pick 9).
|
|
4
|
+
--
|
|
5
|
+
-- WHY. core_223 gave a task ONE optional image (tasks.visual_url + visual_alt),
|
|
6
|
+
-- the picture beside its value summary. A page tweak needs eight: the applied
|
|
7
|
+
-- page before and after, on desktop and on a phone, in light and in dark. The
|
|
8
|
+
-- Approval queue (TW12) shows the artist exactly those renders, images of the
|
|
9
|
+
-- applied branch, never a page built from the batch, which is what keeps ADR
|
|
10
|
+
-- 0233's "nothing renders a task description as product copy" true. One column
|
|
11
|
+
-- cannot hold eight pictures, and eight columns would be a schema for one
|
|
12
|
+
-- feature, so a visual gets a NAME and a row.
|
|
13
|
+
--
|
|
14
|
+
-- THE SINGLE VISUAL IS UNTOUCHED. tasks.visual_url stays the ship-time picture
|
|
15
|
+
-- every existing reader renders (the hall, the status feed); a slot is a
|
|
16
|
+
-- separate, named picture a reader asks for by name. Nothing here moves,
|
|
17
|
+
-- copies or backfills the existing column.
|
|
18
|
+
--
|
|
19
|
+
-- A MODULE TABLE, because ADR 0083 lets a module migration create only its own
|
|
20
|
+
-- lifecycle_-namespaced tables; it references the core tasks table the way
|
|
21
|
+
-- lifecycle_010 does, and never alters it.
|
|
22
|
+
--
|
|
23
|
+
-- THE SAME AUDIENCE AS THE ONE VISUAL. A slot's file is stored and served by the
|
|
24
|
+
-- existing task-visuals store (task-<id>-<hex>.<ext>, GET /task-visuals/:name),
|
|
25
|
+
-- whose serve route derives who may see an image from the TASK it names. So a
|
|
26
|
+
-- slot adds no privacy axis, exactly as core_223 promised of the column.
|
|
27
|
+
--
|
|
28
|
+
-- PORTABLE and replay-safe: IF NOT EXISTS throughout, no instance data.
|
|
29
|
+
|
|
30
|
+
BEGIN;
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS lifecycle_task_visual_slots (
|
|
33
|
+
task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
34
|
+
|
|
35
|
+
-- The slot's name: lowercase words joined by dashes, such as
|
|
36
|
+
-- 'after-phone-dark'. The vocabulary a page tweak uses is declared once in
|
|
37
|
+
-- modules/lifecycle/task-visuals.js (TWEAK_RENDER_SLOTS); the column checks only
|
|
38
|
+
-- the SHAPE, so a later feature can name its own slots without a migration.
|
|
39
|
+
slot text NOT NULL CHECK (slot ~ '^[a-z0-9]+(-[a-z0-9]+){0,5}$' AND length(slot) <= 48),
|
|
40
|
+
|
|
41
|
+
-- The served URL (/api/bongos/task-visuals/<file>), minted by the store.
|
|
42
|
+
visual_url text NOT NULL,
|
|
43
|
+
visual_alt text,
|
|
44
|
+
|
|
45
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
|
|
47
|
+
-- One picture per (task, slot): attaching a slot again REPLACES it, and the
|
|
48
|
+
-- route unlinks the superseded file.
|
|
49
|
+
PRIMARY KEY (task_id, slot)
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
COMMIT;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// modules/lifecycle/page-tweak-hold.js — the ONE definition of "a page tweak
|
|
2
|
+
// waiting for its artist" (task 1004322 / BV2.TW11, ADR 0341 D7).
|
|
3
|
+
//
|
|
4
|
+
// THE HOLD. A page tweak transcribes words an artist wrote, so its artist must
|
|
5
|
+
// see the applied page before it ships (spec decision 4). When a page tweak's
|
|
6
|
+
// grade PASSES, applyGrade records the grade but does not promote `completed`
|
|
7
|
+
// to `confirmed`: the task waits at `completed` until the artist approves it
|
|
8
|
+
// (TW12's approve route does the promotion the grade withheld) or sends it back.
|
|
9
|
+
//
|
|
10
|
+
// WHY ONE FILE. Five readers must agree on this one fact, and a second copy of
|
|
11
|
+
// it is how they would drift: applyGrade (do not promote), the publish-branch
|
|
12
|
+
// route (publish, but never arm auto-merge), the publish-status route (never
|
|
13
|
+
// nudge the merge), ship.js (publish the branch and say the page is waiting)
|
|
14
|
+
// and strand-watch / grade-recover (a held pass is not a failed grade). Every
|
|
15
|
+
// one of them asks the questions below.
|
|
16
|
+
//
|
|
17
|
+
// PURE: no require, no fs, no DB. The scripts under scripts/gds/ require it
|
|
18
|
+
// directly, so it must load without the module doorway.
|
|
19
|
+
//
|
|
20
|
+
// Every other task is untouched by construction: each predicate is false unless
|
|
21
|
+
// the task's source is exactly 'page-tweak'.
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
// The task source the page claim writes (modules/copy-desk/pages.js SOURCE).
|
|
26
|
+
// Restated, not required: copy-desk is another module, and this file requires
|
|
27
|
+
// nothing so that the CLI can load it.
|
|
28
|
+
const PAGE_TWEAK_SOURCE = 'page-tweak';
|
|
29
|
+
|
|
30
|
+
// The derived round state this hold produces (ADR 0341 D7's table).
|
|
31
|
+
const HOLD_STATE = 'waiting_for_artist';
|
|
32
|
+
|
|
33
|
+
function isPageTweak(task) {
|
|
34
|
+
return !!task && task.source === PAGE_TWEAK_SOURCE;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Is this task held for its artist? `gradePassed` is the task's grade row's
|
|
38
|
+
// `passed` (task_grades keeps one row per task, the latest). Only a strict
|
|
39
|
+
// `true` holds: a missing grade, or a failed one, is the ordinary `completed`
|
|
40
|
+
// that /grade-recover handles.
|
|
41
|
+
function isHeldForArtist(task, gradePassed) {
|
|
42
|
+
return isPageTweak(task) && task.status === 'completed' && gradePassed === true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// What ship.js prints when it parks a held pass. One place, so the ship, the
|
|
46
|
+
// re-grade and the resume all say the same thing.
|
|
47
|
+
function heldLines(taskId, { prUrl = null } = {}) {
|
|
48
|
+
return [
|
|
49
|
+
'',
|
|
50
|
+
`Task #${taskId} passed its grade and is WAITING FOR THE ARTIST (ADR 0341 D7).`,
|
|
51
|
+
' This is a page tweak: the artist approves the applied page before it ships.',
|
|
52
|
+
prUrl
|
|
53
|
+
? ` The branch and its PR are published (${prUrl}); auto-merge is NOT armed.`
|
|
54
|
+
: ' The branch is published; auto-merge is NOT armed.',
|
|
55
|
+
' The task stays at completed. Approve promotes it to land; send-back returns it to the /tweak queue.',
|
|
56
|
+
' Nothing more to do here.',
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The auto-merge result the publish route reports for a held pass, in the shape
|
|
61
|
+
// enableAutoMergeDetailed returns, so the route's response carries its reason
|
|
62
|
+
// on the field it already has (auto_merge_reason).
|
|
63
|
+
const HELD_NOT_ARMED = Object.freeze({
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: 'held for the artist (ADR 0341 D7): a page tweak lands only after its artist approves it',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
HELD_NOT_ARMED,
|
|
70
|
+
HOLD_STATE,
|
|
71
|
+
isPageTweak,
|
|
72
|
+
isHeldForArtist,
|
|
73
|
+
heldLines,
|
|
74
|
+
};
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
'use strict';
|
|
13
13
|
|
|
14
14
|
const api = require('../../src/module-api');
|
|
15
|
+
const hold = require('./page-tweak-hold');
|
|
15
16
|
|
|
16
17
|
// Every page-tweak task, oldest first, with what the round state (D7) and the
|
|
17
18
|
// changelog (D8) need beside the task itself:
|
|
@@ -93,4 +94,35 @@ async function shippedTasksTouchingSince({ files, since, limit = 50 } = {}) {
|
|
|
93
94
|
return rows;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
// Is this task a page tweak held for its artist (ADR 0341 D7; task 1004322)?
|
|
98
|
+
// The publish routes ask it before they arm or nudge a merge: a held pass is
|
|
99
|
+
// published so the Approval queue can show the applied branch, but it must not
|
|
100
|
+
// land until the artist approves. One row: the task's source and status, and its
|
|
101
|
+
// grade (task_grades keeps one row per task). The verdict is the shared pure
|
|
102
|
+
// predicate, so this read and applyGrade cannot disagree about what "held" means.
|
|
103
|
+
async function readTweakHold(taskId) {
|
|
104
|
+
const { rows } = await api.pool.query(
|
|
105
|
+
`SELECT t.source, t.status, g.passed AS grade_passed
|
|
106
|
+
FROM tasks t
|
|
107
|
+
LEFT JOIN task_grades g ON g.task_id = t.id
|
|
108
|
+
WHERE t.id = $1`,
|
|
109
|
+
[taskId]
|
|
110
|
+
);
|
|
111
|
+
const row = rows[0] || null;
|
|
112
|
+
return { held: !!row && hold.isHeldForArtist(row, row.grade_passed), page_tweak: hold.isPageTweak(row) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The publish routes' form of the question, FAIL-CLOSED: a read that throws
|
|
116
|
+
// answers "held", so the route neither arms auto-merge nor nudges the merge.
|
|
117
|
+
// That is the safe direction for every task: an ordinary confirmed task whose
|
|
118
|
+
// PR was left un-armed still lands, because the publish reconciler's sweep
|
|
119
|
+
// merges green PRs of confirmed tasks on its own (task 1162).
|
|
120
|
+
async function heldForArtistFailClosed(taskId, read = readTweakHold) {
|
|
121
|
+
try {
|
|
122
|
+
return (await read(taskId)).held;
|
|
123
|
+
} catch (_) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { listPageTweakTasks, shippedTasksTouchingSince, heldForArtistFailClosed };
|
|
@@ -35,6 +35,7 @@ const { depRefusalRow } = require('../dead-deps.js');
|
|
|
35
35
|
const { gradeQualityWhere } = require('../db-shared');
|
|
36
36
|
const auth = api;
|
|
37
37
|
const db = require('../db');
|
|
38
|
+
const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED } = require('../page-tweak-hold'); // ADR 0341 D7 (tests stub the read)
|
|
38
39
|
const hierarchy = require('../hierarchy');
|
|
39
40
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
40
41
|
// The orphan-task advisory (task 1002539) — pure, fail-open; see goal-advisory.js.
|
|
@@ -1184,7 +1185,8 @@ module.exports = function buildTasksRouter() {
|
|
|
1184
1185
|
// task 1001431: ask WHY when arming fails. `auto_merge: false` on its own told the
|
|
1185
1186
|
// builder nothing, and the commonest cause is a repo with allow_auto_merge OFF —
|
|
1186
1187
|
// a permanent condition that reads exactly like a transient blip.
|
|
1187
|
-
|
|
1188
|
+
// task 1004322 (ADR 0341 D7): a page tweak held for its artist is PUBLISHED, never armed.
|
|
1189
|
+
const autoMergeResult = (await pageTweakReads.heldForArtistFailClosed(id)) ? HELD_NOT_ARMED : await githubPush.enableAutoMergeDetailed({ nodeId: pr.node_id });
|
|
1188
1190
|
const autoMerge = autoMergeResult.ok;
|
|
1189
1191
|
// task 1139 (ADR 0058): attest the author's trust tier on the pushed sha so the
|
|
1190
1192
|
// gate-review check can auto-approve a Metic+ author's gate-surface PR with no
|
|
@@ -1326,7 +1328,7 @@ module.exports = function buildTasksRouter() {
|
|
|
1326
1328
|
// mirrors the reconcile-on-read precedent above (a GET that finishes the
|
|
1327
1329
|
// land); best-effort so a merge-driver miss never turns the poll into a 502.
|
|
1328
1330
|
let merge_driver = null;
|
|
1329
|
-
if (!reconciled && !status.merged && status.pr_state === 'open') {
|
|
1331
|
+
if (!reconciled && !status.merged && status.pr_state === 'open' && !(await pageTweakReads.heldForArtistFailClosed(id))) { // a held page tweak is never nudged (ADR 0341 D7)
|
|
1330
1332
|
try {
|
|
1331
1333
|
merge_driver = await githubPush.mergeIfGreen({ branch });
|
|
1332
1334
|
} catch (e) {
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
// DELETE /tasks/:id/visual take it back off (claim holder | Archon)
|
|
15
15
|
// GET /task-visuals/:name serve one (see the audience note)
|
|
16
16
|
//
|
|
17
|
+
// NAMED SLOTS (task 1004322 / BV2.TW11, lifecycle_013) — the same store, the same
|
|
18
|
+
// gate, the same audience, one picture per name:
|
|
19
|
+
// POST /tasks/:id/visuals/:slot attach or replace a named one (claim holder | Archon)
|
|
20
|
+
// DELETE /tasks/:id/visuals/:slot take it back off (claim holder | Archon)
|
|
21
|
+
// GET /tasks/:id/visuals list them by name (any builder)
|
|
22
|
+
//
|
|
17
23
|
// The twin of routes/bug-attachments.js, for the image a builder attaches to
|
|
18
24
|
// their own shipped task instead of one the bot pulled out of #bugs. Storage +
|
|
19
25
|
// screening live in ../task-visuals.js; all three routes live here rather than
|
|
@@ -191,6 +197,82 @@ module.exports = function buildTaskVisualsRouter() {
|
|
|
191
197
|
}
|
|
192
198
|
});
|
|
193
199
|
|
|
200
|
+
// ==========================================================================
|
|
201
|
+
// NAMED SLOTS (task 1004322 / BV2.TW11; ADR 0341 D7, builder pick 9)
|
|
202
|
+
// --------------------------------------------------------------------------
|
|
203
|
+
// A page tweak carries eight renders of the applied page (before and after,
|
|
204
|
+
// desktop and phone, light and dark), and the Approval queue shows them by
|
|
205
|
+
// name. Each slot is stored and screened exactly as the one visual is, and
|
|
206
|
+
// served by the same GET /task-visuals/:name, whose audience comes from the
|
|
207
|
+
// task. The slot name is validated before any byte is read.
|
|
208
|
+
|
|
209
|
+
// POST /tasks/:id/visuals/:slot — attach (or replace) one named visual.
|
|
210
|
+
router.post('/tasks/:id/visuals/:slot', auth.requireBuilder, visualUpload, async (req, res) => {
|
|
211
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
212
|
+
if (id === null) return;
|
|
213
|
+
const slot = req.params.slot;
|
|
214
|
+
if (!visuals.isSafeSlot(slot)) return res.fail('bad_slot', 400, { max_length: visuals.MAX_SLOT_LENGTH, example: visuals.TWEAK_RENDER_SLOTS[0] });
|
|
215
|
+
if (validateOrRespond(req, res, {
|
|
216
|
+
image_b64: { required: true, type: 'string', maxLength: VISUAL_B64_MAX, minLength: 1 },
|
|
217
|
+
content_type: { required: true, type: 'string', maxLength: 100, minLength: 1 },
|
|
218
|
+
alt: { type: 'string', maxLength: visuals.MAX_ALT_LENGTH },
|
|
219
|
+
})) return;
|
|
220
|
+
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
221
|
+
const buf = Buffer.from(req.body.image_b64, 'base64');
|
|
222
|
+
const screened = visuals.screenImage(buf, req.body.content_type);
|
|
223
|
+
if (!screened.ok) {
|
|
224
|
+
return res.fail(`visual_${screened.reason}`, screened.reason === 'too_big' ? 413 : 400, {
|
|
225
|
+
max_bytes: visuals.MAX_BYTES,
|
|
226
|
+
accepted: Object.keys(visuals.EXT_BY_TYPE),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const task = await db.getTaskVisualAccess(id);
|
|
230
|
+
if (!task) return res.fail('task_not_found', 404);
|
|
231
|
+
try {
|
|
232
|
+
const stored = await visuals.persistImageBuffer(buf, { taskId: id, contentType: req.body.content_type });
|
|
233
|
+
if (!stored.ok) return res.fail(`visual_${stored.reason}`, 400);
|
|
234
|
+
const row = await db.setTaskVisualSlot({ taskId: id, slot, visualUrl: stored.url, visualAlt: visuals.normalizeAlt(req.body.alt) });
|
|
235
|
+
if (row && row.replaced_url && row.replaced_url !== stored.url) await visuals.deleteStored(row.replaced_url);
|
|
236
|
+
return res.status(201).json({ ok: true, slot, visual_url: row.visual_url, visual_alt: row.visual_alt, bytes: stored.bytes });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
log.error({ taskId: id, slot, err: err && err.message }, 'task-visual slot upload failed');
|
|
239
|
+
return res.fail('visual_store_failed', { status: 500, message: 'internal error' });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// DELETE /tasks/:id/visuals/:slot — remove one named visual. Idempotent.
|
|
244
|
+
router.delete('/tasks/:id/visuals/:slot', auth.requireBuilder, async (req, res) => {
|
|
245
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
246
|
+
if (id === null) return;
|
|
247
|
+
const slot = req.params.slot;
|
|
248
|
+
if (!visuals.isSafeSlot(slot)) return res.fail('bad_slot', 400);
|
|
249
|
+
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
250
|
+
try {
|
|
251
|
+
const url = await db.deleteTaskVisualSlot({ taskId: id, slot });
|
|
252
|
+
if (url) await visuals.deleteStored(url);
|
|
253
|
+
return res.json({ ok: true, slot, visual_url: null });
|
|
254
|
+
} catch (err) {
|
|
255
|
+
log.error({ taskId: id, slot, err: err && err.message }, 'task-visual slot delete failed');
|
|
256
|
+
return res.fail('visual_delete_failed', { status: 500, message: 'internal error' });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// GET /tasks/:id/visuals — the task's named visuals. Any signed-in builder: it
|
|
261
|
+
// lists URLs only, and each image is still served under its own task's audience.
|
|
262
|
+
router.get('/tasks/:id/visuals', auth.requireBuilder, async (req, res) => {
|
|
263
|
+
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
264
|
+
if (id === null) return;
|
|
265
|
+
try {
|
|
266
|
+
const task = await db.getTaskVisualAccess(id);
|
|
267
|
+
if (!task) return res.fail('task_not_found', 404);
|
|
268
|
+
const rows = await db.listTaskVisualSlots(id);
|
|
269
|
+
return res.json({ ok: true, task_id: String(id), slots: rows.map((x) => ({ slot: x.slot, visual_url: x.visual_url, visual_alt: x.visual_alt, updated_at: x.updated_at })) });
|
|
270
|
+
} catch (err) {
|
|
271
|
+
log.error({ taskId: id, err: err && err.message }, 'task-visual slot list failed');
|
|
272
|
+
return res.fail('visual_list_failed', { status: 500, message: 'internal error' });
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
194
276
|
// DELETE /tasks/:id/visual — remove it again. The counterpart to "always
|
|
195
277
|
// optional": a builder who attached the wrong screenshot must be able to take
|
|
196
278
|
// it back, and an image is not prose (the write-once rule of task 1003102
|
|
@@ -43,4 +43,43 @@ async function getTaskVisualAccess(taskId) {
|
|
|
43
43
|
return rows[0] ?? null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// NAMED SLOTS (task 1004322, lifecycle_013). Status-blind, like the two above:
|
|
47
|
+
// the routes decide who may write and who may see.
|
|
48
|
+
//
|
|
49
|
+
// Upsert one slot. Returns the stored row and the URL it REPLACED (null when the
|
|
50
|
+
// slot was empty), read in the same statement so the route can unlink exactly the
|
|
51
|
+
// superseded file.
|
|
52
|
+
async function setTaskVisualSlot({ taskId, slot, visualUrl, visualAlt = null }) {
|
|
53
|
+
const { rows } = await pool.query(
|
|
54
|
+
`WITH prev AS (
|
|
55
|
+
SELECT visual_url FROM lifecycle_task_visual_slots WHERE task_id = $1 AND slot = $2
|
|
56
|
+
)
|
|
57
|
+
INSERT INTO lifecycle_task_visual_slots (task_id, slot, visual_url, visual_alt, updated_at)
|
|
58
|
+
VALUES ($1, $2, $3, $4, now())
|
|
59
|
+
ON CONFLICT (task_id, slot) DO UPDATE
|
|
60
|
+
SET visual_url = EXCLUDED.visual_url, visual_alt = EXCLUDED.visual_alt, updated_at = now()
|
|
61
|
+
RETURNING slot, visual_url, visual_alt, (SELECT visual_url FROM prev) AS replaced_url`,
|
|
62
|
+
[taskId, slot, visualUrl, visualAlt]
|
|
63
|
+
);
|
|
64
|
+
return rows[0] ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Remove one slot. Returns the URL it held, or null when it was already empty.
|
|
68
|
+
async function deleteTaskVisualSlot({ taskId, slot }) {
|
|
69
|
+
const { rows } = await pool.query(
|
|
70
|
+
`DELETE FROM lifecycle_task_visual_slots WHERE task_id = $1 AND slot = $2 RETURNING visual_url`,
|
|
71
|
+
[taskId, slot]
|
|
72
|
+
);
|
|
73
|
+
return rows[0] ? rows[0].visual_url : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Every slot a task carries, by name.
|
|
77
|
+
async function listTaskVisualSlots(taskId) {
|
|
78
|
+
const { rows } = await pool.query(
|
|
79
|
+
`SELECT slot, visual_url, visual_alt, updated_at FROM lifecycle_task_visual_slots WHERE task_id = $1 ORDER BY slot`,
|
|
80
|
+
[taskId]
|
|
81
|
+
);
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { setTaskVisual, getTaskVisualAccess, setTaskVisualSlot, deleteTaskVisualSlot, listTaskVisualSlots };
|
|
@@ -55,6 +55,24 @@ const TYPE_BY_EXT = {
|
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
const MAX_BYTES = 4 * 1024 * 1024; // 4 MB — a screenshot, not an asset library.
|
|
58
|
+
|
|
59
|
+
// NAMED SLOTS (task 1004322 / BV2.TW11, lifecycle_013). A task may carry named
|
|
60
|
+
// visuals beside its one ship-time picture. The shape is the column's own CHECK,
|
|
61
|
+
// restated so a bad name is refused before any byte is stored.
|
|
62
|
+
const SLOT_RE = /^[a-z0-9]+(-[a-z0-9]+){0,5}$/;
|
|
63
|
+
const MAX_SLOT_LENGTH = 48;
|
|
64
|
+
function isSafeSlot(slot) {
|
|
65
|
+
return typeof slot === 'string' && slot.length <= MAX_SLOT_LENGTH && SLOT_RE.test(slot);
|
|
66
|
+
}
|
|
67
|
+
// The eight renders /tweak attaches to a page tweak (ADR 0341 D7, builder pick 9):
|
|
68
|
+
// the applied page before and after, desktop (1440) and phone (390), light and
|
|
69
|
+
// dark. Declared ONCE: the renderer names its files with it, the attach step
|
|
70
|
+
// uploads by it, and the Approval queue (TW12) reads the same names.
|
|
71
|
+
const TWEAK_RENDER_PHASES = Object.freeze(['before', 'after']);
|
|
72
|
+
const TWEAK_RENDER_DEVICES = Object.freeze({ desktop: 1440, phone: 390 });
|
|
73
|
+
const TWEAK_RENDER_MODES = Object.freeze(['light', 'dark']);
|
|
74
|
+
const TWEAK_RENDER_SLOTS = Object.freeze(TWEAK_RENDER_PHASES.flatMap((phase) =>
|
|
75
|
+
Object.keys(TWEAK_RENDER_DEVICES).flatMap((device) => TWEAK_RENDER_MODES.map((mode) => `${phase}-${device}-${mode}`))));
|
|
58
76
|
const MAX_ALT_LENGTH = 300; // the caption/alt text stored beside it.
|
|
59
77
|
|
|
60
78
|
// Stored filenames are SERVER-generated, never user-supplied: a fixed shape we
|
|
@@ -249,6 +267,12 @@ async function deleteStored(nameOrUrl, { dir = visualsDir() } = {}) {
|
|
|
249
267
|
}
|
|
250
268
|
|
|
251
269
|
module.exports = {
|
|
270
|
+
isSafeSlot,
|
|
271
|
+
MAX_SLOT_LENGTH,
|
|
272
|
+
TWEAK_RENDER_PHASES,
|
|
273
|
+
TWEAK_RENDER_DEVICES,
|
|
274
|
+
TWEAK_RENDER_MODES,
|
|
275
|
+
TWEAK_RENDER_SLOTS,
|
|
252
276
|
EXT_BY_TYPE,
|
|
253
277
|
TYPE_BY_EXT,
|
|
254
278
|
MAX_BYTES,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1064",
|
|
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.1064",
|
|
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.1064",
|
|
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
|
@@ -7673,5 +7673,17 @@
|
|
|
7673
7673
|
"id": "1004320",
|
|
7674
7674
|
"text": "An artist can now download any page's words as a Word document, work on it offline, and upload it back. The upload goes into their draft, never straight to the site, and only the lines they actually changed in Word are taken,"
|
|
7675
7675
|
}
|
|
7676
|
+
],
|
|
7677
|
+
"1.19.1063": [
|
|
7678
|
+
{
|
|
7679
|
+
"id": "1004321",
|
|
7680
|
+
"text": "Artists now have their own editor for a page. It shows every word of the page as one sheet of paper over the studio at night. You type over any line and it saves as you go. A changed line gets a small orange mark and shows wha"
|
|
7681
|
+
}
|
|
7682
|
+
],
|
|
7683
|
+
"1.19.1064": [
|
|
7684
|
+
{
|
|
7685
|
+
"id": "1004322",
|
|
7686
|
+
"text": "A session can now apply an artist's rewritten page. The new /tweak skill takes the next submitted page, applies every line in one pass, and photographs the page before and after, on a desktop and a phone, in light and dark, at"
|
|
7687
|
+
}
|
|
7676
7688
|
]
|
|
7677
7689
|
}
|