@bongos/core 1.19.1063 → 1.19.1065
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 +136 -56
- 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 +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +21 -1
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/api/openapi.json +492 -4
- package/docs/api-reference.md +9 -3
- package/docs/copy-inventory.md +60 -41
- package/docs/copy-registry.json +228 -47
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +11 -2
- package/docs/page-readings.json +45 -22
- package/modules/copy-desk/page-decisions.js +71 -0
- package/modules/copy-desk/page-status.js +96 -0
- package/modules/copy-desk/pages.js +30 -0
- package/modules/copy-desk/routes/copy-desk.js +153 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
- package/modules/hall-ui/public/approval-queue-lib.js +156 -0
- package/modules/hall-ui/public/approval-queue.css +286 -0
- package/modules/hall-ui/public/approval-queue.js +338 -0
- package/modules/hall-ui/public/studio.css +33 -0
- package/modules/hall-ui/public/studio.html +18 -1
- package/modules/hall-ui/public/studio.js +30 -0
- package/modules/hall-ui/public/studio.states.json +198 -0
- package/modules/hall-ui/records/approval-queue.md +32 -0
- package/modules/lifecycle/db-grade.js +96 -58
- package/modules/lifecycle/github-pr-close.js +41 -0
- package/modules/lifecycle/github-push.js +5 -2
- package/modules/lifecycle/lifecycle.js +12 -0
- package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
- package/modules/lifecycle/page-tweak-approve.js +242 -0
- package/modules/lifecycle/page-tweak-hold.js +74 -0
- package/modules/lifecycle/page-tweak-reads.js +33 -1
- package/modules/lifecycle/routes/gate-approvals.js +17 -2
- 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 +18 -0
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_approve.mjs +307 -0
- package/tests/fixtures/page-ledger.mjs +41 -1
- package/tests/gate_approvals.mjs +93 -0
- package/tests/hall_approval_queue.mjs +327 -0
- package/tests/hall_audit.mjs +4 -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,32 @@
|
|
|
1
|
+
# The Approval queue — a glass panel on /studio, the applied page before and after (task 1004323)
|
|
2
|
+
|
|
3
|
+
**BV2.TW12 (goal 1000095; [ADR 0341](../../../docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md) D7, D11).** Built 1:1 on the Approval queue dialog of the owner-approved master design [`docs/design/mocks/tweak-mode/Main.dc.html`](../../../docs/design/mocks/tweak-mode/README.md): the queue position ("approval queue · 1 of 2"), the page name and "· landing · N lines rewritten, applied", the before/after pills, close, the applied page on its light frame, "approve and ship", "send it back" (a one-line note and "send"), "highlighted: the words you changed", and the approved and sent-back lines.
|
|
4
|
+
|
|
5
|
+
Files: `approval-queue-lib.js` (the pure rules, `window.OTBApprovalQueueLib`, node-require-safe: every word the panel says, the render pick, the note floor), `approval-queue.js` (the panel, `window.OTBApprovalQueue.mount`) and `approval-queue.css` (the artboard's inline styles, one rule per element). The studio loads all three and mounts the panel behind a quick action (`#studio-approvals`, "Approval queue", "2 pages applied, waiting for you").
|
|
6
|
+
|
|
7
|
+
## A component, so TW16 places it without rebuilding it
|
|
8
|
+
|
|
9
|
+
`OTBApprovalQueue.mount({ api, apiBase, writes: { approve, sendBack }, onCount })` returns `{ open, close, refresh, count }`. The panel appends its own overlay to `body`, reads `GET /copy-desk/approvals` and `GET /tasks/:id/visuals`, and writes ONLY through the `writes` it is handed. The studio hands it `WRITE_ROUTES.approve` and `WRITE_ROUTES.sendBack` from its own allowlist, so the studio's no-live-CMS test still names every write the room can make. **TW16 (the lofi home) keeps the three files as they are:** it calls `mount` with the same two writes and turns its own "Approval queue" quick action into `open()`, with `onCount` feeding the action's detail line.
|
|
10
|
+
|
|
11
|
+
## The page is a picture of the applied branch
|
|
12
|
+
|
|
13
|
+
The frame shows the renders `/tweak` attached (TW11's named slots, `before|after-desktop|phone-light|dark`), never a page rebuilt from the batch (ADR 0341 builder pick 9, which keeps ADR 0233's "nothing renders a task description as product copy" true). Only server-minted `task-visuals` URLs are put in a `src`. A missing view falls back to the other colour mode, then the other device, and says what it shows. With no render at all, approve is disabled (there is nothing to approve from) and send-back stays. On a phone (760px and under) the phone render is the default.
|
|
14
|
+
|
|
15
|
+
**A word cannot be highlighted inside an image**, so the changed lines stand beside the render (under it on a phone): the section in mono, the new words in the artboard's accent mark (after) or the old words unmarked (before), "was:" / "becomes:" under each, and a line the applier refused says why ("not applied: the page no longer shows it"). The hint "highlighted: the words you changed" points at that list.
|
|
16
|
+
|
|
17
|
+
## Where it differs from the artboard, and what forced it
|
|
18
|
+
|
|
19
|
+
- **The page is an image of the real page**, not the artboard's hand-drawn light landing page: ADR 0341 pick 9 and the task. So the highlight is on the list beside it, not on the words in the page.
|
|
20
|
+
- **A second switch, desktop · phone and light · dark**, sits above the lines: the task says the eight renders fill the view switch. The header keeps the artboard's before/after pills and close exactly.
|
|
21
|
+
- **The page frame is 300px narrower** to make room for the lines.
|
|
22
|
+
- **The header counts refused lines** ("· 1 not applied"), which the artboard's sample never had: ADR 0341 D4, a refused line is shown to the artist at approval.
|
|
23
|
+
- **After a decision, "open <next page>"** sits beside the outcome line when another page waits; the artboard names the next page and stops.
|
|
24
|
+
- **The scrim is .94 over the light hall and .88 on a phone** (the artboard's .42 over the dark room otherwise): at .42 over a light page, or through the full-screen phone glass, the grey words fall below the AA floor.
|
|
25
|
+
- **A viewer who may not decide** (neither the author nor an artist) reads the queue with one sentence in place of the two buttons, the studio's `studio-verdict__sealed` precedent.
|
|
26
|
+
- **Not on the artboard at all:** the phone layout, light mode, the empty queue ("Nothing waiting"), the could-not-read line, the refusal line (the server's `what_this_means`), and the studio door itself (the artboard's 72px quick-action row, in the hall's tokens; the night room is TW16's).
|
|
27
|
+
|
|
28
|
+
The overlay root is `.aq-overlay` and its palette is its own token tier (`tests/hall_audit.mjs` `LITERAL_EXEMPT`), the tweak editor's precedent: glass over the night studio in both modes, so it cannot spend the hall's mode-flipping tier or its `--scrim`.
|
|
29
|
+
|
|
30
|
+
## The proof
|
|
31
|
+
|
|
32
|
+
`studio.states.json` renders six panel states (approvals, approvals-before, approvals-phone, approvals-note, approved, sent-back) beside the studio's own. Populated, against the hall-preview harness (`node scripts/hall-preview/server.js --port 4634 --fixture-me`), which answers the queue from `copy-desk__approvals.json` (built by the real `composeApprovals`), the renders from `tasks__visuals.json`, and the two decisions from its CANNED WRITES. `HALL_PREVIEW_VISUALS=<dir>` makes the harness serve real images by the fixture's names (the review sheet used TW11's real renders of landing:index). All clean at 1440, 390 and 320, dark and light. Behaviour: `tests/hall_approval_queue.mjs` (the real panel in a vm over the real composer's answer) and `tests/copy_desk_page_approve.mjs` (the routes and the lifecycle port, whole path).
|
|
@@ -15,6 +15,81 @@ const { pool, withTx } = api;
|
|
|
15
15
|
const seams = api;
|
|
16
16
|
const { logGradeAttempt } = require('./db-analytics.js');
|
|
17
17
|
const { mostRecentClaimHolderId } = require('./db-shared.js');
|
|
18
|
+
const tweakHold = require('./page-tweak-hold.js');
|
|
19
|
+
|
|
20
|
+
// promoteToConfirmed — completed -> confirmed, and the grade-time rewards that
|
|
21
|
+
// go with it: the net-negative bonus, the ship achievements and the achievement
|
|
22
|
+
// reconcile. Runs inside the CALLER's transaction, on its client, after the task
|
|
23
|
+
// row is locked. Two callers: applyGrade (an ordinary pass) and approveHeldTweak
|
|
24
|
+
// (task 1004323 / BV2.TW12, ADR 0341 D7), which performs for a page tweak the
|
|
25
|
+
// promotion its pass was held from. One body, so an approved page is promoted
|
|
26
|
+
// and rewarded exactly as any passed grade is, and the two cannot drift.
|
|
27
|
+
//
|
|
28
|
+
// diffStats the ship's diff_stats (the grade's signals carry them)
|
|
29
|
+
// committedFiles the ship's committed paths, or null (touches[] then)
|
|
30
|
+
// reward the resolved `reward` port
|
|
31
|
+
//
|
|
32
|
+
// Returns { netNegativeBonus, justUnlocked }.
|
|
33
|
+
async function promoteToConfirmed(client, { taskId, creditedBuilderId, diffStats = null, committedFiles = null, reward }) {
|
|
34
|
+
await client.query(
|
|
35
|
+
`UPDATE tasks
|
|
36
|
+
SET status = 'confirmed',
|
|
37
|
+
shipped_by = COALESCE(shipped_by, $2),
|
|
38
|
+
updated_at = now()
|
|
39
|
+
WHERE id = $1 AND status = 'completed'`,
|
|
40
|
+
[taskId, creditedBuilderId]
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// V3.R93 (#354): reward simplification. A ship whose diff removed more
|
|
44
|
+
// lines than it added earns a small flat bonus, written as its own
|
|
45
|
+
// credit_log row (reason='ship.net_negative') with a self-describing
|
|
46
|
+
// description. Idempotent on (task_id, reason) so a re-grade is a no-op.
|
|
47
|
+
// diff_stats arrives via the grade's signals (ship.js decorates it).
|
|
48
|
+
//
|
|
49
|
+
// Awarded BEFORE the achievement evaluators so the Subtractor achievement
|
|
50
|
+
// (which counts the builder's net-negative ships) sees the current ship's
|
|
51
|
+
// bonus row in this same transaction.
|
|
52
|
+
const nn = await reward.awardNetNegativeBonus(
|
|
53
|
+
client, creditedBuilderId, taskId, diffStats
|
|
54
|
+
);
|
|
55
|
+
const netNegativeBonus = nn.bonus;
|
|
56
|
+
const subtractorUnlocks = nn.justUnlocked;
|
|
57
|
+
|
|
58
|
+
// ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are
|
|
59
|
+
// NO LONGER awarded at grader-confirm. They land at the confirmed→shipped
|
|
60
|
+
// flip (shipTask → awardLandingCredit, keyed 'task.shipped'), so a task
|
|
61
|
+
// that strands at 'confirmed' pays nothing until its code is on `main`.
|
|
62
|
+
// The net-negative (simplification) bonus above STAYS here — it derives
|
|
63
|
+
// from grade-time diff_stats that only exist at grade time, so it cannot
|
|
64
|
+
// move to shipTask (and is outside ADR 0120's two named reward writes).
|
|
65
|
+
// creditsAwarded/creditsRaw/creditsMultiplier/ideaBonus stay 0/null at
|
|
66
|
+
// confirm for a stable return contract; the credit lands at ship. The
|
|
67
|
+
// credit-crossing achievement evaluator (evaluateAfterCreditAward) moves
|
|
68
|
+
// with the credit to shipTask's reconcile; here we still fire the
|
|
69
|
+
// ship/subtractor unlocks that DON'T depend on the per-task credit.
|
|
70
|
+
const shipUnlocks = await reward.evaluateAfterShip(client, creditedBuilderId, taskId, committedFiles);
|
|
71
|
+
let justUnlocked = [...subtractorUnlocks, ...shipUnlocks];
|
|
72
|
+
|
|
73
|
+
// task 1679: state-based safety net. The granular evaluators above are
|
|
74
|
+
// boundary-/path-sensitive — breaking-the-100c-bar only fires when a
|
|
75
|
+
// per-task confirm credit crosses 100 (the session cost-plus reward never
|
|
76
|
+
// hits it), and streaks key off current_streak. reconcile re-checks every
|
|
77
|
+
// achievement against current state and unlocks any straggler. It is
|
|
78
|
+
// idempotent: anything the granular calls just unlocked is visible in this
|
|
79
|
+
// txn, so reconcile's alreadyUnlocked guard skips it (no dup awards).
|
|
80
|
+
// SAVEPOINT-wrapped so it can never fail the grade-confirm.
|
|
81
|
+
if (reward && reward.reconcileBuilderAchievements) {
|
|
82
|
+
await client.query('SAVEPOINT ach_reconcile');
|
|
83
|
+
try {
|
|
84
|
+
const reconciled = await reward.reconcileBuilderAchievements(client, creditedBuilderId, { taskId });
|
|
85
|
+
await client.query('RELEASE SAVEPOINT ach_reconcile');
|
|
86
|
+
if (reconciled.length) justUnlocked = [...justUnlocked, ...reconciled];
|
|
87
|
+
} catch (_) {
|
|
88
|
+
await client.query('ROLLBACK TO SAVEPOINT ach_reconcile');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { netNegativeBonus, justUnlocked };
|
|
92
|
+
}
|
|
18
93
|
|
|
19
94
|
// applyGrade — V3.R18 (#209). Writes the subagent grader result to task_grades
|
|
20
95
|
// and, if the grade passed, promotes completed→confirmed + awards credits.
|
|
@@ -124,7 +199,6 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
|
|
|
124
199
|
let creditsMultiplier = 1.0;
|
|
125
200
|
let ideaBonus = null; // { ideaBonusAwarded, bonusBuilderId, ideaId } | null
|
|
126
201
|
let netNegativeBonus = 0;
|
|
127
|
-
let subtractorUnlocks = [];
|
|
128
202
|
let justUnlocked = [];
|
|
129
203
|
// Credit allocation + gamification carved to modules/economy/ (BV1.R77 / ADR
|
|
130
204
|
// 0093 §1-2): resolve the `reward` kernel port ONCE; every credit/achievement
|
|
@@ -132,69 +206,29 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
|
|
|
132
206
|
// state-machine SQL (FOR UPDATE, task_grades upsert, UPDATE tasks status,
|
|
133
207
|
// claim-holder lookup, logGradeAttempt) stays here in the lifecycle.
|
|
134
208
|
const reward = seams.resolveOptional('reward');
|
|
209
|
+
|
|
210
|
+
// THE HOLD (ADR 0341 D7; task 1004322 / BV2.TW11). A page tweak whose grade
|
|
211
|
+
// passes WAITS at completed for its artist: the grade row above is written,
|
|
212
|
+
// and the promotion below is withheld. TW12's approve performs it. Asked only
|
|
213
|
+
// on a pass at completed, and false for every task whose source is not
|
|
214
|
+
// exactly 'page-tweak', so every other grade takes the unchanged path below.
|
|
215
|
+
let held = false;
|
|
135
216
|
if (gradeResult.passed && task.status === 'completed') {
|
|
136
|
-
await client.query(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
);
|
|
217
|
+
const { rows: srcRows } = await client.query(`SELECT source FROM tasks WHERE id = $1`, [taskId]);
|
|
218
|
+
held = tweakHold.isPageTweak(srcRows[0] || null);
|
|
219
|
+
}
|
|
220
|
+
if (gradeResult.passed && task.status === 'completed' && !held) {
|
|
221
|
+
const promoted = await promoteToConfirmed(client, {
|
|
222
|
+
taskId, creditedBuilderId, diffStats: gradeResult.signals?.diff_stats, committedFiles, reward,
|
|
223
|
+
});
|
|
144
224
|
finalStatus = 'confirmed';
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
// lines than it added earns a small flat bonus, written as its own
|
|
148
|
-
// credit_log row (reason='ship.net_negative') with a self-describing
|
|
149
|
-
// description. Idempotent on (task_id, reason) so a re-grade is a no-op.
|
|
150
|
-
// diff_stats arrives via gradeResult.signals (ship.js decorates it).
|
|
151
|
-
//
|
|
152
|
-
// Awarded BEFORE the achievement evaluators so the Subtractor achievement
|
|
153
|
-
// (which counts the builder's net-negative ships) sees the current ship's
|
|
154
|
-
// bonus row in this same transaction.
|
|
155
|
-
const nn = await reward.awardNetNegativeBonus(
|
|
156
|
-
client, creditedBuilderId, taskId, gradeResult.signals?.diff_stats
|
|
157
|
-
);
|
|
158
|
-
netNegativeBonus = nn.bonus;
|
|
159
|
-
subtractorUnlocks = nn.justUnlocked;
|
|
160
|
-
|
|
161
|
-
// ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are
|
|
162
|
-
// NO LONGER awarded at grader-confirm. They land at the confirmed→shipped
|
|
163
|
-
// flip (shipTask → awardLandingCredit, keyed 'task.shipped'), so a task
|
|
164
|
-
// that strands at 'confirmed' pays nothing until its code is on `main`.
|
|
165
|
-
// The net-negative (simplification) bonus above STAYS here — it derives
|
|
166
|
-
// from grade-time diff_stats that only exist at grade time, so it cannot
|
|
167
|
-
// move to shipTask (and is outside ADR 0120's two named reward writes).
|
|
168
|
-
// creditsAwarded/creditsRaw/creditsMultiplier/ideaBonus stay 0/null at
|
|
169
|
-
// confirm for a stable return contract; the credit lands at ship. The
|
|
170
|
-
// credit-crossing achievement evaluator (evaluateAfterCreditAward) moves
|
|
171
|
-
// with the credit to shipTask's reconcile; here we still fire the
|
|
172
|
-
// ship/subtractor unlocks that DON'T depend on the per-task credit.
|
|
173
|
-
const shipUnlocks = await reward.evaluateAfterShip(client, creditedBuilderId, taskId, committedFiles);
|
|
174
|
-
justUnlocked = [...subtractorUnlocks, ...shipUnlocks];
|
|
175
|
-
|
|
176
|
-
// task 1679: state-based safety net. The granular evaluators above are
|
|
177
|
-
// boundary-/path-sensitive — breaking-the-100c-bar only fires when a
|
|
178
|
-
// per-task confirm credit crosses 100 (the session cost-plus reward never
|
|
179
|
-
// hits it), and streaks key off current_streak. reconcile re-checks every
|
|
180
|
-
// achievement against current state and unlocks any straggler. It is
|
|
181
|
-
// idempotent: anything the granular calls just unlocked is visible in this
|
|
182
|
-
// txn, so reconcile's alreadyUnlocked guard skips it (no dup awards).
|
|
183
|
-
// SAVEPOINT-wrapped so it can never fail the grade-confirm.
|
|
184
|
-
if (reward && reward.reconcileBuilderAchievements) {
|
|
185
|
-
await client.query('SAVEPOINT ach_reconcile');
|
|
186
|
-
try {
|
|
187
|
-
const reconciled = await reward.reconcileBuilderAchievements(client, creditedBuilderId, { taskId });
|
|
188
|
-
await client.query('RELEASE SAVEPOINT ach_reconcile');
|
|
189
|
-
if (reconciled.length) justUnlocked = [...justUnlocked, ...reconciled];
|
|
190
|
-
} catch (_) {
|
|
191
|
-
await client.query('ROLLBACK TO SAVEPOINT ach_reconcile');
|
|
192
|
-
}
|
|
193
|
-
}
|
|
225
|
+
netNegativeBonus = promoted.netNegativeBonus;
|
|
226
|
+
justUnlocked = promoted.justUnlocked;
|
|
194
227
|
}
|
|
195
228
|
// !passed → task_grades row written above; task stays at completed; no
|
|
196
229
|
// credits, no achievements. Builder can manual-confirm via /confirm if the
|
|
197
230
|
// grade was wrong.
|
|
231
|
+
// held → the same row, the same completed; the pass is recorded and waits.
|
|
198
232
|
|
|
199
233
|
return {
|
|
200
234
|
taskId,
|
|
@@ -207,6 +241,9 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
|
|
|
207
241
|
ideaBonus, // V3.R27 (#247): { ideaBonusAwarded, bonusBuilderId, ideaId } | null
|
|
208
242
|
netNegativeBonus,
|
|
209
243
|
advanceToMerge: finalStatus === 'confirmed',
|
|
244
|
+
// task 1004322: true only for a page tweak whose pass is waiting for its
|
|
245
|
+
// artist. ship.js reads it to publish the branch without landing it.
|
|
246
|
+
held,
|
|
210
247
|
justUnlocked,
|
|
211
248
|
grade: {
|
|
212
249
|
score: gradeResult.score,
|
|
@@ -230,4 +267,5 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
|
|
|
230
267
|
|
|
231
268
|
module.exports = {
|
|
232
269
|
applyGrade,
|
|
270
|
+
promoteToConfirmed,
|
|
233
271
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// modules/lifecycle/github-pr-close.js — close an open PR without merging it
|
|
2
|
+
// (task 1004323 / BV2.TW12, ADR 0341 D7).
|
|
3
|
+
//
|
|
4
|
+
// The Approval queue's send-back returns a page tweak to the /tweak queue, and
|
|
5
|
+
// the applied branch's PR must not sit open to be merged by anything that sweeps
|
|
6
|
+
// green PRs. Same server credential and the same PATCH as github-push.js
|
|
7
|
+
// recheckPullRequest's close half, never reopened here. Its own file because
|
|
8
|
+
// github-push.js sits at the 1,500-line ratchet; it reuses that file's token and
|
|
9
|
+
// headers rather than a second copy of either.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const githubPush = require('./github-push');
|
|
14
|
+
const { repoInfo } = require('../../src/module-api');
|
|
15
|
+
|
|
16
|
+
function fail(code, extra = {}) {
|
|
17
|
+
return Object.assign(new Error(code), { code, ...extra });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// closePullRequest({ number }, deps) -> { closed: true, number }
|
|
21
|
+
// deps: token / repoInfo / fetchImpl, the github-push test seams.
|
|
22
|
+
async function closePullRequest({ number }, deps = {}) {
|
|
23
|
+
const token = await githubPush.resolveToken(deps);
|
|
24
|
+
if (!token) throw fail('PUSH_UNCONFIGURED');
|
|
25
|
+
if (!Number.isInteger(number) || number < 1) throw fail('BAD_PR');
|
|
26
|
+
const info = deps.repoInfo || repoInfo.loadRepoInfo();
|
|
27
|
+
if (!info || info.error) throw fail('NO_REPO_INFO');
|
|
28
|
+
const doFetch = deps.fetchImpl || fetch;
|
|
29
|
+
const res = await doFetch(`https://api.github.com/repos/${info.owner}/${info.repo}/pulls/${number}`, {
|
|
30
|
+
method: 'PATCH',
|
|
31
|
+
headers: { ...githubPush.ghHeaders(token), 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify({ state: 'closed' }),
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const detail = await res.text().catch(() => '');
|
|
36
|
+
throw fail('PR_PATCH_FAILED', { status: res.status, detail: githubPush.scrubCredential(detail).slice(0, 200) });
|
|
37
|
+
}
|
|
38
|
+
return { closed: true, number };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { closePullRequest };
|
|
@@ -1392,7 +1392,9 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
1392
1392
|
// `reopened` event carrying full PR context. Stays inside the App's existing
|
|
1393
1393
|
// `pull_requests:write` — no new permission (the deterministic Actions rerun API would
|
|
1394
1394
|
// need `actions:write`, deliberately not granted; see blocker 37).
|
|
1395
|
-
|
|
1395
|
+
// `arm: false` (task 1004323): reopen WITHOUT re-arming, for a page tweak held for its
|
|
1396
|
+
// artist (ADR 0341 D7); `heldReason` becomes the auto_merge_reason.
|
|
1397
|
+
async function recheckPullRequest({ number, arm = true, heldReason = null }, deps = {}) {
|
|
1396
1398
|
const token = await resolveToken(deps);
|
|
1397
1399
|
if (!token) throw err('PUSH_UNCONFIGURED');
|
|
1398
1400
|
if (!Number.isInteger(number) || number < 1) throw err('BAD_PR');
|
|
@@ -1426,7 +1428,8 @@ async function recheckPullRequest({ number }, deps = {}) {
|
|
|
1426
1428
|
let autoMerge = false;
|
|
1427
1429
|
let autoMergeReason = null;
|
|
1428
1430
|
try {
|
|
1429
|
-
if (
|
|
1431
|
+
if (!arm) autoMergeReason = heldReason || 'auto-merge was not re-armed (held)';
|
|
1432
|
+
else if (reopened && reopened.node_id) {
|
|
1430
1433
|
// The DETAILED variant: the boolean façade discarded the cause here, and no
|
|
1431
1434
|
// response body carried it either — the quietest of the four (task 1003511).
|
|
1432
1435
|
const armed = await enableAutoMergeDetailed(
|
|
@@ -27,6 +27,7 @@ const mingle = require('./mingle');
|
|
|
27
27
|
const pageTweakReads = require('./page-tweak-reads');
|
|
28
28
|
const pageTweakClaim = require('./page-tweak-claim');
|
|
29
29
|
const pageTweakWrite = require('./page-tweak-write');
|
|
30
|
+
const pageTweakApprove = require('./page-tweak-approve');
|
|
30
31
|
|
|
31
32
|
module.exports = {
|
|
32
33
|
// --- retired doorway leaks (ADR 0093 §3) ---------------------------------
|
|
@@ -126,6 +127,17 @@ module.exports = {
|
|
|
126
127
|
// updateTaskDescription and releaseClaim; see modules/lifecycle/page-tweak-write.js.
|
|
127
128
|
updateHeldTaskDescription: (...a) => pageTweakWrite.updateHeldTaskDescription(...a),
|
|
128
129
|
submitPageTweak: (...a) => pageTweakWrite.submitPageTweak(...a),
|
|
130
|
+
// The Approval queue's two decisions (task 1004323 / BV2.TW12, ADR 0341 D7,
|
|
131
|
+
// D11): approve a page tweak held for its artist (completed -> confirmed with
|
|
132
|
+
// the grade-time rewards its pass skipped, then the server lands it), or send
|
|
133
|
+
// it back (a sent-back block, the claim released, the grade row dropped, the
|
|
134
|
+
// PR closed, the round back to ready). Only the author or an artist decides.
|
|
135
|
+
// See modules/lifecycle/page-tweak-approve.js.
|
|
136
|
+
approveHeldTweak: (...a) => pageTweakApprove.approveHeldTweak(...a),
|
|
137
|
+
sendBackHeldTweak: (...a) => pageTweakApprove.sendBackHeldTweak(...a),
|
|
138
|
+
// A builder's crafts (help-requests' reader, from builders.preferred_disciplines),
|
|
139
|
+
// so the queue read can say whether the caller may decide before they try.
|
|
140
|
+
builderCrafts: (...a) => helpRequests.craftsForBuilder(...a),
|
|
129
141
|
listVersions: (...a) => db.listVersions(...a),
|
|
130
142
|
versionProgress: (...a) => db.versionProgress(...a),
|
|
131
143
|
recentShippedTasks: (...a) => db.recentShippedTasks(...a),
|
|
@@ -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,242 @@
|
|
|
1
|
+
// modules/lifecycle/page-tweak-approve.js — the two decisions of the Approval
|
|
2
|
+
// queue: approve a held page tweak so it lands, or send it back to the /tweak
|
|
3
|
+
// queue with a note (task 1004323 / BV2.TW12, ADR 0341 D7 and D11).
|
|
4
|
+
//
|
|
5
|
+
// On the `lifecycle` port because they move `tasks.status`, and the lifecycle
|
|
6
|
+
// owns the state machine; copy-desk calls them through the port and never
|
|
7
|
+
// writes a status itself (ADR 0341 D7). What the sent-back block SAYS is
|
|
8
|
+
// copy-desk's (modules/copy-desk/pages.js, its one writer) and is handed in as
|
|
9
|
+
// a function; the transaction, the hold check and the authority are here.
|
|
10
|
+
//
|
|
11
|
+
// THE HOLD THEY RELEASE. A page tweak whose grade passes waits at `completed`
|
|
12
|
+
// (page-tweak-hold.js, applyGrade): its branch and PR are published, not armed.
|
|
13
|
+
// Both decisions are refused (`not_waiting`) unless the round is exactly that:
|
|
14
|
+
// source 'page-tweak', status 'completed', and a strict passed grade. So a round
|
|
15
|
+
// still being applied, one already landing, or an ordinary task cannot be moved
|
|
16
|
+
// through here.
|
|
17
|
+
//
|
|
18
|
+
// WHO DECIDES (ADR 0341 D7, builder pick 3): the round's author (the builder of
|
|
19
|
+
// its latest WEB claim, the D10 payee rule, read from `claims`, never the
|
|
20
|
+
// request), or any other builder whose crafts include artist (help-requests'
|
|
21
|
+
// own reader), so an absent author does not strand a page. The project owner's
|
|
22
|
+
// escape hatch is the existing override-request path (confirmTask is not held),
|
|
23
|
+
// recorded and audit-logged there. Everyone else is refused `not_an_artist`.
|
|
24
|
+
//
|
|
25
|
+
// APPROVE performs the promotion the grade withheld, through the SAME body
|
|
26
|
+
// applyGrade runs (db-grade.promoteToConfirmed): completed -> confirmed, the
|
|
27
|
+
// net-negative bonus from the grade's own diff_stats, the ship achievements and
|
|
28
|
+
// the reconcile, all credited to the applier (the task's most recent claim
|
|
29
|
+
// holder, applyGrade's rule). Then the server lands it as it lands any
|
|
30
|
+
// confirmed task: after the commit it nudges the PR's merge once (mergeIfGreen,
|
|
31
|
+
// best-effort), and the publish reconciler's sweep merges it otherwise. Nothing
|
|
32
|
+
// here arms or forces anything GitHub's own gate refuses.
|
|
33
|
+
//
|
|
34
|
+
// SEND-BACK appends the page-tweak-sent-back block, releases any claim still
|
|
35
|
+
// open on the round (the applier's CLI claim is normally released by its ship
|
|
36
|
+
// already), DELETES the round's grade row and returns the task to `ready`, so
|
|
37
|
+
// the round reads `submitted` again: back in the /tweak queue. After the commit
|
|
38
|
+
// it closes the unmerged PR (best-effort; a ready task's PR is never swept).
|
|
39
|
+
//
|
|
40
|
+
// WHY THE GRADE ROW GOES. task_grades keeps one row per task, the latest. Left
|
|
41
|
+
// in place, the re-applied round would reach `completed` at its next ship with
|
|
42
|
+
// the OLD pass still on it, and read as waiting for the artist, approvable,
|
|
43
|
+
// before its new work was graded; ship.js would also take that stale pass as a
|
|
44
|
+
// held pass and re-publish instead of grading (ship-flow's hold check). The
|
|
45
|
+
// attempt stays in the append-only grade-attempts log.
|
|
46
|
+
//
|
|
47
|
+
// Outcomes are returned, not thrown, so the route maps each to a named answer:
|
|
48
|
+
// approved | sent_back
|
|
49
|
+
// not_found no such task
|
|
50
|
+
// wrong_source not a page tweak
|
|
51
|
+
// not_waiting not held for its artist (status and grade named)
|
|
52
|
+
// not_an_artist the caller is neither the author nor an artist
|
|
53
|
+
// builder_inactive
|
|
54
|
+
// refused copy-desk's compose refused (its refusal passed back)
|
|
55
|
+
|
|
56
|
+
'use strict';
|
|
57
|
+
|
|
58
|
+
const api = require('../../src/module-api');
|
|
59
|
+
const hold = require('./page-tweak-hold');
|
|
60
|
+
const helpRequests = require('./help-requests');
|
|
61
|
+
const githubPush = require('./github-push');
|
|
62
|
+
const prClose = require('./github-pr-close');
|
|
63
|
+
|
|
64
|
+
// The GitHub reads and writes the two decisions make, resolved at call time so a
|
|
65
|
+
// test's stub of either file bites. A test hands its own as deps.push.
|
|
66
|
+
const PUSH = Object.freeze({
|
|
67
|
+
isConfigured: () => githubPush.isConfigured(),
|
|
68
|
+
findTaskPullRequest: (a) => githubPush.findTaskPullRequest(a),
|
|
69
|
+
mergeIfGreen: (a) => githubPush.mergeIfGreen(a),
|
|
70
|
+
closePullRequest: (a) => prClose.closePullRequest(a),
|
|
71
|
+
});
|
|
72
|
+
const { mostRecentClaimHolderId } = require('./db-shared.js');
|
|
73
|
+
const { promoteToConfirmed } = require('./db-grade.js');
|
|
74
|
+
|
|
75
|
+
const log = api.logger('lifecycle');
|
|
76
|
+
|
|
77
|
+
function activePoolFor(deps) {
|
|
78
|
+
return (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : api.pool;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Lock the round, check it is held for its artist, and check the caller may
|
|
82
|
+
// decide it. Returns { task, grade, authorId } or an outcome.
|
|
83
|
+
async function lockWaitingRound(client, { taskId, builderId }) {
|
|
84
|
+
const { rows } = await client.query(
|
|
85
|
+
`SELECT id, status, title, description, source, source_ref, version_id, goal_id, touches
|
|
86
|
+
FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
87
|
+
[taskId]
|
|
88
|
+
);
|
|
89
|
+
const task = rows[0];
|
|
90
|
+
if (!task) return { outcome: 'not_found' };
|
|
91
|
+
if (!hold.isPageTweak(task)) return { outcome: 'wrong_source', task };
|
|
92
|
+
const { rows: g } = await client.query('SELECT passed, signals FROM task_grades WHERE task_id = $1', [taskId]);
|
|
93
|
+
const grade = g[0] || null;
|
|
94
|
+
if (!hold.isHeldForArtist(task, grade ? grade.passed : null)) {
|
|
95
|
+
return { outcome: 'not_waiting', task, grade_passed: grade ? grade.passed : null };
|
|
96
|
+
}
|
|
97
|
+
const { rows: b } = await client.query(
|
|
98
|
+
'SELECT id, status, github_login, display_name FROM builders WHERE id = $1', [builderId]
|
|
99
|
+
);
|
|
100
|
+
if (!b[0] || b[0].status === 'inactive') return { outcome: 'builder_inactive', task };
|
|
101
|
+
const { rows: w } = await client.query(
|
|
102
|
+
`SELECT builder_id FROM claims
|
|
103
|
+
WHERE task_id = $1 AND worktree_name IS NULL AND creator_session_id IS NULL
|
|
104
|
+
ORDER BY claimed_at DESC, id DESC LIMIT 1`,
|
|
105
|
+
[taskId]
|
|
106
|
+
);
|
|
107
|
+
const authorId = w[0] ? String(w[0].builder_id) : null;
|
|
108
|
+
if (authorId !== String(builderId)) {
|
|
109
|
+
const crafts = await helpRequests.craftsForBuilder(builderId, { pool: client });
|
|
110
|
+
if (!crafts.includes('artist')) return { outcome: 'not_an_artist', task, author_id: authorId };
|
|
111
|
+
}
|
|
112
|
+
return { task, grade, authorId };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function inTx(deps, body) {
|
|
116
|
+
const client = await activePoolFor(deps).connect();
|
|
117
|
+
try {
|
|
118
|
+
await client.query('BEGIN');
|
|
119
|
+
const out = await body(client);
|
|
120
|
+
await client.query(out && out.commit ? 'COMMIT' : 'ROLLBACK');
|
|
121
|
+
return out;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
124
|
+
throw err;
|
|
125
|
+
} finally {
|
|
126
|
+
client.release();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The round's PR, from the server's own GitHub reads (the reconciler's
|
|
131
|
+
// adopt-by-task lookup). Null when there is no credential or no such PR.
|
|
132
|
+
async function roundPullRequest(taskId, push) {
|
|
133
|
+
if (!push.isConfigured()) return null;
|
|
134
|
+
return push.findTaskPullRequest({ taskId: Number(taskId) });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// After an approve commits: one merge nudge, the reconciler's own first step.
|
|
138
|
+
// Best-effort and never thrown: the task is confirmed either way, and the
|
|
139
|
+
// sweep lands it when this misses.
|
|
140
|
+
async function nudgeLand(taskId, push = PUSH) {
|
|
141
|
+
try {
|
|
142
|
+
const pr = await roundPullRequest(taskId, push);
|
|
143
|
+
if (!pr) return { nudged: false, reason: 'no_pr_found', lands_via: 'reconciler' };
|
|
144
|
+
if (pr.merged) return { nudged: false, merged: true, pr_number: pr.pr_number };
|
|
145
|
+
if (pr.pr_state !== 'open') return { nudged: false, reason: `pr_${pr.pr_state || 'unknown'}`, pr_number: pr.pr_number, lands_via: 'reconciler' };
|
|
146
|
+
const m = await push.mergeIfGreen({ branch: pr.branch });
|
|
147
|
+
return { nudged: true, merged: !!(m && m.merged), merged_now: !!(m && m.merged_now), reason: (m && m.reason) || null, pr_number: pr.pr_number, lands_via: m && m.merged ? 'merged' : 'reconciler' };
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log.warn({ task_id: String(taskId), err: err && err.message }, 'approve: merge nudge failed (the reconciler lands it)');
|
|
150
|
+
return { nudged: false, reason: 'nudge_failed', lands_via: 'reconciler' };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// After a send-back commits: close the applied branch's unmerged PR.
|
|
155
|
+
async function closeRoundPr(taskId, push = PUSH) {
|
|
156
|
+
try {
|
|
157
|
+
const pr = await roundPullRequest(taskId, push);
|
|
158
|
+
if (!pr) return { closed: false, reason: 'no_pr_found' };
|
|
159
|
+
if (pr.merged) return { closed: false, reason: 'already_merged', pr_number: pr.pr_number };
|
|
160
|
+
if (pr.pr_state !== 'open') return { closed: false, reason: `pr_${pr.pr_state || 'unknown'}`, pr_number: pr.pr_number };
|
|
161
|
+
await push.closePullRequest({ number: Number(pr.pr_number) });
|
|
162
|
+
return { closed: true, pr_number: pr.pr_number };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
log.warn({ task_id: String(taskId), err: err && err.message }, 'send-back: closing the PR failed (it stays un-armed)');
|
|
165
|
+
return { closed: false, reason: 'close_failed' };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// approveHeldTweak({ taskId, builderId }, deps)
|
|
170
|
+
//
|
|
171
|
+
// deps.pool (NODE_ENV=test only), deps.reward (the resolved reward port),
|
|
172
|
+
// deps.push (github-push, for the post-commit nudge).
|
|
173
|
+
async function approveHeldTweak({ taskId, builderId }, deps = {}) {
|
|
174
|
+
const reward = deps.reward || api.resolveOptional('reward');
|
|
175
|
+
const out = await inTx(deps, async (client) => {
|
|
176
|
+
const held = await lockWaitingRound(client, { taskId, builderId });
|
|
177
|
+
if (held.outcome) return held;
|
|
178
|
+
const creditedBuilderId = (await mostRecentClaimHolderId(client, taskId)) ?? builderId;
|
|
179
|
+
const signals = (held.grade && held.grade.signals) || {};
|
|
180
|
+
const promoted = await promoteToConfirmed(client, {
|
|
181
|
+
taskId,
|
|
182
|
+
creditedBuilderId,
|
|
183
|
+
diffStats: signals.diff_stats || null,
|
|
184
|
+
committedFiles: null,
|
|
185
|
+
reward,
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
commit: true,
|
|
189
|
+
outcome: 'approved',
|
|
190
|
+
task: { id: String(held.task.id), status: 'confirmed', source_ref: held.task.source_ref, title: held.task.title },
|
|
191
|
+
advanceToMerge: true,
|
|
192
|
+
author_id: held.authorId,
|
|
193
|
+
credited_builder_id: creditedBuilderId == null ? null : String(creditedBuilderId),
|
|
194
|
+
net_negative_bonus: promoted.netNegativeBonus,
|
|
195
|
+
just_unlocked: promoted.justUnlocked,
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
delete out.commit;
|
|
199
|
+
if (out.outcome !== 'approved') return out;
|
|
200
|
+
out.land = await nudgeLand(taskId, deps.push || PUSH);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// sendBackHeldTweak({ taskId, builderId, compose }, deps)
|
|
205
|
+
//
|
|
206
|
+
// compose(task) -> { ok: true, description } | { ok: false, code, detail }
|
|
207
|
+
//
|
|
208
|
+
// copy-desk's compose appends the page-tweak-sent-back block ({ at, by, note });
|
|
209
|
+
// the note's floor is checked there and at the route.
|
|
210
|
+
async function sendBackHeldTweak({ taskId, builderId, compose }, deps = {}) {
|
|
211
|
+
const out = await inTx(deps, async (client) => {
|
|
212
|
+
const held = await lockWaitingRound(client, { taskId, builderId });
|
|
213
|
+
if (held.outcome) return held;
|
|
214
|
+
const r = compose(held.task);
|
|
215
|
+
if (!r || !r.ok) return { outcome: 'refused', refusal: r, task: held.task };
|
|
216
|
+
const { rows } = await client.query(
|
|
217
|
+
`UPDATE tasks SET description = $2, status = 'ready', updated_at = now() WHERE id = $1
|
|
218
|
+
RETURNING id, status, description, source_ref, updated_at`,
|
|
219
|
+
[taskId, r.description]
|
|
220
|
+
);
|
|
221
|
+
const { rows: released } = await client.query(
|
|
222
|
+
`UPDATE claims SET released_at = now(), outcome = 'partial'
|
|
223
|
+
WHERE task_id = $1 AND released_at IS NULL
|
|
224
|
+
RETURNING id`,
|
|
225
|
+
[taskId]
|
|
226
|
+
);
|
|
227
|
+
await client.query('DELETE FROM task_grades WHERE task_id = $1', [taskId]);
|
|
228
|
+
return {
|
|
229
|
+
commit: true,
|
|
230
|
+
outcome: 'sent_back',
|
|
231
|
+
task: rows[0],
|
|
232
|
+
author_id: held.authorId,
|
|
233
|
+
released_claim_ids: released.map((x) => String(x.id)),
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
delete out.commit;
|
|
237
|
+
if (out.outcome !== 'sent_back') return out;
|
|
238
|
+
out.pr = await closeRoundPr(taskId, deps.push || PUSH);
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { approveHeldTweak, sendBackHeldTweak };
|