@gentbajko/slopify 3.16.0 → 3.18.0
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/dist/edge/http/actions.js +2 -2
- package/dist/edge/http/actor.js +10 -0
- package/dist/edge/http/app.js +21 -0
- package/dist/edge/http/checkpoints.js +14 -6
- package/dist/edge/http/external-reviews.js +95 -0
- package/dist/edge/http/projects.js +36 -18
- package/dist/edge/http/reviews.js +53 -18
- package/dist/edge/http/revisions.js +5 -1
- package/dist/edge/http/settings.js +9 -1
- package/dist/edge/http/starts.js +41 -0
- package/dist/edge/http/studio.js +5 -130
- package/dist/edge/mcp/attribution.js +38 -0
- package/dist/edge/mcp/call-route.js +203 -0
- package/dist/edge/mcp/excluded-routes.js +95 -0
- package/dist/edge/mcp/file-tools.js +141 -0
- package/dist/edge/mcp/http.js +79 -0
- package/dist/edge/mcp/instructions.js +17 -0
- package/dist/edge/mcp/library-tools.js +143 -0
- package/dist/edge/mcp/remake-tools.js +90 -0
- package/dist/edge/mcp/review-tools.js +64 -0
- package/dist/edge/mcp/route-tool.js +61 -0
- package/dist/edge/mcp/server.js +48 -0
- package/dist/edge/mcp/state-shapes.js +60 -0
- package/dist/edge/mcp/tools-channels.js +153 -0
- package/dist/edge/mcp/tools-library.js +151 -0
- package/dist/edge/mcp/tools-planning.js +137 -0
- package/dist/edge/mcp/tools-projects.js +167 -0
- package/dist/edge/mcp/tools-publishing.js +148 -0
- package/dist/edge/mcp/tools-runs.js +94 -0
- package/dist/edge/mcp/tools-schedules.js +143 -0
- package/dist/edge/mcp/tools-setup.js +234 -0
- package/dist/edge/mcp/tools-storage.js +99 -0
- package/dist/edge/mcp/tools.js +21 -0
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
- package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
- package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
- package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
- package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
- package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
- package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
- package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
- package/dist/kernel/pipeline.js +6 -0
- package/dist/kernel/runner/graph.js +37 -2
- package/dist/kernel/runner/index.js +6 -3
- package/dist/main.js +29 -13
- package/dist/patch-notes/3.17.0.md +10 -0
- package/dist/patch-notes/3.18.0.md +13 -0
- package/dist/patch-notes/index.json +12 -0
- package/dist/slices/admission/admitted-work.js +53 -0
- package/dist/slices/admission/heads.js +5 -0
- package/dist/slices/admission/repo.js +47 -29
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/admission/stage-writes.js +21 -0
- package/dist/slices/admission/start.js +2 -1
- package/dist/slices/admission/state-words.js +110 -0
- package/dist/slices/agents/attention-work.js +233 -0
- package/dist/slices/agents/attention.js +168 -0
- package/dist/slices/agents/connection.js +15 -0
- package/dist/slices/agents/model.js +29 -0
- package/dist/slices/agents/repo.js +66 -0
- package/dist/slices/cancel/index.js +4 -1
- package/dist/slices/control/index.js +2 -1
- package/dist/slices/episodes/summarize.js +2 -1
- package/dist/slices/estimate/index.js +6 -2
- package/dist/slices/images/run.js +2 -1
- package/dist/slices/library/transfer.js +69 -0
- package/dist/slices/narration/run.js +2 -1
- package/dist/slices/notifications/address.js +40 -0
- package/dist/slices/notifications/notifier.js +43 -10
- package/dist/slices/notifications/rules.js +111 -45
- package/dist/slices/notifications/server.js +14 -0
- package/dist/slices/notifications/settings.js +1 -1
- package/dist/slices/notifications/subject.js +30 -0
- package/dist/slices/notifications/told.js +33 -0
- package/dist/slices/play-drafts/readiness.js +9 -2
- package/dist/slices/play-drafts/schema.js +2 -1
- package/dist/slices/rebuild/narration-words.js +76 -0
- package/dist/slices/rebuild/recipe-model.js +1 -0
- package/dist/slices/rebuild/recipe-reviews.js +44 -23
- package/dist/slices/rebuild/recovery-repo.js +0 -20
- package/dist/slices/rebuild/recovery-selection.js +14 -3
- package/dist/slices/rebuild/recovery.js +5 -2
- package/dist/slices/rebuild/remake-outdated.js +27 -0
- package/dist/slices/rebuild/repo.js +7 -2
- package/dist/slices/rebuild/review-redo.js +12 -0
- package/dist/slices/rebuild/runtime-agent-review.js +121 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
- package/dist/slices/rebuild/runtime-review.js +12 -2
- package/dist/slices/rebuild/runtime-run.js +11 -2
- package/dist/slices/rebuild/runtime-store.js +32 -5
- package/dist/slices/reruns/index.js +2 -1
- package/dist/slices/research/run.js +2 -1
- package/dist/slices/reviews/external-model.js +21 -0
- package/dist/slices/reviews/external-release.js +38 -0
- package/dist/slices/reviews/external-repo.js +109 -0
- package/dist/slices/reviews/external-rows.js +68 -0
- package/dist/slices/reviews/external-waiting.js +75 -0
- package/dist/slices/reviews/holds.js +144 -0
- package/dist/slices/reviews/model.js +14 -0
- package/dist/slices/reviews/repo.js +27 -10
- package/dist/slices/reviews/rules.js +5 -3
- package/dist/slices/reviews/verdict.js +18 -8
- package/dist/slices/revisions/repo.js +3 -0
- package/dist/slices/revisions/schema.js +1 -0
- package/dist/slices/schedules/agenda.js +47 -17
- package/dist/slices/schedules/prepare.js +6 -0
- package/dist/slices/schedules/schema.js +13 -1
- package/dist/slices/storage/consequences.js +27 -0
- package/dist/slices/storage/portable.js +3 -0
- package/dist/slices/storage/project-files.js +66 -0
- package/dist/slices/studio/settings.js +0 -35
- package/dist/slices/studio/stats.js +2 -34
- package/dist/slices/subtitles/prepare.js +2 -1
- package/dist/slices/uploads/repo.js +12 -6
- package/dist/slices/uploads/rules.js +5 -0
- package/dist/slices/video/write-export.js +1 -1
- package/dist/slices/video/write-subtitles.js +1 -1
- package/dist/tutorials/Agents-MCP.md +168 -0
- package/dist/tutorials/Calendar.md +2 -1
- package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
- package/dist/tutorials/Home-and-Projects.md +16 -9
- package/dist/tutorials/Home.md +1 -0
- package/dist/tutorials/Notifications.md +25 -6
- package/dist/tutorials/Project-Page.md +8 -2
- package/dist/tutorials/Publishing-to-YouTube.md +1 -0
- package/dist/tutorials/Recovery-and-Retries.md +17 -3
- package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
- package/dist/tutorials/Schedules.md +1 -1
- package/dist/tutorials/Settings-Reference.md +15 -0
- package/dist/tutorials/Troubleshooting.md +3 -1
- package/dist/tutorials/Your-First-Short.md +1 -0
- package/dist/tutorials/_Sidebar.md +1 -0
- package/dist/web/assets/{index-VbpUYLAp.css → index-BKbZIxAq.css} +1 -1
- package/dist/web/assets/index-Drth_MfQ.js +325 -0
- package/dist/web/assets/{pdf-C6KhxeKQ.js → pdf-CQFUyijy.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/slices/studio/performance.js +0 -83
- package/dist/slices/studio/report-stats.js +0 -39
- package/dist/slices/studio/report.js +0 -172
- package/dist/web/assets/index-Dvz8bsiD.js +0 -325
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { externalReviewWaitMs } from "../reviews/external-model.js";
|
|
3
|
+
import { openExternalReview } from "../reviews/external-repo.js";
|
|
4
|
+
import { externalReviewByFingerprint } from "../reviews/external-rows.js";
|
|
5
|
+
import { reviewStages } from "../reviews/model.js";
|
|
6
|
+
import { nextAttempt, reviewOutcome } from "../reviews/outcome.js";
|
|
7
|
+
import { latestVerdict, saveVerdict } from "../reviews/repo.js";
|
|
8
|
+
import { exportSnapshot } from "./runtime-export-inputs.js";
|
|
9
|
+
import { publishResult } from "./runtime-publication.js";
|
|
10
|
+
import { reviewMaterial } from "./runtime-review.js";
|
|
11
|
+
// What `recipe-reviews.ts` saves for a review an external agent answers, by position.
|
|
12
|
+
const valuesSchema = z.tuple([
|
|
13
|
+
z.enum(reviewStages),
|
|
14
|
+
z.string(),
|
|
15
|
+
z.array(z.tuple([z.string(), z.unknown()])),
|
|
16
|
+
z.string(),
|
|
17
|
+
z.enum(["flag", "redo"]),
|
|
18
|
+
z.number().int().nonnegative(),
|
|
19
|
+
]);
|
|
20
|
+
// A review no model answers: the step saves what a reviewer would be shown and waits for an
|
|
21
|
+
// agent's verdict through MCP. Waiting is not "held": the stage's other reviews still open
|
|
22
|
+
// theirs in the same pass, and `agentReviewHold` keeps the runner from claiming it again.
|
|
23
|
+
export async function executeAgentReviewRecipe(deps, context, piece) {
|
|
24
|
+
if (deps.db
|
|
25
|
+
.prepare("SELECT state FROM revision_work_pieces WHERE id=? AND work_id=?")
|
|
26
|
+
.get(piece.id, context.work.workId)?.state === "done")
|
|
27
|
+
return "done";
|
|
28
|
+
if (!context.maySubmit(piece.id))
|
|
29
|
+
return "held";
|
|
30
|
+
const values = piece.input.kind === "local" ? valuesSchema.safeParse(piece.input.values) : undefined;
|
|
31
|
+
if (values === undefined || !values.success)
|
|
32
|
+
throw new Error("Slopify hit an internal error (a review step was set up wrongly). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
33
|
+
const [stage, itemKey, , prompt, mode, retries] = values.data;
|
|
34
|
+
const projectId = context.work.projectId;
|
|
35
|
+
const known = externalReviewByFingerprint(deps.db, projectId, piece.fingerprint);
|
|
36
|
+
if (known === undefined || known.state === "superseded" || known.state === "canceled") {
|
|
37
|
+
const material = reviewMaterial(deps, context, exportSnapshot(deps, context, piece), stage, itemKey, prompt);
|
|
38
|
+
const now = deps.clock.now();
|
|
39
|
+
openExternalReview(deps.db, {
|
|
40
|
+
id: deps.ids.next(),
|
|
41
|
+
projectId,
|
|
42
|
+
revisionId: context.work.revisionId,
|
|
43
|
+
workId: context.work.workId,
|
|
44
|
+
pieceId: piece.id,
|
|
45
|
+
workKey: piece.key,
|
|
46
|
+
itemKey,
|
|
47
|
+
stage,
|
|
48
|
+
itemFingerprint: material.itemFingerprint,
|
|
49
|
+
reviewFingerprint: piece.fingerprint,
|
|
50
|
+
material: {
|
|
51
|
+
request: material.request,
|
|
52
|
+
images: material.images.map((image) => ({ stored: image.stored, name: image.name })),
|
|
53
|
+
},
|
|
54
|
+
waitingSince: now.toISOString(),
|
|
55
|
+
deadlineAt: new Date(now.getTime() + externalReviewWaitMs).toISOString(),
|
|
56
|
+
});
|
|
57
|
+
context.emit({ type: "project.updated", projectId });
|
|
58
|
+
return "waiting";
|
|
59
|
+
}
|
|
60
|
+
const autoPassed = known.state === "auto-passed";
|
|
61
|
+
const verdict = autoPassed ? { passed: true, reasons: [] } : known.verdict;
|
|
62
|
+
if (known.state === "waiting" || verdict === null)
|
|
63
|
+
return "waiting";
|
|
64
|
+
const attempt = nextAttempt(latestVerdict(deps.db, projectId, itemKey), known.itemFingerprint);
|
|
65
|
+
const outcome = autoPassed
|
|
66
|
+
? "auto-passed"
|
|
67
|
+
: reviewOutcome({ passed: verdict.passed, mode, attempt, retries });
|
|
68
|
+
const saved = saveVerdict(deps.db, {
|
|
69
|
+
id: deps.ids.next(),
|
|
70
|
+
projectId,
|
|
71
|
+
revisionId: context.work.revisionId,
|
|
72
|
+
itemKey,
|
|
73
|
+
stage,
|
|
74
|
+
itemFingerprint: known.itemFingerprint,
|
|
75
|
+
reviewFingerprint: piece.fingerprint,
|
|
76
|
+
passed: verdict.passed,
|
|
77
|
+
reasons: verdict.reasons,
|
|
78
|
+
outcome,
|
|
79
|
+
attempt,
|
|
80
|
+
action: null,
|
|
81
|
+
actionAt: null,
|
|
82
|
+
redoState: outcome === "redo" ? "pending" : null,
|
|
83
|
+
redoError: null,
|
|
84
|
+
answeredBy: autoPassed ? "nobody" : (known.answeredBy ?? "agent"),
|
|
85
|
+
actionBy: null,
|
|
86
|
+
createdAt: deps.clock.now().toISOString(),
|
|
87
|
+
});
|
|
88
|
+
await publishResult(deps, context, piece, [], {
|
|
89
|
+
verdictId: saved.id,
|
|
90
|
+
passed: saved.passed,
|
|
91
|
+
reasons: saved.reasons,
|
|
92
|
+
outcome: saved.outcome,
|
|
93
|
+
attempt: saved.attempt,
|
|
94
|
+
});
|
|
95
|
+
if (saved.outcome === "flagged")
|
|
96
|
+
context.emit({
|
|
97
|
+
type: "review.flagged",
|
|
98
|
+
projectId,
|
|
99
|
+
verdictId: saved.id,
|
|
100
|
+
stage: saved.stage,
|
|
101
|
+
itemKey: saved.itemKey,
|
|
102
|
+
...(saved.reasons[0] === undefined ? {} : { reason: saved.reasons[0] }),
|
|
103
|
+
...(saved.answeredBy === "agent" ? { byAgent: true } : {}),
|
|
104
|
+
});
|
|
105
|
+
return "done";
|
|
106
|
+
}
|
|
107
|
+
// A stage whose only unfinished steps are reviews waiting for an agent is not claimed again
|
|
108
|
+
// until one is answered (or passes at its deadline); anything else lets it run.
|
|
109
|
+
export function agentReviewHold(deps, work) {
|
|
110
|
+
const open = deps.db
|
|
111
|
+
.prepare(`SELECT e.id AS review FROM revision_work_pieces p
|
|
112
|
+
LEFT JOIN external_reviews e ON e.work_id=p.work_id AND e.piece_id=p.id
|
|
113
|
+
AND e.review_fingerprint=p.fingerprint AND e.state='waiting'
|
|
114
|
+
WHERE p.work_id=? AND p.state!='done' AND p.work_key NOT LIKE 'article:continuation:%'`)
|
|
115
|
+
.all(work.workId)
|
|
116
|
+
.map((row) => z.object({ review: z.string().nullable() }).parse(row).review);
|
|
117
|
+
const waiting = open.flatMap((id) => (id === null ? [] : [id]));
|
|
118
|
+
return open.length > 0 && waiting.length === open.length
|
|
119
|
+
? { kind: "held", checkpointIds: waiting.map((id) => `agent-review:${id}`) }
|
|
120
|
+
: { kind: "eligible" };
|
|
121
|
+
}
|
|
@@ -30,6 +30,15 @@ export async function revisionAudio(deps, context, view,
|
|
|
30
30
|
// the shorts' pick) reads the plain ones, which it waits for. An uploaded narration has no
|
|
31
31
|
// levelled join: only the master reaches it.
|
|
32
32
|
options = {}) {
|
|
33
|
+
return narrationAudio({
|
|
34
|
+
paths: deps.paths,
|
|
35
|
+
measure: (path) => deps.measureAudio?.(path, context.signal) ??
|
|
36
|
+
probeDurationMs(deps.ffmpeg, path, context.signal, deps.log),
|
|
37
|
+
}, view, options);
|
|
38
|
+
}
|
|
39
|
+
// The narration's parts in their order on the export timeline: the version's selected, ready
|
|
40
|
+
// intro, body and outro, each measured when its length was never recorded.
|
|
41
|
+
export async function narrationAudio(deps, view, options = {}) {
|
|
33
42
|
const config = view.revision.config;
|
|
34
43
|
if (config.sources.audio === "off")
|
|
35
44
|
return [];
|
|
@@ -46,10 +55,8 @@ options = {}) {
|
|
|
46
55
|
throw new Error(`The levelled ${segment === "body" ? "narration" : segment} isn't finished yet, so the sound can't be exported at an even volume. Let the Narration stage finish (Resume, or Try again on it), then retry this stage; or turn off Level the volume in Edit project → Pauses and volume.`);
|
|
47
56
|
if (row === undefined)
|
|
48
57
|
return undefined;
|
|
49
|
-
const path = outputPath(deps.paths,
|
|
50
|
-
const duration = row.output.durationMs ??
|
|
51
|
-
(await (deps.measureAudio?.(path, context.signal) ??
|
|
52
|
-
probeDurationMs(deps.ffmpeg, path, context.signal, deps.log)));
|
|
58
|
+
const path = outputPath(deps.paths, view.revision.projectId, row.output.path);
|
|
59
|
+
const duration = row.output.durationMs ?? (await deps.measure(path));
|
|
53
60
|
if (!Number.isFinite(duration) || duration <= 0)
|
|
54
61
|
throw new Error("Slopify couldn't read the length of the narration audio, so the file may be damaged or empty. Regenerate the narration (or upload your audio file again in Edit project → Narration), then Try again.");
|
|
55
62
|
return { path, seconds: duration / 1000 };
|
|
@@ -57,14 +64,22 @@ options = {}) {
|
|
|
57
64
|
const body = await input("audio_body");
|
|
58
65
|
if (body === undefined)
|
|
59
66
|
throw new Error("The narration audio isn't finished yet. Let the Narration stage finish (Resume, or Try again on it), then retry this stage.");
|
|
60
|
-
return
|
|
67
|
+
return narrationTimeline(config, {
|
|
61
68
|
body,
|
|
62
|
-
intro: config
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
intro: speaksPart(config, "intro") ? await input("audio_intro") : undefined,
|
|
70
|
+
outro: speaksPart(config, "outro") ? await input("audio_outro") : undefined,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// Only generated narration has a spoken intro and outro.
|
|
74
|
+
export function speaksPart(config, part) {
|
|
75
|
+
return config.sources.audio === "generate" && config[part] !== undefined;
|
|
76
|
+
}
|
|
77
|
+
// The narration in its order on the export timeline, the clock the word timing is on.
|
|
78
|
+
export function narrationTimeline(config, parts) {
|
|
79
|
+
return audioTimeline({
|
|
80
|
+
body: parts.body,
|
|
81
|
+
intro: speaksPart(config, "intro") ? parts.intro : undefined,
|
|
82
|
+
outro: speaksPart(config, "outro") ? parts.outro : undefined,
|
|
68
83
|
gapSeconds: config.silenceGapSeconds,
|
|
69
84
|
edgeSeconds: config.edgeSilenceSeconds,
|
|
70
85
|
}, config.sources.video === "off" ? 1 / 48000 : 1 / 30);
|
|
@@ -93,6 +93,8 @@ export async function executeReviewRecipe(deps, context, providers, piece) {
|
|
|
93
93
|
actionAt: null,
|
|
94
94
|
redoState: outcome === "redo" ? "pending" : null,
|
|
95
95
|
redoError: null,
|
|
96
|
+
answeredBy: "model",
|
|
97
|
+
actionBy: null,
|
|
96
98
|
createdAt: deps.clock.now().toISOString(),
|
|
97
99
|
});
|
|
98
100
|
await publishResult(deps, context, piece, [], {
|
|
@@ -138,7 +140,7 @@ function spokenText(deps, snapshot) {
|
|
|
138
140
|
}
|
|
139
141
|
return plainText(snapshot.view.articleMarkdown ?? "");
|
|
140
142
|
}
|
|
141
|
-
function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
|
|
143
|
+
export function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
|
|
142
144
|
const { view } = snapshot;
|
|
143
145
|
const recipes = snapshot.plan.recipes;
|
|
144
146
|
const config = view.revision.config;
|
|
@@ -197,11 +199,18 @@ function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
|
|
|
197
199
|
images: [
|
|
198
200
|
{
|
|
199
201
|
path: path(image),
|
|
202
|
+
stored: image.output.path,
|
|
200
203
|
name: stage === "images" ? "the image to review" : "the thumbnail to review",
|
|
201
204
|
},
|
|
202
205
|
...(reference === undefined
|
|
203
206
|
? []
|
|
204
|
-
: [
|
|
207
|
+
: [
|
|
208
|
+
{
|
|
209
|
+
path: path(reference),
|
|
210
|
+
stored: reference.output.path,
|
|
211
|
+
name: "the establishing image it must match",
|
|
212
|
+
},
|
|
213
|
+
]),
|
|
205
214
|
],
|
|
206
215
|
itemFingerprint: image.fingerprint,
|
|
207
216
|
};
|
|
@@ -255,6 +264,7 @@ function reviewMaterial(deps, context, snapshot, stage, itemKey, prompt) {
|
|
|
255
264
|
request: request([{ label: "Title of the short", text: title }], names),
|
|
256
265
|
images: stills.map((row, index) => ({
|
|
257
266
|
path: path(row),
|
|
267
|
+
stored: row.output.path,
|
|
258
268
|
name: names[index] ?? "an image of the short",
|
|
259
269
|
})),
|
|
260
270
|
itemFingerprint: render.fingerprint,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { executeAgentReviewRecipe } from "./runtime-agent-review.js";
|
|
1
2
|
import { executeAnimateRecipe } from "./runtime-animate.js";
|
|
2
3
|
import { executeDocumentRecipe } from "./runtime-document.js";
|
|
3
4
|
import { executeExportRecipe } from "./runtime-export.js";
|
|
@@ -12,6 +13,7 @@ import { executeYoutubeRecipe } from "./runtime-youtube.js";
|
|
|
12
13
|
import { workPieces } from "./work-records.js";
|
|
13
14
|
export async function runRevisionInvocation(deps, context, providers) {
|
|
14
15
|
const pieces = workPieces(deps.db, context.work.workId).filter((piece) => !piece.key.startsWith("article:continuation:"));
|
|
16
|
+
let waiting = false;
|
|
15
17
|
for (const piece of pieces) {
|
|
16
18
|
if (piece.state === "done")
|
|
17
19
|
continue;
|
|
@@ -34,7 +36,9 @@ export async function runRevisionInvocation(deps, context, providers) {
|
|
|
34
36
|
: piece.key.startsWith("document:")
|
|
35
37
|
? await executeDocumentRecipe(deps, context, piece)
|
|
36
38
|
: piece.key.startsWith("review:")
|
|
37
|
-
?
|
|
39
|
+
? piece.input.kind === "local" && piece.input.operation === "review-agent-v1"
|
|
40
|
+
? await executeAgentReviewRecipe(deps, context, piece)
|
|
41
|
+
: await executeReviewRecipe(deps, context, providers, piece)
|
|
38
42
|
: piece.key.startsWith("voices:")
|
|
39
43
|
? await executeVoicesRecipe(deps, context, piece)
|
|
40
44
|
: piece.key.startsWith("youtube:")
|
|
@@ -48,6 +52,11 @@ export async function runRevisionInvocation(deps, context, providers) {
|
|
|
48
52
|
piece.input.kind === "image"
|
|
49
53
|
? await executeProviderRecipe(deps, context, providers, piece)
|
|
50
54
|
: await executeLocalRecipe(deps, context, piece);
|
|
55
|
+
// An agent's review waits without stopping the stage's other pieces.
|
|
56
|
+
if (outcome === "waiting") {
|
|
57
|
+
waiting = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
51
60
|
if (outcome === "held")
|
|
52
61
|
return outcome;
|
|
53
62
|
}
|
|
@@ -74,7 +83,7 @@ export async function runRevisionInvocation(deps, context, providers) {
|
|
|
74
83
|
deps.db
|
|
75
84
|
.prepare("UPDATE revision_work_pieces SET state='done' WHERE work_id=? AND result_json IS NOT NULL")
|
|
76
85
|
.run(context.work.workId);
|
|
77
|
-
return "done";
|
|
86
|
+
return waiting ? "held" : "done";
|
|
78
87
|
}
|
|
79
88
|
function startLocalTime(deps, context) {
|
|
80
89
|
const id = deps.ids.next();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { stageKinds, stageStates } from "../../kernel/pipeline.js";
|
|
3
3
|
import { optionalDeps } from "../../kernel/runner/graph.js";
|
|
4
|
+
import { admittedStates, parkedKinds } from "../admission/admitted-work.js";
|
|
4
5
|
import { sourceOf } from "../admission/model.js";
|
|
5
6
|
import { usesShorts, usesYoutubeDescription } from "../admission/rules.js";
|
|
6
7
|
import { reviewsNarration } from "../reviews/rules.js";
|
|
@@ -138,7 +139,7 @@ export function executionStandings(deps, projectId, derived) {
|
|
|
138
139
|
if (view === undefined)
|
|
139
140
|
return [];
|
|
140
141
|
const work = derived ?? executionStages(deps, projectId);
|
|
141
|
-
|
|
142
|
+
const standings = stageKinds.map((kind) => {
|
|
142
143
|
const source = sourceOf(view.revision.config.sources, kind);
|
|
143
144
|
const group = work
|
|
144
145
|
.filter((entry) => entry.kind === kind)
|
|
@@ -174,8 +175,17 @@ export function executionStandings(deps, projectId, derived) {
|
|
|
174
175
|
? "provided"
|
|
175
176
|
: "done"
|
|
176
177
|
: "pending";
|
|
177
|
-
return state === "pending" && retryAt !== null
|
|
178
|
+
return state === "pending" && retryAt !== null
|
|
179
|
+
? { kind, state, source, retryAt }
|
|
180
|
+
: { kind, state, source };
|
|
178
181
|
});
|
|
182
|
+
const admitted = admittedStates(deps.db, projectId);
|
|
183
|
+
const parked = parkedKinds(deps.db, projectId);
|
|
184
|
+
return standings.map((stage) => ({
|
|
185
|
+
...stage,
|
|
186
|
+
...(admitted.has(`${stage.kind}:${stage.state}`) ? { admitted: true } : {}),
|
|
187
|
+
...(parked.has(stage.kind) ? { parked: true } : {}),
|
|
188
|
+
}));
|
|
179
189
|
}
|
|
180
190
|
// A finished piece whose request was planned under this fingerprint: a placeholder is planned
|
|
181
191
|
// under the logical fingerprint, the finished piece carries the one its request resolved to.
|
|
@@ -216,7 +226,9 @@ export function projectStandings(deps, projectId) {
|
|
|
216
226
|
.get(entry.work.workId);
|
|
217
227
|
return total + fraction(progress?.progress_current, progress?.progress_total);
|
|
218
228
|
}, 0);
|
|
219
|
-
// A failed step, or else one waiting to run again, says why on the stage row.
|
|
229
|
+
// A failed step, or else one waiting to run again, says why on the stage row. A failed
|
|
230
|
+
// stage keeps the moment it failed: needs_attention tells a narration retry started after
|
|
231
|
+
// the failure (still going) from one started before it (spent) by it.
|
|
220
232
|
const failed = all.find((work) => work.state === "failed") ??
|
|
221
233
|
(stage.retryAt === undefined || stage.retryAt === null
|
|
222
234
|
? undefined
|
|
@@ -227,8 +239,23 @@ export function projectStandings(deps, projectId) {
|
|
|
227
239
|
.prepare("SELECT failure_reason,failure_kind FROM revision_work WHERE id=?")
|
|
228
240
|
.get(failed.work.workId);
|
|
229
241
|
deps.db
|
|
230
|
-
.prepare(
|
|
231
|
-
|
|
242
|
+
.prepare(`UPDATE stages SET state=:state,failure_reason=:reason,failure_kind=:kind,retry_at=:retryAt,
|
|
243
|
+
progress_current=:current,progress_total=:total,
|
|
244
|
+
finished_at=CASE WHEN :state='done' THEN :now
|
|
245
|
+
WHEN :state='failed' THEN CASE WHEN state='failed' AND finished_at IS NOT NULL THEN finished_at ELSE :now END
|
|
246
|
+
ELSE NULL END
|
|
247
|
+
WHERE project_id=:projectId AND kind=:stage`)
|
|
248
|
+
.run({
|
|
249
|
+
state: stage.state,
|
|
250
|
+
reason: textOf(failure?.failure_reason),
|
|
251
|
+
kind: textOf(failure?.failure_kind),
|
|
252
|
+
retryAt: stage.retryAt ?? null,
|
|
253
|
+
current: invocations.length === 0 ? null : current,
|
|
254
|
+
total: invocations.length === 0 ? null : invocations.length,
|
|
255
|
+
now: deps.clock.now().toISOString(),
|
|
256
|
+
projectId,
|
|
257
|
+
stage: stage.kind,
|
|
258
|
+
});
|
|
232
259
|
}
|
|
233
260
|
settleScheduleRunsForProject(deps.db, projectId, deps.clock.now().toISOString());
|
|
234
261
|
return work;
|
|
@@ -2,7 +2,8 @@ import { rmSync } from "node:fs";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { transact } from "../../kernel/db/tx.js";
|
|
4
4
|
import { allPiecesOf, deletePiece, deletePieces, setPiece, } from "../../kernel/runner/piece-repo.js";
|
|
5
|
-
import { projectById,
|
|
5
|
+
import { projectById, stagesOf } from "../admission/repo.js";
|
|
6
|
+
import { resetStage } from "../admission/stage-writes.js";
|
|
6
7
|
import { storeArticleText } from "../article/store.js";
|
|
7
8
|
import { currentRevisionId } from "../revisions/repo.js";
|
|
8
9
|
import { outputPath } from "../storage/layout.js";
|
|
@@ -2,7 +2,8 @@ import { z } from "zod";
|
|
|
2
2
|
import { transact } from "../../kernel/db/tx.js";
|
|
3
3
|
import { isProviderError } from "../../kernel/ports/model.js";
|
|
4
4
|
import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
5
|
-
import { projectById
|
|
5
|
+
import { projectById } from "../admission/repo.js";
|
|
6
|
+
import { setStageProgress } from "../admission/stage-writes.js";
|
|
6
7
|
import { storeText } from "../storage/staging.js";
|
|
7
8
|
import { noTokens, plusUsage } from "../telemetry/model.js";
|
|
8
9
|
import { researchDocuments } from "./documents.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// ceiling: the owner's number. An hourly agent routine answers well inside two hours; a run
|
|
2
|
+
// with no agent at all still finishes the same evening.
|
|
3
|
+
export const externalReviewWaitMs = 2 * 60 * 60 * 1000;
|
|
4
|
+
// `canceled`: Cancel closed it while it waited; taken up again, its step asks it once more.
|
|
5
|
+
export const externalReviewStates = [
|
|
6
|
+
"waiting",
|
|
7
|
+
"answered",
|
|
8
|
+
"auto-passed",
|
|
9
|
+
"superseded",
|
|
10
|
+
"canceled",
|
|
11
|
+
];
|
|
12
|
+
// Why a review takes no verdict, one sentence per standing: what get_external_review explains,
|
|
13
|
+
// and what a refused verdict says after "The verdict wasn't saved."
|
|
14
|
+
export const closedReviewSentences = {
|
|
15
|
+
answered: "This review was already answered, so it takes no other verdict. Call needs_attention to see what is waiting now.",
|
|
16
|
+
"auto-passed": "This review passed at its 2-hour deadline with no verdict, so it takes none now. Call needs_attention to see what is waiting now.",
|
|
17
|
+
superseded: "This review was replaced: its item was made again or edited, and the new one waits for its own verdict. Call needs_attention to see it.",
|
|
18
|
+
"replaced-pending": "This review was replaced: its item was made again or edited, so it takes no verdict, and no new review waits for it yet. One opens once the new item is made; an edit saved but not continued makes none until the run is continued. There is nothing to answer here: call needs_attention to see what is waiting now.",
|
|
19
|
+
canceled: "This review's run was canceled, so nothing waits on it and it takes no verdict: a canceled run's reviews never pass. Call needs_attention to see what is waiting now.",
|
|
20
|
+
trashed: "This review's project is in the Trash, so nothing waits on it and it takes no verdict. Restore the project in Settings → Backup & storage → Trash; the review then waits for its verdict again.",
|
|
21
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { causedBy } from "../../kernel/errors.js";
|
|
2
|
+
import { reviewItemName } from "../notifications/rules.js";
|
|
3
|
+
import { revisionById } from "../revisions/repo.js";
|
|
4
|
+
import { settleExternalReviews } from "./external-repo.js";
|
|
5
|
+
function itemName(db, review) {
|
|
6
|
+
const revision = revisionById(db, review.projectId, review.revisionId);
|
|
7
|
+
return reviewItemName(review.itemKey, revision?.content.imageOrder ?? []);
|
|
8
|
+
}
|
|
9
|
+
// At boot and every few seconds: each review past its deadline passes, says so, and the run
|
|
10
|
+
// goes on. The deadline is a row, so a restart in between loses nothing. Each passed review is
|
|
11
|
+
// announced and woken on its own: one that throws is logged, and the others still go on (the
|
|
12
|
+
// passing itself is already saved).
|
|
13
|
+
export function releaseDueReviews(deps) {
|
|
14
|
+
const passed = settleExternalReviews(deps.db, deps.now());
|
|
15
|
+
for (const review of passed) {
|
|
16
|
+
try {
|
|
17
|
+
deps.emit(review.projectId, {
|
|
18
|
+
type: "review.auto-passed",
|
|
19
|
+
projectId: review.projectId,
|
|
20
|
+
reviewId: review.id,
|
|
21
|
+
stage: review.stage,
|
|
22
|
+
itemKey: review.itemKey,
|
|
23
|
+
item: itemName(deps.db, review),
|
|
24
|
+
revisionId: review.revisionId,
|
|
25
|
+
workId: review.workId,
|
|
26
|
+
});
|
|
27
|
+
deps.emit(review.projectId, { type: "project.updated", projectId: review.projectId });
|
|
28
|
+
deps.tick(review.projectId);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
deps.log.write("error", "review.deadline", {
|
|
32
|
+
projectId: review.projectId,
|
|
33
|
+
detail: `Review ${review.id} passed at its deadline, but announcing it or waking the run failed: ${causedBy(error)}`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return passed.length;
|
|
38
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
3
|
+
import { liveProject } from "../admission/repo.js";
|
|
4
|
+
import { externalReviewByFingerprint, externalReviewById } from "./external-rows.js";
|
|
5
|
+
import { liveWork, reviewStanding, wantedBy } from "./external-waiting.js";
|
|
6
|
+
import { reviewStages } from "./model.js";
|
|
7
|
+
// A step asked again for the same review keeps its row; one whose earlier wait was superseded
|
|
8
|
+
// or canceled waits again from now.
|
|
9
|
+
export function openExternalReview(db, input) {
|
|
10
|
+
db.prepare(`INSERT INTO external_reviews(id,project_id,revision_id,work_id,piece_id,work_key,item_key,stage,item_fingerprint,review_fingerprint,material,state,waiting_since,deadline_at)
|
|
11
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,'waiting',?,?)
|
|
12
|
+
ON CONFLICT(project_id,review_fingerprint) DO UPDATE SET revision_id=excluded.revision_id,
|
|
13
|
+
work_id=excluded.work_id,piece_id=excluded.piece_id,work_key=excluded.work_key,
|
|
14
|
+
item_fingerprint=excluded.item_fingerprint,material=excluded.material,state='waiting',
|
|
15
|
+
waiting_since=excluded.waiting_since,deadline_at=excluded.deadline_at,verdict=NULL,
|
|
16
|
+
answered_by=NULL,answered_at=NULL
|
|
17
|
+
WHERE external_reviews.state IN ('superseded','canceled')`).run(input.id, input.projectId, input.revisionId, input.workId, input.pieceId, input.workKey, input.itemKey, input.stage, input.itemFingerprint, input.reviewFingerprint, JSON.stringify(input.material), input.waitingSince, input.deadlineAt);
|
|
18
|
+
const saved = externalReviewByFingerprint(db, input.projectId, input.reviewFingerprint);
|
|
19
|
+
if (saved === undefined)
|
|
20
|
+
throw new Error("Slopify hit an internal error (an external review could not be saved). Retry stage; if it happens again, use Download diagnostics in Settings and report it.");
|
|
21
|
+
return saved;
|
|
22
|
+
}
|
|
23
|
+
// Cancel stops a run's reviews waiting. Retried, its step asks again (`openExternalReview`) and
|
|
24
|
+
// each review waits its whole two hours from then, not from before the cancel.
|
|
25
|
+
export function stopWaitingReviews(db, projectId) {
|
|
26
|
+
db.prepare("UPDATE external_reviews SET state='canceled' WHERE project_id=? AND state='waiting'").run(projectId);
|
|
27
|
+
}
|
|
28
|
+
// The step that waited ended its stage as held, which parks the work (`finishWork`); a settled
|
|
29
|
+
// review lets it be claimed again while the current version still wants it. A restart also
|
|
30
|
+
// held the work's unfinished pieces (`recoverWork`), so the ones never submitted are allowed
|
|
31
|
+
// again, as boot does for work it keeps (`recoverCheckpointWork`). A paused project stays put:
|
|
32
|
+
// the runner claims nothing for it until Resume.
|
|
33
|
+
function releaseParkedWork(db, projectId, id) {
|
|
34
|
+
const parked = `(SELECT e.work_id FROM external_reviews e WHERE e.project_id=? AND e.id=? AND ${wantedBy("e")})`;
|
|
35
|
+
const released = db
|
|
36
|
+
.prepare(`UPDATE revision_work SET dispatch_state='allowed' WHERE state='pending' AND dispatch_state='held' AND id=${parked}`)
|
|
37
|
+
.run(projectId, id);
|
|
38
|
+
if (Number(released.changes) === 0)
|
|
39
|
+
return;
|
|
40
|
+
db.prepare(`UPDATE revision_work_pieces SET state='pending',dispatch_state='allowed'
|
|
41
|
+
WHERE work_id=${parked} AND state='held' AND submitted_at IS NULL AND continuation IS NULL`).run(projectId, id);
|
|
42
|
+
}
|
|
43
|
+
// The first verdict saved wins: read and answered in one transaction, so two agents answering
|
|
44
|
+
// at once cannot both succeed. A review whose item was just replaced is superseded here rather
|
|
45
|
+
// than at the next settle, a project in the Trash takes no verdict until it is restored, and a
|
|
46
|
+
// canceled run's review takes none at all.
|
|
47
|
+
export function answerExternalReview(db, input) {
|
|
48
|
+
return transact(db, () => {
|
|
49
|
+
const review = externalReviewById(db, input.projectId, input.id);
|
|
50
|
+
if (review === undefined)
|
|
51
|
+
return { ok: false, reason: "not-found" };
|
|
52
|
+
const standing = reviewStanding(db, review);
|
|
53
|
+
if (standing === "trashed")
|
|
54
|
+
return { ok: false, reason: "trashed" };
|
|
55
|
+
if (standing === "superseded" || standing === "replaced-pending")
|
|
56
|
+
db.prepare("UPDATE external_reviews SET state='superseded' WHERE project_id=? AND id=? AND state='waiting'").run(input.projectId, input.id);
|
|
57
|
+
if (standing !== "waiting")
|
|
58
|
+
return { ok: false, reason: standing, value: review };
|
|
59
|
+
db.prepare("UPDATE external_reviews SET state='answered', verdict=?, answered_by=?, answered_at=? WHERE project_id=? AND id=?").run(JSON.stringify(input.verdict), input.by, input.at, input.projectId, input.id);
|
|
60
|
+
releaseParkedWork(db, input.projectId, input.id);
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
value: {
|
|
64
|
+
...review,
|
|
65
|
+
state: "answered",
|
|
66
|
+
verdict: input.verdict,
|
|
67
|
+
answeredBy: input.by,
|
|
68
|
+
answeredAt: input.at,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const dueRow = z.object({
|
|
74
|
+
id: z.string(),
|
|
75
|
+
project_id: z.string(),
|
|
76
|
+
revision_id: z.string(),
|
|
77
|
+
work_id: z.string(),
|
|
78
|
+
stage: z.enum(reviewStages),
|
|
79
|
+
item_key: z.string(),
|
|
80
|
+
});
|
|
81
|
+
// Replaced reviews stop waiting; waiting ones past their deadline pass without review. A
|
|
82
|
+
// paused project's review passes too (nothing runs until Resume); a canceled run's never does,
|
|
83
|
+
// nor one of a project in the Trash (it waits on, and passes once restored).
|
|
84
|
+
// Read by plain columns only: a review whose stored material can't be read back still passes,
|
|
85
|
+
// and never holds up the others.
|
|
86
|
+
export function settleExternalReviews(db, now) {
|
|
87
|
+
const at = now.toISOString();
|
|
88
|
+
return transact(db, () => {
|
|
89
|
+
db.prepare(`UPDATE external_reviews SET state='superseded' WHERE state='waiting' AND NOT ${wantedBy("external_reviews")}`).run();
|
|
90
|
+
const due = db
|
|
91
|
+
.prepare(`SELECT e.id, e.project_id, e.revision_id, e.work_id, e.stage, e.item_key FROM external_reviews e
|
|
92
|
+
WHERE e.state='waiting' AND e.deadline_at<=? AND ${liveWork("e")} AND ${liveProject("e", "project_id")}
|
|
93
|
+
ORDER BY e.waiting_since, e.rowid`)
|
|
94
|
+
.all(at)
|
|
95
|
+
.map((value) => dueRow.parse(value));
|
|
96
|
+
return due.map((row) => {
|
|
97
|
+
db.prepare("UPDATE external_reviews SET state='auto-passed', answered_at=? WHERE id=? AND state='waiting'").run(at, row.id);
|
|
98
|
+
releaseParkedWork(db, row.project_id, row.id);
|
|
99
|
+
return {
|
|
100
|
+
id: row.id,
|
|
101
|
+
projectId: row.project_id,
|
|
102
|
+
revisionId: row.revision_id,
|
|
103
|
+
workId: row.work_id,
|
|
104
|
+
stage: row.stage,
|
|
105
|
+
itemKey: row.item_key,
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { actors } from "../agents/model.js";
|
|
3
|
+
import { externalReviewStates } from "./external-model.js";
|
|
4
|
+
import { reviewStages } from "./model.js";
|
|
5
|
+
const materialSchema = z.object({
|
|
6
|
+
request: z.object({
|
|
7
|
+
stage: z.enum(reviewStages),
|
|
8
|
+
instruction: z.string(),
|
|
9
|
+
sections: z.array(z.object({ label: z.string(), text: z.string() })),
|
|
10
|
+
images: z.array(z.string()),
|
|
11
|
+
}),
|
|
12
|
+
images: z.array(z.object({ stored: z.string(), name: z.string() })),
|
|
13
|
+
});
|
|
14
|
+
const verdictSchema = z.object({ passed: z.boolean(), reasons: z.array(z.string()) });
|
|
15
|
+
const rowSchema = z.object({
|
|
16
|
+
id: z.string(),
|
|
17
|
+
project_id: z.string(),
|
|
18
|
+
revision_id: z.string(),
|
|
19
|
+
work_id: z.string(),
|
|
20
|
+
piece_id: z.string(),
|
|
21
|
+
work_key: z.string(),
|
|
22
|
+
item_key: z.string(),
|
|
23
|
+
stage: z.enum(reviewStages),
|
|
24
|
+
item_fingerprint: z.string(),
|
|
25
|
+
review_fingerprint: z.string(),
|
|
26
|
+
material: z.string(),
|
|
27
|
+
state: z.enum(externalReviewStates),
|
|
28
|
+
waiting_since: z.string(),
|
|
29
|
+
deadline_at: z.string(),
|
|
30
|
+
verdict: z.string().nullable(),
|
|
31
|
+
answered_by: z.enum(actors).nullable(),
|
|
32
|
+
answered_at: z.string().nullable(),
|
|
33
|
+
});
|
|
34
|
+
// One stored review as the slice speaks of it; throws for a row that can't be read back.
|
|
35
|
+
export function externalReviewOf(value) {
|
|
36
|
+
const row = rowSchema.parse(value);
|
|
37
|
+
return {
|
|
38
|
+
id: row.id,
|
|
39
|
+
projectId: row.project_id,
|
|
40
|
+
revisionId: row.revision_id,
|
|
41
|
+
workId: row.work_id,
|
|
42
|
+
pieceId: row.piece_id,
|
|
43
|
+
workKey: row.work_key,
|
|
44
|
+
itemKey: row.item_key,
|
|
45
|
+
stage: row.stage,
|
|
46
|
+
itemFingerprint: row.item_fingerprint,
|
|
47
|
+
reviewFingerprint: row.review_fingerprint,
|
|
48
|
+
material: materialSchema.parse(JSON.parse(row.material)),
|
|
49
|
+
state: row.state,
|
|
50
|
+
waitingSince: row.waiting_since,
|
|
51
|
+
deadlineAt: row.deadline_at,
|
|
52
|
+
verdict: row.verdict === null ? null : verdictSchema.parse(JSON.parse(row.verdict)),
|
|
53
|
+
answeredBy: row.answered_by,
|
|
54
|
+
answeredAt: row.answered_at,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function externalReviewByFingerprint(db, projectId, reviewFingerprint) {
|
|
58
|
+
const row = db
|
|
59
|
+
.prepare("SELECT * FROM external_reviews WHERE project_id=? AND review_fingerprint=?")
|
|
60
|
+
.get(projectId, reviewFingerprint);
|
|
61
|
+
return row === undefined ? undefined : externalReviewOf(row);
|
|
62
|
+
}
|
|
63
|
+
export function externalReviewById(db, projectId, id) {
|
|
64
|
+
const row = db
|
|
65
|
+
.prepare("SELECT * FROM external_reviews WHERE project_id=? AND id=?")
|
|
66
|
+
.get(projectId, id);
|
|
67
|
+
return row === undefined ? undefined : externalReviewOf(row);
|
|
68
|
+
}
|