@bongos/core 1.19.1063 → 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 +72 -37
- 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 +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +10 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +258 -3
- package/docs/api-reference.md +5 -2
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -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 +6 -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/src/module-api.js +1 -1
- 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
|
@@ -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
|
@@ -7679,5 +7679,11 @@
|
|
|
7679
7679
|
"id": "1004321",
|
|
7680
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
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
|
+
}
|
|
7682
7688
|
]
|
|
7683
7689
|
}
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
// arithmetic does not hold, or the file is outside a registry surface. None of
|
|
31
31
|
// them means the wording was wrong — they mean a person should look.
|
|
32
32
|
//
|
|
33
|
-
// Run: node scripts/gds/copy-apply.js
|
|
33
|
+
// Run: node scripts/gds/copy-apply.js --batch (a PAGE TWEAK round you hold: every line, task 1004322)
|
|
34
|
+
// node scripts/gds/copy-apply.js --batch --batch-file round.md --dry-run (a local round; writes nothing)
|
|
35
|
+
// node scripts/gds/copy-apply.js (the task you are HOLDING — the normal call)
|
|
34
36
|
// node scripts/gds/copy-apply.js --dry-run (say what would change, write nothing)
|
|
35
37
|
// node scripts/gds/copy-apply.js --task 1234 (a claim you hold, when you hold more than one)
|
|
36
38
|
// node scripts/gds/copy-apply.js --patch-file p.json (a local patch — used by the tests)
|
|
@@ -53,9 +55,12 @@
|
|
|
53
55
|
|
|
54
56
|
const fs = require('node:fs');
|
|
55
57
|
const path = require('node:path');
|
|
58
|
+
const { spawnSync } = require('node:child_process');
|
|
56
59
|
|
|
57
60
|
const ci = require('./copy-inventory.js');
|
|
58
61
|
const proposals = require('../../modules/copy-desk/proposals.js');
|
|
62
|
+
// The page tweak format's one reader and writer (ADR 0341 D4), for --batch.
|
|
63
|
+
const pages = require('../../modules/copy-desk/pages.js');
|
|
59
64
|
const { apiCall, arg, hasFlag, cliExit } = require('./cli-lib.js');
|
|
60
65
|
|
|
61
66
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
@@ -305,11 +310,275 @@ async function loadPatch({ taskId, patchFile }) {
|
|
|
305
310
|
return { ok: true, patch: parsed.patch, task };
|
|
306
311
|
}
|
|
307
312
|
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// --batch: a whole PAGE at once (task 1004322 / BV2.TW11; ADR 0341 D4).
|
|
316
|
+
//
|
|
317
|
+
// A page tweak's round carries a ```page-tweak``` block: every line the artist
|
|
318
|
+
// rewrote, frozen at submit, each with its reading key, its section, the text the
|
|
319
|
+
// page showed (`before`), the artist's wording (`after`) and where the registry
|
|
320
|
+
// placed it (`file`, `line`, `string_id`). --batch lands every line through the
|
|
321
|
+
// SAME rules one proposal lands by (ADR 0233): the span is re-derived from the
|
|
322
|
+
// tree as it stands, the `${expr}` holes go back in, and every doubt is a named
|
|
323
|
+
// refusal. What is new is only that there are many lines, and so:
|
|
324
|
+
//
|
|
325
|
+
// * A REFUSED LINE IS NAMED, NEVER SKIPPED. It goes in the applied block's
|
|
326
|
+
// `refused` list with its code, and the artist sees it at approval. The rest
|
|
327
|
+
// of the batch still lands (D4: "the batch lands with the lines that applied").
|
|
328
|
+
// * ORDER IS BY SOURCE POSITION, BOTTOM UP, per file. A rewrite can change how
|
|
329
|
+
// many lines a span covers, which would move every later line number; the
|
|
330
|
+
// line number is only ever a tie-breaker between duplicates, but applying from
|
|
331
|
+
// the bottom keeps even the tie-breaker true for every line still to come.
|
|
332
|
+
// * A READING LINE MAY BE PART OF A REGISTRY STRING. The page reader places a
|
|
333
|
+
// visible fragment on the registry row that contains it (a word inside a
|
|
334
|
+
// sentence built around a hole), so `before` is either the row's whole text
|
|
335
|
+
// or appears in it exactly once; the rewrite replaces that one occurrence.
|
|
336
|
+
// Anything else is `no_exact_span` (absent) or `ambiguous_target` (twice).
|
|
337
|
+
// * TWO LINES ON ONE STRING compose: the second is resolved against the text the
|
|
338
|
+
// first one left, never against the stale registry row.
|
|
339
|
+
//
|
|
340
|
+
// Then the command regenerates the registry, re-reads the changed pages
|
|
341
|
+
// (page-reader.js --stale, which opens a browser) and writes the
|
|
342
|
+
// ```page-tweak-applied``` block from the NEW reading: reading_hash_after and
|
|
343
|
+
// lines_after (the page's non-shared lines, the baseline drift is measured from,
|
|
344
|
+
// D8) plus the refused list.
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
const REFUSAL_CODES = Object.freeze(['target_gone', 'ambiguous_target', 'no_exact_span', 'placeholder_mismatch', 'path_outside_surface', 'unknown_surface', 'file_unreadable']);
|
|
348
|
+
|
|
349
|
+
function countOccurrences(haystack, needle) {
|
|
350
|
+
if (!needle) return 0;
|
|
351
|
+
let n = 0;
|
|
352
|
+
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) n += 1;
|
|
353
|
+
return n;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Which registry row a batch line means. A string id is a hash of (surface,
|
|
357
|
+
// text), so the same sentence in two files shares one id: the row in the line's
|
|
358
|
+
// own file wins, the nearest to its line if that file has it twice. Pure.
|
|
359
|
+
function registryRowFor(line, registryById) {
|
|
360
|
+
const rows = (line && line.string_id && registryById.get(line.string_id)) || [];
|
|
361
|
+
if (rows.length <= 1) return rows[0] || null;
|
|
362
|
+
const inFile = rows.filter((e) => e.file === line.file);
|
|
363
|
+
const pool = inFile.length ? inFile : rows;
|
|
364
|
+
if (pool.length === 1) return pool[0];
|
|
365
|
+
const near = (e) => Math.abs((e.line || 0) - (line.line || 0));
|
|
366
|
+
return pool.slice().sort((a, b) => near(a) - near(b))[0];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const rowKey = (e) => `${e.id}|${e.file}|${e.line}`;
|
|
370
|
+
|
|
371
|
+
// One batch line -> the single-proposal patch shape (proposals.js), resolved
|
|
372
|
+
// against the registry row its string_id names. `texts` carries a row's text as
|
|
373
|
+
// earlier lines of this batch left it. Pure.
|
|
374
|
+
function patchForBatchLine(line, registryById, texts = new Map()) {
|
|
375
|
+
const entry = registryRowFor(line, registryById);
|
|
376
|
+
if (!entry) {
|
|
377
|
+
return { ok: false, code: 'target_gone', detail: { string_id: line && line.string_id, what_this_means: 'The copy registry no longer has the string this line was placed on: the page changed after the artist submitted.' } };
|
|
378
|
+
}
|
|
379
|
+
const want = proposals.placeholderCount(line.before);
|
|
380
|
+
const got = proposals.placeholderCount(line.after);
|
|
381
|
+
if (want !== got) {
|
|
382
|
+
return { ok: false, code: 'placeholder_mismatch', detail: { expected: want, got, what_this_means: 'The rewrite does not keep the values the page drops into this line.' } };
|
|
383
|
+
}
|
|
384
|
+
const current = texts.has(rowKey(entry)) ? texts.get(rowKey(entry)) : entry.text;
|
|
385
|
+
let proposed;
|
|
386
|
+
if (current === line.before) proposed = line.after;
|
|
387
|
+
else {
|
|
388
|
+
const n = countOccurrences(current, line.before);
|
|
389
|
+
if (n === 0) return { ok: false, code: 'no_exact_span', detail: { file: entry.file, looked_for: line.before, in: current, what_this_means: 'The line the page shows cannot be found inside the string the registry placed it on, so no exact span exists.' } };
|
|
390
|
+
if (n > 1) return { ok: false, code: 'ambiguous_target', detail: { file: entry.file, occurrences: n, what_this_means: 'The line appears more than once inside its string, and the batch cannot say which one it meant.' } };
|
|
391
|
+
proposed = current.replace(line.before, () => line.after);
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
ok: true,
|
|
395
|
+
patch: { row: rowKey(entry), surface: entry.surface, file: entry.file, line: entry.line, origin: entry.origin, current, proposed },
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// applyBatchToSources({ batch, registry, readSource }) -> { applied, refused, sources }
|
|
400
|
+
//
|
|
401
|
+
// The whole batch, as a pure function of the registry and the files' current
|
|
402
|
+
// contents (readSource(rel) returns a file's text or throws). Writes nothing.
|
|
403
|
+
// `applied` and `refused` come back in PAGE order (the batch's own order);
|
|
404
|
+
// `sources` maps each changed file to its new contents.
|
|
405
|
+
function applyBatchToSources({ batch, registry, readSource }) {
|
|
406
|
+
const registryById = new Map();
|
|
407
|
+
for (const e of (registry && registry.entries) || []) {
|
|
408
|
+
if (!registryById.has(e.id)) registryById.set(e.id, []);
|
|
409
|
+
registryById.get(e.id).push(e);
|
|
410
|
+
}
|
|
411
|
+
const lines = ((batch && batch.lines) || []).map((l, i) => ({ ...l, order: i }));
|
|
412
|
+
const byId = (l) => registryRowFor(l, registryById);
|
|
413
|
+
// Bottom up per file, by the registry's line (the tree's truth, not the batch's).
|
|
414
|
+
const work = lines.slice().sort((a, b) => {
|
|
415
|
+
const ea = byId(a), eb = byId(b);
|
|
416
|
+
const fa = ea ? ea.file : '', fb = eb ? eb.file : '';
|
|
417
|
+
if (fa !== fb) return fa < fb ? -1 : 1;
|
|
418
|
+
return ((eb && eb.line) || 0) - ((ea && ea.line) || 0) || a.order - b.order;
|
|
419
|
+
});
|
|
420
|
+
const sources = new Map();
|
|
421
|
+
const texts = new Map();
|
|
422
|
+
const applied = [];
|
|
423
|
+
const refused = [];
|
|
424
|
+
const refuse = (l, r) => refused.push({ key: l.key, section: l.section == null ? null : l.section, before: l.before, code: r.code, detail: r.detail || {} });
|
|
425
|
+
for (const l of work) {
|
|
426
|
+
const p = patchForBatchLine(l, registryById, texts);
|
|
427
|
+
if (!p.ok) { refuse(l, p); continue; }
|
|
428
|
+
const guard = checkPatchPath(p.patch);
|
|
429
|
+
if (!guard.ok) { refuse(l, guard); continue; }
|
|
430
|
+
let source = sources.get(guard.rel);
|
|
431
|
+
if (source === undefined) {
|
|
432
|
+
try { source = readSource(guard.rel); } catch (err) { refuse(l, { code: 'file_unreadable', detail: { file: guard.rel, reason: err.message } }); continue; }
|
|
433
|
+
}
|
|
434
|
+
const out = applyPatchToSource(source, guard.rel, p.patch);
|
|
435
|
+
if (!out.ok) { refuse(l, out); continue; }
|
|
436
|
+
sources.set(guard.rel, out.source);
|
|
437
|
+
texts.set(p.patch.row, p.patch.proposed);
|
|
438
|
+
applied.push({ key: l.key, section: l.section == null ? null : l.section, file: guard.rel, line: out.line, before: out.before, after: out.after, proposed: p.patch.proposed, order: l.order });
|
|
439
|
+
}
|
|
440
|
+
const byOrder = (x, y) => x.order - y.order;
|
|
441
|
+
const orderOf = new Map(lines.map((l) => [l.key, l.order]));
|
|
442
|
+
refused.sort((x, y) => orderOf.get(x.key) - orderOf.get(y.key));
|
|
443
|
+
return { applied: applied.sort(byOrder).map(({ order, ...rest }) => rest), refused, sources };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// The ```page-tweak-applied``` block's content, from the page's NEW reading.
|
|
447
|
+
// lines_after is the non-shared lines' texts: shared shell lines are left out of
|
|
448
|
+
// drift on both sides (D8, builder pick 7), so they must be left out here too or
|
|
449
|
+
// every one of them would read as "removed" on the next drift check.
|
|
450
|
+
function appliedBlockFor({ pageId, readingPage, refused, appliedAt }) {
|
|
451
|
+
return {
|
|
452
|
+
page_id: pageId,
|
|
453
|
+
applied_at: appliedAt,
|
|
454
|
+
reading_hash_after: readingPage ? readingPage.reading_hash : null,
|
|
455
|
+
lines_after: readingPage ? (readingPage.lines || []).filter((l) => l && l.placement !== 'shared').map((l) => String(l.text)) : [],
|
|
456
|
+
refused: (refused || []).map((x) => ({ key: x.key, code: x.code })),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Load the round's batch: the task you hold, or a local file (the tests' and the
|
|
461
|
+
// dry run's entry point). A file may hold a whole round description or a bare
|
|
462
|
+
// batch object; either way it goes through pages.parseBatchBlock, the one reader.
|
|
463
|
+
async function loadBatch({ taskId, batchFile, readFile = (p) => fs.readFileSync(p, 'utf8'), api = apiCall }) {
|
|
464
|
+
let description;
|
|
465
|
+
if (batchFile) {
|
|
466
|
+
let raw;
|
|
467
|
+
try { raw = readFile(path.resolve(process.cwd(), batchFile)); } catch (err) { return { ok: false, code: 'batch_file_unreadable', detail: { reason: err.message } }; }
|
|
468
|
+
description = raw.trim().startsWith('{') ? ['```' + pages.FENCES.batch, raw.trim(), '```'].join('\n') : raw;
|
|
469
|
+
} else {
|
|
470
|
+
const res = await api('GET', `/api/bongos/tasks/${taskId}`);
|
|
471
|
+
if (!res.ok) return { ok: false, code: 'task_unreadable', detail: { task: taskId, status: res.status } };
|
|
472
|
+
const task = (res.data && res.data.task) || {};
|
|
473
|
+
if (task.source !== pages.SOURCE) return { ok: false, code: 'not_a_page_tweak', detail: { task: taskId, source: task.source || null, what_this_means: '--batch applies a page tweak round. For a single copy proposal, run this without --batch.' } };
|
|
474
|
+
description = task.description || '';
|
|
475
|
+
}
|
|
476
|
+
const parsed = pages.parseBatchBlock(description);
|
|
477
|
+
if (!parsed.ok) return parsed;
|
|
478
|
+
return { ok: true, batch: parsed.batch, description };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Re-read the pages whose files the batch changed (page-reader.js --stale: this
|
|
482
|
+
// page, and any other page sharing a changed file). Opens a browser.
|
|
483
|
+
function rereadStalePages({ spawn = spawnSync } = {}) {
|
|
484
|
+
const r = spawn(process.execPath, [path.join(__dirname, 'page-reader.js'), '--stale'], { cwd: REPO_ROOT, stdio: 'inherit' });
|
|
485
|
+
return r.status === 0 ? { ok: true } : { ok: false, code: r.status === 3 ? 'no_browser' : 'reread_failed', detail: { exit: r.status } };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function readingFor(pageId, { readFile = (p) => fs.readFileSync(p, 'utf8') } = {}) {
|
|
489
|
+
try {
|
|
490
|
+
const doc = JSON.parse(readFile(path.join(REPO_ROOT, 'docs', 'page-readings.json')));
|
|
491
|
+
return (doc.pages || []).find((p) => p.id === pageId) || null;
|
|
492
|
+
} catch { return null; }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Write the applied block into the round's description (replacing an earlier one:
|
|
496
|
+
// a re-apply after a send-back supersedes it), through PATCH /tasks/:id.
|
|
497
|
+
async function writeAppliedBlock({ taskId, description, block, api = apiCall }) {
|
|
498
|
+
const next = pages.replaceBlock(description, pages.FENCES.applied, pages.composeAppliedBlock(block));
|
|
499
|
+
if (!next.ok) return next;
|
|
500
|
+
const res = await api('PATCH', `/api/bongos/tasks/${taskId}`, { description: next.description });
|
|
501
|
+
if (!res.ok) return { ok: false, code: 'task_write_failed', detail: { task: taskId, status: res.status, what_this_means: 'The files are applied, but the round does not yet record it. PATCH /tasks/:id needs the task.edit permission (Metic+).' } };
|
|
502
|
+
return { ok: true };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// The --batch command. `deps` is the test seam: every read, write, re-read and API
|
|
506
|
+
// call can be replaced, so the whole path runs against a fixture round.
|
|
507
|
+
async function batchMain({ taskId, batchFile, dryRun, now = () => new Date().toISOString() }, deps = {}) {
|
|
508
|
+
const readFile = deps.readFile || ((p) => fs.readFileSync(p, 'utf8'));
|
|
509
|
+
const writeFile = deps.writeFile || ((p, s) => fs.writeFileSync(p, s));
|
|
510
|
+
const api = deps.api || apiCall;
|
|
511
|
+
const log = deps.log || console.log;
|
|
512
|
+
const loaded = await loadBatch({ taskId, batchFile, readFile, api });
|
|
513
|
+
if (!loaded.ok) return refuse(loaded);
|
|
514
|
+
const { batch } = loaded;
|
|
515
|
+
const registry = deps.registry || JSON.parse(readFile(path.join(REPO_ROOT, 'docs', 'copy-registry.json')));
|
|
516
|
+
const result = applyBatchToSources({ batch, registry, readSource: (rel) => readFile(path.join(REPO_ROOT, rel)) });
|
|
517
|
+
|
|
518
|
+
log(`copy-apply --batch: ${batch.page_id}, ${batch.lines.length} line(s): ${result.applied.length} applied, ${result.refused.length} refused.`);
|
|
519
|
+
for (const a of result.applied) {
|
|
520
|
+
log(` ${a.key} ${a.file}:${a.line}`);
|
|
521
|
+
log(` - ${a.before}`);
|
|
522
|
+
log(` + ${a.after}`);
|
|
523
|
+
}
|
|
524
|
+
for (const x of result.refused) {
|
|
525
|
+
log(` ${x.key} REFUSED (${x.code})${x.detail && x.detail.what_this_means ? `: ${x.detail.what_this_means}` : ''}`);
|
|
526
|
+
log(` line: ${JSON.stringify(x.before)}`);
|
|
527
|
+
}
|
|
528
|
+
if (dryRun) { log('copy-apply --batch: --dry-run, nothing written.'); return 0; }
|
|
529
|
+
|
|
530
|
+
for (const [rel, source] of result.sources) writeFile(path.join(REPO_ROOT, rel), source);
|
|
531
|
+
const { reg } = (deps.regenerateRegistry || (() => ci.write()))();
|
|
532
|
+
const missing = result.applied.filter((a) => !reg.entries.some((e) => e.file === a.file && e.text === a.proposed));
|
|
533
|
+
if (missing.length) {
|
|
534
|
+
console.error(`copy-apply --batch: WROTE THE FILES, but the regenerated registry does not contain the new wording for ${missing.map((m) => m.key).join(', ')}.`);
|
|
535
|
+
console.error(' An edit landed somewhere the inventory does not read as copy. Review `git diff` before doing anything else.');
|
|
536
|
+
return 3;
|
|
537
|
+
}
|
|
538
|
+
log(`copy-apply --batch: files written, registry regenerated (${reg.counts ? reg.counts.strings : reg.entries.length} strings).`);
|
|
539
|
+
|
|
540
|
+
const reread = (deps.reread || rereadStalePages)();
|
|
541
|
+
if (!reread.ok) {
|
|
542
|
+
console.error(`copy-apply --batch: the page could not be re-read (${reread.code}). The files are applied; run node scripts/gds/page-reader.js --stale, then this command again to write the applied block.`);
|
|
543
|
+
return 4;
|
|
544
|
+
}
|
|
545
|
+
const readingPage = (deps.readingFor || ((id) => readingFor(id, { readFile })))(batch.page_id);
|
|
546
|
+
const block = appliedBlockFor({ pageId: batch.page_id, readingPage, refused: result.refused, appliedAt: now() });
|
|
547
|
+
if (batchFile) {
|
|
548
|
+
log('copy-apply --batch: the page-tweak-applied block (a --batch-file run writes no task):');
|
|
549
|
+
log(pages.composeAppliedBlock(block));
|
|
550
|
+
} else {
|
|
551
|
+
const wrote = await writeAppliedBlock({ taskId, description: loaded.description, block, api });
|
|
552
|
+
if (!wrote.ok) return refuse(wrote);
|
|
553
|
+
log(`copy-apply --batch: recorded the page-tweak-applied block on task ${taskId} (reading ${block.reading_hash_after}, ${block.lines_after.length} lines, ${block.refused.length} refused).`);
|
|
554
|
+
}
|
|
555
|
+
if (!result.applied.length) {
|
|
556
|
+
console.error('copy-apply --batch: NO line applied. Every refusal is recorded; there is nothing to ship until they are resolved.');
|
|
557
|
+
return 2;
|
|
558
|
+
}
|
|
559
|
+
log(' Review with `git diff`, render the page, then ship this task the normal way (it waits for the artist).');
|
|
560
|
+
return 0;
|
|
561
|
+
}
|
|
562
|
+
|
|
308
563
|
async function main() {
|
|
309
564
|
let taskId = arg('--task');
|
|
310
565
|
const patchFile = arg('--patch-file');
|
|
311
566
|
const dryRun = hasFlag('--dry-run');
|
|
312
567
|
|
|
568
|
+
// --batch (task 1004322): a page tweak round. The claim gate is the same one the
|
|
569
|
+
// single proposal uses; --batch-file is the local, claim-free entry point, and
|
|
570
|
+
// like --patch-file it lands nothing a task authorised.
|
|
571
|
+
if (hasFlag('--batch')) {
|
|
572
|
+
const batchFile = arg('--batch-file');
|
|
573
|
+
if (!batchFile) {
|
|
574
|
+
const held = taskId ? await claimOnTask(taskId) : await claimedTaskId();
|
|
575
|
+
if (!held.ok) return cliExit(refuse(held));
|
|
576
|
+
taskId = held.taskId;
|
|
577
|
+
console.log(`copy-apply --batch: the round you are holding — task ${taskId}`);
|
|
578
|
+
}
|
|
579
|
+
return cliExit(await batchMain({ taskId, batchFile, dryRun }));
|
|
580
|
+
}
|
|
581
|
+
|
|
313
582
|
if (!taskId && !patchFile) {
|
|
314
583
|
const held = await claimedTaskId();
|
|
315
584
|
if (!held.ok) return cliExit(refuse(held));
|
|
@@ -379,6 +648,13 @@ if (require.main === module) {
|
|
|
379
648
|
}
|
|
380
649
|
|
|
381
650
|
module.exports = {
|
|
651
|
+
// task 1004322: --batch, a page tweak round.
|
|
652
|
+
REFUSAL_CODES,
|
|
653
|
+
registryRowFor,
|
|
654
|
+
patchForBatchLine,
|
|
655
|
+
applyBatchToSources,
|
|
656
|
+
appliedBlockFor,
|
|
657
|
+
batchMain,
|
|
382
658
|
surfaceRootFor,
|
|
383
659
|
checkPatchPath,
|
|
384
660
|
locateOccurrence,
|
|
@@ -671,4 +671,6 @@ module.exports = {
|
|
|
671
671
|
filesHash, readingHash, buildMatcher, placeText, collectStrings, holeData,
|
|
672
672
|
mergeStates, assembleReading, serialize, buildDoc, checkReadings, planRenders,
|
|
673
673
|
captureInPage, mergeReadings,
|
|
674
|
+
// task 1004322: /tweak renders the page the reader reads, through the same harness.
|
|
675
|
+
startHarness, SURFACE_MODULE, PINNED_CLOCK,
|
|
674
676
|
};
|
|
@@ -17,7 +17,8 @@ const { uploadSessionOnShip } = require('./ship-session-upload.js');
|
|
|
17
17
|
const { gitOk, shellOk } = require('./ship-git.js');
|
|
18
18
|
const { fetchDeployedVersion } = require('./ship-deploy-target.js');
|
|
19
19
|
const { landBailed, reconciledCardStatus } = require('./ship-land.js');
|
|
20
|
-
const { autoMerge } = require('./ship-merge.js');
|
|
20
|
+
const { autoMerge, publishHeld } = require('./ship-merge.js');
|
|
21
|
+
const tweakHold = require('../../modules/lifecycle/page-tweak-hold.js');
|
|
21
22
|
const { normalizeApiError, playShipBell, postLandShipOutcome } = require('./ship-io.js');
|
|
22
23
|
const { getServerReward, noteServerReward } = require('./ship-state.js');
|
|
23
24
|
|
|
@@ -176,7 +177,10 @@ function postShipCloseout(taskId) {
|
|
|
176
177
|
}
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
async function finishShip({ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine, card, dbOnly = false }) {
|
|
180
|
+
async function finishShip({ taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine, card, dbOnly = false, held = false }) {
|
|
181
|
+
// task 1004322 (ADR 0341 D7): a page tweak whose grade passed is HELD for its
|
|
182
|
+
// artist. It publishes its branch and PR and stops; nothing merges.
|
|
183
|
+
if (held) return finishHeld({ taskId, summary, postGradeStatus, card });
|
|
180
184
|
// task 1002541: the land outcome carries WHY a land didn't happen. Without it
|
|
181
185
|
// the card had only the status to go on and printed 'landing automatically'
|
|
182
186
|
// over a merge that had already bailed.
|
|
@@ -226,6 +230,26 @@ async function finishShip({ taskId, summary, advanceToMerge, skipMerge, postGrad
|
|
|
226
230
|
}
|
|
227
231
|
}
|
|
228
232
|
|
|
233
|
+
// finishHeld — the tail of a held pass (task 1004322). The session is finished
|
|
234
|
+
// work, so it syncs exactly as a confirmed ship does (the applier's session
|
|
235
|
+
// reward is computed from the upload, and pays on land). No bell and no tree
|
|
236
|
+
// close-out: nothing landed, and a send-back brings the round back to be
|
|
237
|
+
// re-applied. `deps` is a test seam only.
|
|
238
|
+
async function finishHeld({ taskId, summary, postGradeStatus, card }, deps = {}) {
|
|
239
|
+
console.log('');
|
|
240
|
+
console.log('Publishing the applied branch for the artist (ADR 0341 D7):');
|
|
241
|
+
const land = await (deps.publishHeld || publishHeld)(taskId, summary);
|
|
242
|
+
if (land && land.held) {
|
|
243
|
+
for (const line of tweakHold.heldLines(taskId, { prUrl: land.prUrl })) console.log(line);
|
|
244
|
+
} else {
|
|
245
|
+
console.error(` The branch did not publish${land && land.reason ? `: ${land.reason}` : ''}. The task stays at completed with its passed grade.`);
|
|
246
|
+
console.error(` Re-run to publish it again (the grade is not re-run): node scripts/gds/ship.js ${taskId}`);
|
|
247
|
+
}
|
|
248
|
+
const usage = await (deps.postShipSync || postShipSync)(taskId, 'confirmed');
|
|
249
|
+
await (deps.printCompletionCard || printCompletionCard)({ taskId, postGradeStatus, card, usage, land });
|
|
250
|
+
return land;
|
|
251
|
+
}
|
|
252
|
+
|
|
229
253
|
// task 1275: sum the committed diffstat (added/removed lines) over baseline...HEAD.
|
|
230
254
|
// Shared by the completion card and the in-review card. Best-effort: returns
|
|
231
255
|
// nulls when there's no baseline or git is unavailable.
|
|
@@ -377,6 +401,7 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
|
|
|
377
401
|
module.exports = {
|
|
378
402
|
diffstatFor,
|
|
379
403
|
finishShip,
|
|
404
|
+
finishHeld,
|
|
380
405
|
mergeAndDeploy,
|
|
381
406
|
onboardingNudgeBestEffort,
|
|
382
407
|
postShipSync,
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -27,6 +27,7 @@ const { apiErrorLine, normalizeApiError, isUnknownFieldRejection } = require('./
|
|
|
27
27
|
const { pushVia } = require('./ship-deploy-target.js');
|
|
28
28
|
|
|
29
29
|
const { diffstatFor, finishShip } = require('./ship-finish.js');
|
|
30
|
+
const tweakHold = require('../../modules/lifecycle/page-tweak-hold.js');
|
|
30
31
|
const { evaluateRegradeEligibility, evaluateResumeAction, resumeMerge, shouldConfirmWithoutGrade } = require('./ship-resume.js');
|
|
31
32
|
const { fileNoArtifactEscalation, noArtifactRefusalLines } = require('./ship-escalation.js');
|
|
32
33
|
const { noteServerReward } = require('./ship-state.js');
|
|
@@ -583,6 +584,7 @@ async function shipMain() {
|
|
|
583
584
|
let postGradeJustUnlocked = r.data.just_unlocked || [];
|
|
584
585
|
let gradePassed = false;
|
|
585
586
|
let advanceToMerge = false;
|
|
587
|
+
let held = false; // task 1004322: a page tweak's pass, held for its artist (ADR 0341 D7)
|
|
586
588
|
let gradeObj = null; // task 1270: the grade result, hoisted for the completion card
|
|
587
589
|
// V3.R27 (#247): credit breakdown surfaced from the grade or confirm
|
|
588
590
|
// response. Kept separate from `credits` so we only print the multiplier
|
|
@@ -696,6 +698,7 @@ async function shipMain() {
|
|
|
696
698
|
gradeObj = g;
|
|
697
699
|
gradePassed = g.passed;
|
|
698
700
|
advanceToMerge = gradePost.data.advanceToMerge;
|
|
701
|
+
held = gradePost.data.held === true;
|
|
699
702
|
postGradeStatus = gradePost.data.taskStatus;
|
|
700
703
|
credits = gradePost.data.creditsAwarded;
|
|
701
704
|
postGradeJustUnlocked = gradePost.data.just_unlocked || [];
|
|
@@ -726,7 +729,7 @@ async function shipMain() {
|
|
|
726
729
|
markShipProgress(postGradeStatus === 'shipped' ? 'landed' : 'graded', taskId);
|
|
727
730
|
}
|
|
728
731
|
await finishShip({
|
|
729
|
-
taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine: 'Done.', dbOnly,
|
|
732
|
+
taskId, summary, advanceToMerge, skipMerge, postGradeStatus, doneLine: 'Done.', dbOnly, held,
|
|
730
733
|
// task 1270: data for the deterministic completion card.
|
|
731
734
|
card: {
|
|
732
735
|
title: ac.title,
|
|
@@ -895,6 +898,18 @@ async function regradeMain() {
|
|
|
895
898
|
console.error(` node scripts/gds/ship.js ${taskId} --verified`);
|
|
896
899
|
process.exit(1);
|
|
897
900
|
}
|
|
901
|
+
// task 1004322 (ADR 0341 D7): a page tweak already HELD for its artist has a
|
|
902
|
+
// passed grade. A plain re-run lands here (completed -> resume-grade), and
|
|
903
|
+
// re-grading would spend the panel again for the verdict it already has, so it
|
|
904
|
+
// only re-publishes the branch (the recovery for a publish that failed).
|
|
905
|
+
if (tweakHold.isHeldForArtist(task, tRes.data.grade ? tRes.data.grade.passed : null)) {
|
|
906
|
+
console.log(`Task #${taskId} already passed its grade and is waiting for the artist: re-publishing its branch, not re-grading.`);
|
|
907
|
+
await finishShip({
|
|
908
|
+
taskId, summary: summaryArg || task.value_summary || '', held: true, postGradeStatus: 'completed', doneLine: 'Done.',
|
|
909
|
+
card: { title: task.title, valueSummary: summaryArg || task.value_summary || '', notes: '' },
|
|
910
|
+
});
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
898
913
|
console.log(`Re-grading task #${taskId}: ${task.title}`);
|
|
899
914
|
console.log(` eligibility: ${elig.reason} (${elig.via})`);
|
|
900
915
|
|
|
@@ -1101,6 +1116,7 @@ async function regradeMain() {
|
|
|
1101
1116
|
// normal ship (finishShip, #1093/C9) so a re-grade lands identically.
|
|
1102
1117
|
await finishShip({
|
|
1103
1118
|
taskId, summary, advanceToMerge: gradePost.data.advanceToMerge, skipMerge, dbOnly,
|
|
1119
|
+
held: gradePost.data.held === true, // task 1004322: a page tweak's pass waits for its artist
|
|
1104
1120
|
postGradeStatus,
|
|
1105
1121
|
doneLine: g.passed ? 'Done — re-grade passed.' : 'Done — re-grade still failing; fix and try again.',
|
|
1106
1122
|
// task 1270: completion card for the re-grade path too.
|