@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.
Files changed (143) hide show
  1. package/dist/edge/http/actions.js +2 -2
  2. package/dist/edge/http/actor.js +10 -0
  3. package/dist/edge/http/app.js +21 -0
  4. package/dist/edge/http/checkpoints.js +14 -6
  5. package/dist/edge/http/external-reviews.js +95 -0
  6. package/dist/edge/http/projects.js +36 -18
  7. package/dist/edge/http/reviews.js +53 -18
  8. package/dist/edge/http/revisions.js +5 -1
  9. package/dist/edge/http/settings.js +9 -1
  10. package/dist/edge/http/starts.js +41 -0
  11. package/dist/edge/http/studio.js +5 -130
  12. package/dist/edge/mcp/attribution.js +38 -0
  13. package/dist/edge/mcp/call-route.js +203 -0
  14. package/dist/edge/mcp/excluded-routes.js +95 -0
  15. package/dist/edge/mcp/file-tools.js +141 -0
  16. package/dist/edge/mcp/http.js +79 -0
  17. package/dist/edge/mcp/instructions.js +17 -0
  18. package/dist/edge/mcp/library-tools.js +143 -0
  19. package/dist/edge/mcp/remake-tools.js +90 -0
  20. package/dist/edge/mcp/review-tools.js +64 -0
  21. package/dist/edge/mcp/route-tool.js +61 -0
  22. package/dist/edge/mcp/server.js +48 -0
  23. package/dist/edge/mcp/state-shapes.js +60 -0
  24. package/dist/edge/mcp/tools-channels.js +153 -0
  25. package/dist/edge/mcp/tools-library.js +151 -0
  26. package/dist/edge/mcp/tools-planning.js +137 -0
  27. package/dist/edge/mcp/tools-projects.js +167 -0
  28. package/dist/edge/mcp/tools-publishing.js +148 -0
  29. package/dist/edge/mcp/tools-runs.js +94 -0
  30. package/dist/edge/mcp/tools-schedules.js +143 -0
  31. package/dist/edge/mcp/tools-setup.js +234 -0
  32. package/dist/edge/mcp/tools-storage.js +99 -0
  33. package/dist/edge/mcp/tools.js +21 -0
  34. package/dist/extension/slopify-studio-chrome.zip +0 -0
  35. package/dist/extension/slopify-studio-firefox.zip +0 -0
  36. package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
  37. package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
  38. package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
  39. package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
  40. package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
  41. package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
  42. package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
  43. package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
  44. package/dist/kernel/pipeline.js +6 -0
  45. package/dist/kernel/runner/graph.js +37 -2
  46. package/dist/kernel/runner/index.js +6 -3
  47. package/dist/main.js +29 -13
  48. package/dist/patch-notes/3.17.0.md +10 -0
  49. package/dist/patch-notes/3.18.0.md +13 -0
  50. package/dist/patch-notes/index.json +12 -0
  51. package/dist/slices/admission/admitted-work.js +53 -0
  52. package/dist/slices/admission/heads.js +5 -0
  53. package/dist/slices/admission/repo.js +47 -29
  54. package/dist/slices/admission/schema.js +1 -0
  55. package/dist/slices/admission/stage-writes.js +21 -0
  56. package/dist/slices/admission/start.js +2 -1
  57. package/dist/slices/admission/state-words.js +110 -0
  58. package/dist/slices/agents/attention-work.js +233 -0
  59. package/dist/slices/agents/attention.js +168 -0
  60. package/dist/slices/agents/connection.js +15 -0
  61. package/dist/slices/agents/model.js +29 -0
  62. package/dist/slices/agents/repo.js +66 -0
  63. package/dist/slices/cancel/index.js +4 -1
  64. package/dist/slices/control/index.js +2 -1
  65. package/dist/slices/episodes/summarize.js +2 -1
  66. package/dist/slices/estimate/index.js +6 -2
  67. package/dist/slices/images/run.js +2 -1
  68. package/dist/slices/library/transfer.js +69 -0
  69. package/dist/slices/narration/run.js +2 -1
  70. package/dist/slices/notifications/address.js +40 -0
  71. package/dist/slices/notifications/notifier.js +43 -10
  72. package/dist/slices/notifications/rules.js +111 -45
  73. package/dist/slices/notifications/server.js +14 -0
  74. package/dist/slices/notifications/settings.js +1 -1
  75. package/dist/slices/notifications/subject.js +30 -0
  76. package/dist/slices/notifications/told.js +33 -0
  77. package/dist/slices/play-drafts/readiness.js +9 -2
  78. package/dist/slices/play-drafts/schema.js +2 -1
  79. package/dist/slices/rebuild/narration-words.js +76 -0
  80. package/dist/slices/rebuild/recipe-model.js +1 -0
  81. package/dist/slices/rebuild/recipe-reviews.js +44 -23
  82. package/dist/slices/rebuild/recovery-repo.js +0 -20
  83. package/dist/slices/rebuild/recovery-selection.js +14 -3
  84. package/dist/slices/rebuild/recovery.js +5 -2
  85. package/dist/slices/rebuild/remake-outdated.js +27 -0
  86. package/dist/slices/rebuild/repo.js +7 -2
  87. package/dist/slices/rebuild/review-redo.js +12 -0
  88. package/dist/slices/rebuild/runtime-agent-review.js +121 -0
  89. package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
  90. package/dist/slices/rebuild/runtime-review.js +12 -2
  91. package/dist/slices/rebuild/runtime-run.js +11 -2
  92. package/dist/slices/rebuild/runtime-store.js +32 -5
  93. package/dist/slices/reruns/index.js +2 -1
  94. package/dist/slices/research/run.js +2 -1
  95. package/dist/slices/reviews/external-model.js +21 -0
  96. package/dist/slices/reviews/external-release.js +38 -0
  97. package/dist/slices/reviews/external-repo.js +109 -0
  98. package/dist/slices/reviews/external-rows.js +68 -0
  99. package/dist/slices/reviews/external-waiting.js +75 -0
  100. package/dist/slices/reviews/holds.js +144 -0
  101. package/dist/slices/reviews/model.js +14 -0
  102. package/dist/slices/reviews/repo.js +27 -10
  103. package/dist/slices/reviews/rules.js +5 -3
  104. package/dist/slices/reviews/verdict.js +18 -8
  105. package/dist/slices/revisions/repo.js +3 -0
  106. package/dist/slices/revisions/schema.js +1 -0
  107. package/dist/slices/schedules/agenda.js +47 -17
  108. package/dist/slices/schedules/prepare.js +6 -0
  109. package/dist/slices/schedules/schema.js +13 -1
  110. package/dist/slices/storage/consequences.js +27 -0
  111. package/dist/slices/storage/portable.js +3 -0
  112. package/dist/slices/storage/project-files.js +66 -0
  113. package/dist/slices/studio/settings.js +0 -35
  114. package/dist/slices/studio/stats.js +2 -34
  115. package/dist/slices/subtitles/prepare.js +2 -1
  116. package/dist/slices/uploads/repo.js +12 -6
  117. package/dist/slices/uploads/rules.js +5 -0
  118. package/dist/slices/video/write-export.js +1 -1
  119. package/dist/slices/video/write-subtitles.js +1 -1
  120. package/dist/tutorials/Agents-MCP.md +168 -0
  121. package/dist/tutorials/Calendar.md +2 -1
  122. package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
  123. package/dist/tutorials/Home-and-Projects.md +16 -9
  124. package/dist/tutorials/Home.md +1 -0
  125. package/dist/tutorials/Notifications.md +25 -6
  126. package/dist/tutorials/Project-Page.md +8 -2
  127. package/dist/tutorials/Publishing-to-YouTube.md +1 -0
  128. package/dist/tutorials/Recovery-and-Retries.md +17 -3
  129. package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
  130. package/dist/tutorials/Schedules.md +1 -1
  131. package/dist/tutorials/Settings-Reference.md +15 -0
  132. package/dist/tutorials/Troubleshooting.md +3 -1
  133. package/dist/tutorials/Your-First-Short.md +1 -0
  134. package/dist/tutorials/_Sidebar.md +1 -0
  135. package/dist/web/assets/{index-VbpUYLAp.css → index-BKbZIxAq.css} +1 -1
  136. package/dist/web/assets/index-Drth_MfQ.js +325 -0
  137. package/dist/web/assets/{pdf-C6KhxeKQ.js → pdf-CQFUyijy.js} +1 -1
  138. package/dist/web/index.html +2 -2
  139. package/package.json +1 -1
  140. package/dist/slices/studio/performance.js +0 -83
  141. package/dist/slices/studio/report-stats.js +0 -39
  142. package/dist/slices/studio/report.js +0 -172
  143. 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, context.work.projectId, row.output.path);
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 audioTimeline({
67
+ return narrationTimeline(config, {
61
68
  body,
62
- intro: config.sources.audio === "generate" && config.intro !== undefined
63
- ? await input("audio_intro")
64
- : undefined,
65
- outro: config.sources.audio === "generate" && config.outro !== undefined
66
- ? await input("audio_outro")
67
- : undefined,
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
- : [{ path: path(reference), name: "the establishing image it must match" }]),
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
- ? await executeReviewRecipe(deps, context, providers, piece)
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
- return stageKinds.map((kind) => {
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 ? { kind, state, retryAt } : { kind, state };
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("UPDATE stages SET state=?,failure_reason=?,failure_kind=?,retry_at=?,progress_current=?,progress_total=?,finished_at=CASE WHEN ?='done' THEN ? ELSE NULL END WHERE project_id=? AND kind=?")
231
- .run(stage.state, textOf(failure?.failure_reason), textOf(failure?.failure_kind), stage.retryAt ?? null, invocations.length === 0 ? null : current, invocations.length === 0 ? null : invocations.length, stage.state, deps.clock.now().toISOString(), projectId, stage.kind);
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, resetStage, stagesOf } from "../admission/repo.js";
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, setStageProgress } from "../admission/repo.js";
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
+ }