@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,75 @@
1
+ import { z } from "zod";
2
+ import { liveProject, oneProject, projectTrashed } from "../admission/repo.js";
3
+ import { externalReviewOf } from "./external-rows.js";
4
+ // A review the project still wants: its step is reserved by the project's current version with
5
+ // the same fingerprint. A rerun or an edit that replaced the item leaves it unwanted.
6
+ export function wantedBy(table) {
7
+ return `EXISTS (SELECT 1 FROM revision_work_reservations r
8
+ JOIN project_heads h ON h.project_id=r.project_id AND h.revision_id=r.revision_id
9
+ WHERE r.project_id=${table}.project_id AND r.work_id=${table}.work_id
10
+ AND r.work_key=${table}.work_key AND r.fingerprint=${table}.review_fingerprint)`;
11
+ }
12
+ // Nothing of a canceled run goes ahead on its own: the review's work, or its own step, is
13
+ // canceled. Another step canceled is not enough: retrying one step (`recoverProject`) admits
14
+ // it again while the rest stay canceled, and its reviews wait for their verdict.
15
+ export function liveWork(table) {
16
+ return `EXISTS (SELECT 1 FROM revision_work w WHERE w.id=${table}.work_id AND NOT ${canceledStep("w")})`;
17
+ }
18
+ // The step reads canceled by its stage too, whatever the work row says: a restart before
19
+ // canceled work was kept canceled (`recoverWork`) held it pending.
20
+ function canceledStep(work) {
21
+ return `(${work}.state='canceled' OR EXISTS (SELECT 1 FROM stages s WHERE s.id=${work}.stage_id AND s.state='canceled'))`;
22
+ }
23
+ // Where a review stands, in the one order get_external_review and a refused verdict both say
24
+ // it: a verdict already kept, its run canceled (Cancel closed it, or its step reads canceled;
25
+ // no new one waits until the step is taken up and asks again, unless its item was made again
26
+ // and the new review waits: superseded), its item replaced or its step gone (`superseded`, or
27
+ // `replaced-pending` while no new one waits yet), then its project in the Trash - said last,
28
+ // as only then would Restore bring it back. It waits only when none holds, exactly when
29
+ // `stillWaiting` lists it.
30
+ export function reviewStanding(db, review) {
31
+ if (review.state === "answered" || review.state === "auto-passed")
32
+ return review.state;
33
+ if (review.state === "canceled" ||
34
+ db
35
+ .prepare(`SELECT 1 FROM revision_work w WHERE w.id=? AND ${canceledStep("w")}`)
36
+ .get(review.workId) !== undefined)
37
+ return replacementWaits(db, review) ? "superseded" : "canceled";
38
+ const wanted = review.state === "waiting" &&
39
+ db
40
+ .prepare(`SELECT 1 FROM external_reviews e WHERE e.project_id=? AND e.id=? AND ${wantedBy("e")} AND ${liveWork("e")}`)
41
+ .get(review.projectId, review.id) !== undefined;
42
+ if (!wanted)
43
+ return replacementWaits(db, review) ? "superseded" : "replaced-pending";
44
+ return projectTrashed(db, review.projectId) ? "trashed" : "waiting";
45
+ }
46
+ // Whether another review of the same item waits in its place, the project in the Trash or not:
47
+ // either way the old one was replaced.
48
+ function replacementWaits(db, review) {
49
+ return (db
50
+ .prepare(`SELECT 1 FROM external_reviews e WHERE ${wantedAndLive} AND e.project_id=? AND e.item_key=? AND e.id!=?`)
51
+ .get(review.projectId, review.itemKey, review.id) !== undefined);
52
+ }
53
+ // A review `e` marked waiting, still wanted and of a run that isn't canceled.
54
+ const wantedAndLive = `e.state='waiting' AND ${wantedBy("e")} AND ${liveWork("e")}`;
55
+ // A review `e` that waits: the same, of a project not in the Trash.
56
+ const stillWaiting = `${wantedAndLive} AND ${liveProject("e", "project_id")}`;
57
+ // Waiting, still wanted by the current version, of a run that isn't canceled, oldest first.
58
+ export function waitingExternalReviews(db, projectId) {
59
+ return waitingExternalReviewRows(db, projectId).map(externalReviewOf);
60
+ }
61
+ // The same rows unread, for a caller that skips one it can't read instead of failing them all.
62
+ export function waitingExternalReviewRows(db, projectId) {
63
+ const one = oneProject("e", projectId);
64
+ return db
65
+ .prepare(`SELECT e.* FROM external_reviews e WHERE ${stillWaiting}${one.sql} ORDER BY e.waiting_since, e.rowid`)
66
+ .all(...one.params);
67
+ }
68
+ // The projects with a review waiting for an agent, as `waitingExternalReviewRows` finds them.
69
+ export function projectsWaitingOnAgents(db, projectId) {
70
+ const one = oneProject("e", projectId);
71
+ const rows = db
72
+ .prepare(`SELECT DISTINCT e.project_id FROM external_reviews e WHERE ${stillWaiting}${one.sql}`)
73
+ .all(...one.params);
74
+ return new Set(rows.map((row) => z.string().parse(row.project_id)));
75
+ }
@@ -0,0 +1,144 @@
1
+ import { z } from "zod";
2
+ import { derive, satisfied, deps as stageDeps, } from "../../kernel/runner/graph.js";
3
+ import { pendingAdmitted } from "../admission/admitted-work.js";
4
+ import { liveProject, oneProject, stageStandingsByProject } from "../admission/repo.js";
5
+ import { checkpointStageSchema } from "../checkpoints/schema.js";
6
+ import { heldForItsDay } from "../schedules/prepare.js";
7
+ import { uploadedAndSettled } from "../uploads/repo.js";
8
+ import { projectsWaitingOnAgents } from "./external-waiting.js";
9
+ const gateRow = z.object({
10
+ project_id: z.string(),
11
+ checkpoint_id: z.string(),
12
+ stage: checkpointStageSchema,
13
+ revision_id: z.string(),
14
+ fingerprint: z.string(),
15
+ created_at: z.string(),
16
+ own: z.number(),
17
+ });
18
+ const projectRow = z.object({ project_id: z.string() });
19
+ // Every project's holds, or one project's alone when `projectId` is given. A caller that has
20
+ // already read the stage standings (`stageStandingsByProject`) or the uploaded projects that
21
+ // read Done (`uploadedAndSettled`) passes them; one that lists what it can read passes `skip`,
22
+ // given each checkpoint row that can't be read instead of failing them all.
23
+ export function projectHolds(db, scope = {}) {
24
+ const { projectId, skip } = scope;
25
+ const gatesOf = oneProject("c", projectId);
26
+ const gateSql = `SELECT c.project_id, c.checkpoint_id, c.stage, c.revision_id, c.fingerprint, c.created_at, (c.stage='video' AND ${heldForItsDay("c")}) AS own
27
+ FROM review_checkpoints c
28
+ JOIN project_heads h ON h.project_id=c.project_id AND h.revision_id=c.revision_id
29
+ WHERE c.state IN ('held','pending-review') AND c.approved_at IS NULL AND ${liveProject("c", "project_id")}${gatesOf.sql}`;
30
+ const gates = db
31
+ .prepare(gateSql)
32
+ .all(...gatesOf.params)
33
+ .flatMap((row) => {
34
+ if (skip === undefined)
35
+ return [gateRow.parse(row)];
36
+ const gate = gateRow.safeParse(row);
37
+ if (gate.success)
38
+ return [gate.data];
39
+ skip(row, gate.error);
40
+ return [];
41
+ });
42
+ const standings = scope.standings ?? stageStandingsByProject(db, projectId);
43
+ // A checkpoint holds the run once the stages its step needs are made; before that the run
44
+ // is held by whatever those stages wait on.
45
+ const reached = (project, stage) => stageDeps[stage].every((dependency) => {
46
+ const standing = standings.get(project)?.find((made) => made.kind === dependency);
47
+ return standing === undefined || satisfied(standing.state);
48
+ });
49
+ const theirs = new Set();
50
+ const reachedTheirs = new Set();
51
+ const reachedOwn = new Set();
52
+ const holding = new Map();
53
+ for (const gate of gates) {
54
+ const own = gate.own === 1;
55
+ if (!own)
56
+ theirs.add(gate.project_id);
57
+ if (!reached(gate.project_id, gate.stage))
58
+ continue;
59
+ (own ? reachedOwn : reachedTheirs).add(gate.project_id);
60
+ const hold = {
61
+ checkpointId: gate.checkpoint_id,
62
+ stage: gate.stage,
63
+ waitsFor: own ? "day" : "person",
64
+ revisionId: gate.revision_id,
65
+ fingerprint: gate.fingerprint,
66
+ since: gate.created_at,
67
+ };
68
+ holding.set(gate.project_id, [...(holding.get(gate.project_id) ?? []), hold]);
69
+ }
70
+ const headsOf = oneProject("h", projectId);
71
+ const scheduled = db
72
+ .prepare(`SELECT h.project_id FROM project_heads h WHERE ${heldForItsDay("h")}${headsOf.sql}`)
73
+ .all(...headsOf.params)
74
+ .map((row) => projectRow.parse(row).project_id);
75
+ const agents = projectsWaitingOnAgents(db, projectId);
76
+ const runs = heldRuns(db, projectId);
77
+ // An uploaded project with nothing admitted reads Done (`edge/http/projects.ts`).
78
+ const settled = scope.settled ?? uploadedAndSettled(db, projectId);
79
+ const waitingForAgent = new Set([...agents].filter((id) => !reachedTheirs.has(id) && runs.get(id)?.held !== true));
80
+ const stopped = new Set([...runs]
81
+ .filter(([, run]) => run.stuck)
82
+ .map(([id]) => id)
83
+ .filter((id) => !reachedTheirs.has(id) &&
84
+ !waitingForAgent.has(id) &&
85
+ // Held at its day's hold, it goes on by itself on its day, though a failed step's
86
+ // admission holds its video's step back until then; a step a restart held still
87
+ // waits for Continue the run.
88
+ (!reachedOwn.has(id) || runs.get(id)?.held === true) &&
89
+ !settled.has(id) &&
90
+ derive(standings.get(id) ?? []) === "pending"));
91
+ return {
92
+ prepared: new Set(scheduled.filter((id) => !theirs.has(id) && !stopped.has(id))),
93
+ waitingForAgent,
94
+ stopped,
95
+ // Only Continue the run moves a stopped run on: the one hold it can have reached, its
96
+ // day's, holds it again once it has.
97
+ holding: new Map([...holding].filter(([id]) => !stopped.has(id))),
98
+ };
99
+ }
100
+ // An admitted step of `h`'s project still to run (`pendingAdmitted`), held other than one
101
+ // parked on an agent's review: a restart (`recoverWork`) or a pause held it, and only Continue
102
+ // the run starts it.
103
+ function heldBesideReviews(h) {
104
+ return `EXISTS (SELECT 1 FROM revision_work w
105
+ WHERE w.project_id=${h}.project_id AND w.dispatch_state='held' AND ${pendingAdmitted("w")}
106
+ AND NOT EXISTS (SELECT 1 FROM external_reviews e WHERE e.work_id=w.id AND e.state='waiting'))`;
107
+ }
108
+ const runRow = z.object({ project_id: z.string(), held: z.number(), stuck: z.number() });
109
+ // Whether `h` is still: no step's call in flight, not paused and not waiting its turn in a
110
+ // batch. A running stage needs no clause here: it makes the run's state running (`derive`).
111
+ function still(h) {
112
+ return `NOT EXISTS(SELECT 1 FROM revision_work w WHERE w.project_id=${h}.project_id AND w.state='running')
113
+ AND NOT EXISTS(SELECT 1 FROM project_controls c WHERE c.project_id=${h}.project_id AND c.paused=1)
114
+ AND NOT EXISTS(SELECT 1 FROM project_queue q WHERE q.project_id=${h}.project_id AND q.state='queued')`;
115
+ }
116
+ // No admitted work of `h`'s current version that can start: a saved rerun or edit is admitted
117
+ // by no one until Resume, and work whose admission has a failed sibling never starts.
118
+ function nothingAdmitted(h) {
119
+ return `NOT EXISTS(SELECT 1 FROM revision_work w
120
+ JOIN revision_work_reservations r ON r.work_id=w.id AND r.revision_id=${h}.revision_id
121
+ WHERE w.state='pending' AND w.dispatch_state='allowed' AND NOT EXISTS(
122
+ SELECT 1 FROM revision_work f WHERE f.admission_id=w.admission_id AND f.state IN ('failed','canceled')))`;
123
+ }
124
+ // Each project out of the Trash with a step held beside the reviews (`held`), and whether its
125
+ // run is stuck: it has unfinished stages, is still, and nothing will move it on but Continue
126
+ // the run, because nothing admitted can start or because a step is held (a step still allowed
127
+ // may wait on it). Each reading is made once per project (MATERIALIZED), not once per use.
128
+ function heldRuns(db, projectId) {
129
+ const one = oneProject("h", projectId);
130
+ const rows = db
131
+ .prepare(`WITH runs AS MATERIALIZED (
132
+ SELECT h.project_id, ${heldBesideReviews("h")} AS held,
133
+ EXISTS(SELECT 1 FROM stages s WHERE s.project_id=h.project_id AND s.state NOT IN ('done','provided','skipped'))
134
+ AND ${still("h")} AS idle,
135
+ ${nothingAdmitted("h")} AS unadmitted
136
+ FROM project_heads h WHERE ${liveProject("h", "project_id")}${one.sql})
137
+ SELECT project_id, held, idle AND (held OR unadmitted) AS stuck FROM runs
138
+ WHERE held OR (idle AND unadmitted)`)
139
+ .all(...one.params);
140
+ return new Map(rows.map((row) => {
141
+ const run = runRow.parse(row);
142
+ return [run.project_id, { held: run.held === 1, stuck: run.stuck === 1 }];
143
+ }));
144
+ }
@@ -6,6 +6,15 @@ export const reviewStages = ["article", "images", "narration", "thumbnail", "sho
6
6
  // Off is the same as the stage having no entry, which is what every project saved before
7
7
  // reviews existed has.
8
8
  export const reviewModes = ["off", "flag", "redo"];
9
+ // Who answers the reviews: a text model Slopify calls, or an external agent through MCP.
10
+ export const reviewers = ["model", "agent"];
11
+ // Who gave a verdict: the reviewer model, an external agent, the person (through the verdict
12
+ // route), or nobody when the review passed at its deadline.
13
+ export const reviewAnswerers = ["model", "agent", "person", "nobody"];
14
+ export const reviewerLabels = {
15
+ model: "Model",
16
+ agent: "External agent (MCP)",
17
+ };
9
18
  // ceiling: the owner's numbers. Two redos is what a person would try before looking at it
10
19
  // themselves; five is already six paid images for one slot.
11
20
  export const defaultReviewRetries = 2;
@@ -35,6 +44,9 @@ export function reviewModeOf(settings, stage) {
35
44
  export function reviewRetriesOf(settings) {
36
45
  return settings?.retries ?? defaultReviewRetries;
37
46
  }
47
+ export function reviewedByAgent(settings) {
48
+ return settings?.reviewer === "agent";
49
+ }
38
50
  export function reviewedStages(settings) {
39
51
  return reviewStages.filter((stage) => reviewModeOf(settings, stage) !== "off");
40
52
  }
@@ -56,6 +68,7 @@ export function reviewSettingsForm(settings) {
56
68
  provider: settings.provider,
57
69
  model: settings.model,
58
70
  ...(settings.thinking === undefined ? {} : { thinking: settings.thinking }),
71
+ ...(settings.reviewer === "agent" ? { reviewer: "agent" } : {}),
59
72
  retries: settings.retries === undefined ? "" : String(settings.retries),
60
73
  stages: Object.fromEntries(reviewStages.flatMap((stage) => {
61
74
  const picked = settings.stages[stage];
@@ -86,6 +99,7 @@ export function reviewSettingsFromForm(form, makes) {
86
99
  provider: form.provider,
87
100
  model: form.model,
88
101
  ...(form.thinking === undefined ? {} : { thinking: form.thinking }),
102
+ ...(form.reviewer === "agent" ? { reviewer: "agent" } : {}),
89
103
  ...(retries === undefined || !valid ? {} : { retries }),
90
104
  stages,
91
105
  },
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { reviewStages } from "./model.js";
2
+ import { actors } from "../agents/model.js";
3
+ import { reviewAnswerers, reviewStages } from "./model.js";
3
4
  const rowSchema = z.object({
4
5
  id: z.string(),
5
6
  project_id: z.string(),
@@ -10,12 +11,14 @@ const rowSchema = z.object({
10
11
  review_fingerprint: z.string(),
11
12
  passed: z.number(),
12
13
  reasons: z.string(),
13
- outcome: z.enum(["passed", "flagged", "redo"]),
14
+ outcome: z.enum(["passed", "flagged", "redo", "auto-passed"]),
14
15
  attempt: z.number().int(),
15
16
  action: z.enum(["overruled", "redone"]).nullable(),
16
17
  action_at: z.string().nullable(),
17
18
  redo_state: z.enum(["pending", "started", "failed"]).nullable(),
18
19
  redo_error: z.string().nullable(),
20
+ answered_by: z.enum(reviewAnswerers),
21
+ action_by: z.enum(actors).nullable(),
19
22
  created_at: z.string(),
20
23
  });
21
24
  function recordOf(value) {
@@ -36,18 +39,21 @@ function recordOf(value) {
36
39
  actionAt: row.action_at,
37
40
  redoState: row.redo_state,
38
41
  redoError: row.redo_error,
42
+ answeredBy: row.answered_by,
43
+ actionBy: row.action_by,
39
44
  createdAt: row.created_at,
40
45
  };
41
46
  }
42
47
  // One verdict per review step: the same review asked again (a retried step whose answer was
43
48
  // saved but not published) replaces the earlier answer rather than adding a second one.
44
49
  export function saveVerdict(db, record) {
45
- db.prepare(`INSERT INTO review_verdicts(id,project_id,revision_id,item_key,stage,item_fingerprint,review_fingerprint,passed,reasons,outcome,attempt,action,action_at,redo_state,redo_error,created_at)
46
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
50
+ db.prepare(`INSERT INTO review_verdicts(id,project_id,revision_id,item_key,stage,item_fingerprint,review_fingerprint,passed,reasons,outcome,attempt,action,action_at,redo_state,redo_error,answered_by,action_by,created_at)
51
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
47
52
  ON CONFLICT(project_id,review_fingerprint) DO UPDATE SET revision_id=excluded.revision_id,
48
53
  item_fingerprint=excluded.item_fingerprint,passed=excluded.passed,reasons=excluded.reasons,
49
- outcome=excluded.outcome,attempt=excluded.attempt,action=NULL,action_at=NULL,
50
- redo_state=excluded.redo_state,redo_error=NULL,created_at=excluded.created_at`).run(record.id, record.projectId, record.revisionId, record.itemKey, record.stage, record.itemFingerprint, record.reviewFingerprint, record.passed ? 1 : 0, JSON.stringify(record.reasons), record.outcome, record.attempt, record.action, record.actionAt, record.redoState, record.redoError, record.createdAt);
54
+ outcome=excluded.outcome,attempt=excluded.attempt,action=NULL,action_at=NULL,action_by=NULL,
55
+ redo_state=excluded.redo_state,redo_error=NULL,answered_by=excluded.answered_by,
56
+ created_at=excluded.created_at`).run(record.id, record.projectId, record.revisionId, record.itemKey, record.stage, record.itemFingerprint, record.reviewFingerprint, record.passed ? 1 : 0, JSON.stringify(record.reasons), record.outcome, record.attempt, record.action, record.actionAt, record.redoState, record.redoError, record.answeredBy, record.actionBy, record.createdAt);
51
57
  const saved = verdictByReview(db, record.projectId, record.reviewFingerprint);
52
58
  if (saved === undefined)
53
59
  throw new Error("Slopify hit an internal error (a review's verdict could not be saved). Try again; if it happens again, use Download diagnostics in Settings and report it.");
@@ -93,10 +99,13 @@ export function setRedoState(db, id, state, error = null) {
93
99
  // A passed verdict has nothing to overrule. Overrule also calls off an automatic redo that is
94
100
  // still waiting to start (what depends on the item then goes ahead with it as it is), but a
95
101
  // redo already under way owns the item until it lands.
96
- export function actOnVerdict(db, projectId, id, action, at) {
102
+ export function actOnVerdict(db, projectId, id, action, at, by = "person") {
97
103
  const record = verdictById(db, projectId, id);
98
104
  if (record === undefined)
99
105
  return { ok: false, reason: "not-found" };
106
+ // The first decision stands: a second person or agent is told it was already made.
107
+ if (record.action !== null)
108
+ return { ok: false, reason: "decided", value: record };
100
109
  if (action === "overruled" && record.passed)
101
110
  return { ok: false, reason: "conflict" };
102
111
  if (record.redoState === "started")
@@ -105,13 +114,21 @@ export function actOnVerdict(db, projectId, id, action, at) {
105
114
  if (action !== "overruled")
106
115
  return { ok: false, reason: "conflict" };
107
116
  const called = db
108
- .prepare("UPDATE review_verdicts SET action=?, action_at=?, redo_state=NULL WHERE id=? AND redo_state='pending'")
109
- .run(action, at, id);
117
+ .prepare("UPDATE review_verdicts SET action=?, action_at=?, action_by=?, redo_state=NULL WHERE id=? AND redo_state='pending' AND action IS NULL")
118
+ .run(action, at, by, id);
110
119
  if (Number(called.changes) !== 1)
111
120
  return { ok: false, reason: "conflict" };
112
121
  }
113
122
  else {
114
- db.prepare("UPDATE review_verdicts SET action=?, action_at=? WHERE id=?").run(action, at, id);
123
+ const changed = db
124
+ .prepare("UPDATE review_verdicts SET action=?, action_at=?, action_by=? WHERE id=? AND action IS NULL")
125
+ .run(action, at, by, id);
126
+ if (Number(changed.changes) !== 1) {
127
+ const now = verdictById(db, projectId, id);
128
+ return now === undefined
129
+ ? { ok: false, reason: "not-found" }
130
+ : { ok: false, reason: "decided", value: now };
131
+ }
115
132
  }
116
133
  const updated = verdictById(db, projectId, id);
117
134
  return updated === undefined ? { ok: false, reason: "not-found" } : { ok: true, value: updated };
@@ -1,4 +1,4 @@
1
- import { imageReviewers, reviewModeOf, reviewRetriesMax, reviewRetriesMin, reviewStageLabels, reviewStages, visionStages, } from "./model.js";
1
+ import { imageReviewers, reviewedByAgent, reviewModeOf, reviewRetriesMax, reviewRetriesMin, reviewStageLabels, reviewStages, visionStages, } from "./model.js";
2
2
  // A stage is reviewed only while it makes something: a provided article, uploaded images or
3
3
  // narration Off leave nothing for a reviewer to look at, so a review left on there asks for
4
4
  // nothing and holds nothing up.
@@ -39,7 +39,8 @@ export function reviewFields(draft) {
39
39
  if (active.length === 0 || reviews === undefined)
40
40
  return [];
41
41
  const fields = [];
42
- if (reviews.provider.trim() === "" || reviews.model.trim() === "")
42
+ const byAgent = reviewedByAgent(reviews);
43
+ if (!byAgent && (reviews.provider.trim() === "" || reviews.model.trim() === ""))
43
44
  fields.push({
44
45
  field: "reviews.provider",
45
46
  message: "Automatic reviews need a reviewer model. Choose one in the Reviews section, or set every review to Off.",
@@ -52,7 +53,8 @@ export function reviewFields(draft) {
52
53
  message: `Enter a number of redos between ${reviewRetriesMin} and ${reviewRetriesMax} in the Reviews section.`,
53
54
  });
54
55
  const pictures = active.filter((stage) => visionStages.includes(stage));
55
- if (pictures.length > 0 &&
56
+ if (!byAgent &&
57
+ pictures.length > 0 &&
56
58
  reviews.provider.trim() !== "" &&
57
59
  !reviewerSeesImages(reviews.provider))
58
60
  fields.push({
@@ -3,15 +3,28 @@ import { z } from "zod";
3
3
  // at least one reason, since a reason is what the person reads beside it.
4
4
  export const reviewReasonMax = 500;
5
5
  export const reviewReasonsMax = 10;
6
- const answerSchema = z
6
+ export const verdictAnswerSchema = z
7
7
  .object({
8
8
  verdict: z.enum(["pass", "fail"]),
9
- reasons: z.array(z.string().trim().min(1).max(reviewReasonMax)).max(reviewReasonsMax),
9
+ reasons: z
10
+ .array(z
11
+ .string()
12
+ .trim()
13
+ .min(1, "a reason can't be empty")
14
+ .max(reviewReasonMax, `a reason is at most ${String(reviewReasonMax)} characters`))
15
+ .max(reviewReasonsMax, `at most ${String(reviewReasonsMax)} reasons`),
10
16
  })
11
17
  .strict()
12
18
  .refine((answer) => answer.verdict === "pass" || answer.reasons.length > 0, {
13
19
  message: "a failed verdict names no reason",
14
20
  });
21
+ // What was wrong with a verdict, where it was: " (reasons.0): a reason can't be empty". The
22
+ // reviewer's refused answer and an agent's refused verdict say it the same way.
23
+ export function verdictIssue(issues, otherwise) {
24
+ const issue = issues[0];
25
+ const where = issue === undefined || issue.path.length === 0 ? "" : ` (${issue.path.map(String).join(".")})`;
26
+ return `${where}: ${issue?.message ?? otherwise}`;
27
+ }
15
28
  const answerRules = 'Answer with one JSON object and nothing else: {"verdict":"pass","reasons":[]} when the item is fine, or {"verdict":"fail","reasons":["..."]} with one short sentence per problem you found (at most 10). Name only real problems you can point to.';
16
29
  export function reviewMessages(material) {
17
30
  const attached = material.images.length === 0
@@ -55,15 +68,12 @@ export function parseVerdict(text) {
55
68
  reason: `The reviewer's answer was not the JSON verdict Slopify asked for. ${retryHint}`,
56
69
  };
57
70
  }
58
- const parsed = answerSchema.safeParse(value);
59
- if (!parsed.success) {
60
- const issue = parsed.error.issues[0];
61
- const where = issue === undefined || issue.path.length === 0 ? "" : ` (${issue.path.join(".")})`;
71
+ const parsed = verdictAnswerSchema.safeParse(value);
72
+ if (!parsed.success)
62
73
  return {
63
74
  ok: false,
64
- reason: `The reviewer's verdict was malformed${where}: ${issue?.message ?? "it did not match the verdict format"}. ${retryHint}`,
75
+ reason: `The reviewer's verdict was malformed${verdictIssue(parsed.error.issues, "it did not match the verdict format")}. ${retryHint}`,
65
76
  };
66
- }
67
77
  return {
68
78
  ok: true,
69
79
  value: { passed: parsed.data.verdict === "pass", reasons: parsed.data.reasons },
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { agentRevisionIds } from "../agents/repo.js";
2
3
  import { projectRevisionSchema } from "./schema.js";
3
4
  export { insertManifestOutput, insertManifestPiece, outputsForRevision, piecesForRevision, selectOutputRecord, selectPieceRecord, } from "./manifest-repo.js";
4
5
  const revisionRow = z.object({
@@ -37,6 +38,7 @@ export function currentRevisionId(db, projectId) {
37
38
  }
38
39
  export function listRevisionHistory(db, projectId) {
39
40
  const head = currentRevisionId(db, projectId);
41
+ const byAgent = agentRevisionIds(db, projectId);
40
42
  return db
41
43
  .prepare("SELECT * FROM project_revisions WHERE project_id=? ORDER BY created_at DESC,id DESC")
42
44
  .all(projectId)
@@ -49,6 +51,7 @@ export function listRevisionHistory(db, projectId) {
49
51
  title: revision.config.title,
50
52
  createdAt: revision.createdAt,
51
53
  current: revision.id === head,
54
+ ...(byAgent.has(revision.id) ? { byAgent: true } : {}),
52
55
  };
53
56
  });
54
57
  }
@@ -220,6 +220,7 @@ export const revisionSummarySchema = z.object({
220
220
  title: z.string(),
221
221
  createdAt: z.string(),
222
222
  current: z.boolean(),
223
+ byAgent: z.boolean().optional(),
223
224
  });
224
225
  export const revisionMutationSuccessSchema = z.object({
225
226
  ok: z.literal(true),
@@ -1,11 +1,14 @@
1
1
  import { z } from "zod";
2
- import { derive } from "../../kernel/runner/graph.js";
2
+ import { derive, progressOf } from "../../kernel/runner/graph.js";
3
3
  import { liveProject, stageStandingsByProject } from "../admission/repo.js";
4
+ import { readsByWaitBesideFailure } from "../admission/state-words.js";
4
5
  import { queueEntries } from "../batch/index.js";
5
6
  import { sampleIds } from "../onboarding/model.js";
6
7
  import { readSampleRecord } from "../onboarding/state.js";
8
+ import { projectHolds } from "../reviews/holds.js";
7
9
  import { limitWaitsByProject, listingWait } from "../run-cost/panel.js";
8
- import { uploadedProjects } from "../uploads/repo.js";
10
+ import { setAsideProjects, uploadedAndSettled, uploadedProjects } from "../uploads/repo.js";
11
+ import { settledStatus } from "../uploads/rules.js";
9
12
  import { nextOccurrence } from "./calendar.js";
10
13
  import { preparedProject } from "./prepare.js";
11
14
  import { scheduleRows } from "./repo.js";
@@ -109,21 +112,22 @@ const projectRow = z.object({
109
112
  finished_at: z.string().nullable(),
110
113
  });
111
114
  // Projects the person has something to decide on in the current revision: a review
112
- // checkpoint holding the run, or an automatic review that failed an item and waits for
113
- // Overrule or Redo (its latest verdict, with no redo under way).
114
- function awaitingReview(db) {
115
+ // checkpoint holding the run for them (`reviews/holds.ts`: reached, and not a Before Video hold
116
+ // its schedule added, which waits for its day), or an automatic review that failed an item and
117
+ // waits for Overrule or Redo (its latest verdict, with no redo under way), as needs_attention
118
+ // reads them.
119
+ function awaitingReview(db, holds) {
120
+ const checkpoints = [...holds.holding]
121
+ .filter(([, held]) => held.some((hold) => hold.waitsFor === "person"))
122
+ .map(([id]) => id);
115
123
  const ids = db
116
- .prepare(`SELECT c.project_id AS id FROM review_checkpoints c
117
- JOIN project_heads h ON h.project_id=c.project_id AND h.revision_id=c.revision_id
118
- WHERE c.state IN ('held','pending-review') AND c.approved_at IS NULL
119
- UNION
120
- SELECT v.project_id AS id FROM review_verdicts v
124
+ .prepare(`SELECT v.project_id AS id FROM review_verdicts v
121
125
  JOIN project_heads h ON h.project_id=v.project_id AND h.revision_id=v.revision_id
122
126
  WHERE v.passed=0 AND v.action IS NULL AND (v.redo_state IS NULL OR v.redo_state='failed')
123
127
  AND NOT EXISTS (SELECT 1 FROM review_verdicts n WHERE n.project_id=v.project_id
124
128
  AND n.item_key=v.item_key AND (n.created_at>v.created_at OR (n.created_at=v.created_at AND n.rowid>v.rowid)))`)
125
129
  .all();
126
- return new Set(ids.map((row) => z.string().parse(row.id)));
130
+ return new Set([...checkpoints, ...ids.map((row) => z.string().parse(row.id))]);
127
131
  }
128
132
  // Whether a finished project's settings make a video: projects stored before sources were
129
133
  // recorded made one.
@@ -137,13 +141,20 @@ function sampleProjectIds(db) {
137
141
  }));
138
142
  }
139
143
  // What a project on the calendar asks of the person, if anything. The same readings Home's
140
- // Needs you uses: a failed or paused run, or one waiting for a review.
141
- function needsOf(state, reviewing) {
144
+ // Needs you uses: a failed run, a paused run, one a restart stopped (Continue the run), or one
145
+ // waiting for a review. A step failed beside a wait reads by that wait and is listed apart
146
+ // (`stepFailed`). One Kept as is asks nothing, nor does an uploaded one that reads Done
147
+ // (`uploadedAndSettled`).
148
+ function needsOf(state, ask) {
142
149
  if (state === "failed")
143
150
  return "failed";
144
151
  if (state === "paused")
145
152
  return "paused";
146
- if (reviewing && state !== "canceled")
153
+ if (ask.aside || ask.settled)
154
+ return undefined;
155
+ if (ask.stopped)
156
+ return "stopped";
157
+ if (ask.reviewing && state !== "canceled")
147
158
  return "review";
148
159
  return undefined;
149
160
  }
@@ -151,10 +162,13 @@ function needsOf(state, reviewing) {
151
162
  // the range, with what each needs from the person and whether its video is ready to upload.
152
163
  function projectsIn(deps, from, to) {
153
164
  const standings = stageStandingsByProject(deps.db);
154
- const reviewing = awaitingReview(deps.db);
165
+ const settled = uploadedAndSettled(deps.db);
166
+ const holds = projectHolds(deps.db, { standings, settled });
167
+ const reviewing = awaitingReview(deps.db, holds);
155
168
  const uploads = uploadedProjects(deps.db);
156
169
  const samples = sampleProjectIds(deps.db);
157
170
  const waits = limitWaitsByProject(deps.db);
171
+ const aside = setAsideProjects(deps.db);
158
172
  const fromSchedule = new Map();
159
173
  for (const row of deps.db
160
174
  .prepare(`SELECT DISTINCT admitted.value AS project_id, schedule_runs.schedule_id
@@ -180,14 +194,22 @@ function projectsIn(deps, from, to) {
180
194
  .all(to.toISOString(), from.toISOString())
181
195
  .flatMap((row) => {
182
196
  const project = projectRow.parse(row);
183
- const state = derive(standings.get(project.id) ?? [], project.paused === 1);
197
+ const state = settledStatus(derive(standings.get(project.id) ?? [], project.paused === 1), settled.has(project.id));
184
198
  const terminal = state === "done" || state === "partial" || state === "failed" || state === "canceled";
185
199
  if (terminal) {
186
200
  const ended = project.finished_at ?? project.created_at;
187
201
  if (ended < from.toISOString() || ended >= to.toISOString())
188
202
  return [];
189
203
  }
190
- const needs = needsOf(state, reviewing.has(project.id));
204
+ // One Kept as is waits for nobody. A run parked on an agent's review holds no checkpoint
205
+ // of the person's, but an item already flagged still waits for their Overrule or Redo.
206
+ const waitingForAgent = holds.waitingForAgent.has(project.id);
207
+ const needs = needsOf(state, {
208
+ stopped: holds.stopped.has(project.id),
209
+ reviewing: reviewing.has(project.id),
210
+ aside: aside.has(project.id),
211
+ settled: settled.has(project.id),
212
+ });
191
213
  const ready = (state === "done" || state === "partial") &&
192
214
  makesVideo(project.video_source) &&
193
215
  !uploads.has(project.id) &&
@@ -201,8 +223,16 @@ function projectsIn(deps, from, to) {
201
223
  createdAt: project.created_at,
202
224
  finishedAt: terminal ? (project.finished_at ?? project.created_at) : null,
203
225
  scheduleId: fromSchedule.get(project.id) ?? null,
226
+ progress: progressOf(standings.get(project.id) ?? []),
204
227
  ...(needs === undefined ? {} : { needs }),
205
228
  ...(ready ? { readyToUpload: true } : {}),
229
+ ...(aside.has(project.id) ? { setAside: true } : {}),
230
+ ...(holds.prepared.has(project.id) ? { prepared: true } : {}),
231
+ ...(waitingForAgent ? { waitingForAgent: true } : {}),
232
+ ...(holds.stopped.has(project.id) ? { stopped: true } : {}),
233
+ ...(readsByWaitBesideFailure(state, standings.get(project.id) ?? [])
234
+ ? { stepFailed: true }
235
+ : {}),
206
236
  ...(waiting === undefined ? {} : { limitWaits: waiting.map(listingWait) }),
207
237
  },
208
238
  ];
@@ -154,3 +154,9 @@ export function preparedTopics(deps, scheduleId) {
154
154
  return found === undefined ? [] : [{ title, topic: topic.title, projectId: found.projectId }];
155
155
  });
156
156
  }
157
+ // A project a schedule prepared ahead whose Before Video hold is the schedule's own: that hold
158
+ // waits for its day, not for anyone (`reviews/holds.ts` reads it with the project's other
159
+ // checkpoints). The day deletes the row (`continuePrepared`).
160
+ export function heldForItsDay(alias) {
161
+ return `EXISTS (SELECT 1 FROM prepared_videos p WHERE p.project_id=${alias}.project_id AND p.added_checkpoint=1)`;
162
+ }
@@ -5,7 +5,7 @@ import { cadenceSchema, validTimeZone } from "./calendar.js";
5
5
  const id = z.uuid();
6
6
  // What a project on the calendar waits on the person for: a failed or paused run, or a review
7
7
  // (a checkpoint holding the run, or an automatic review's failed item).
8
- export const calendarNeeds = ["failed", "paused", "review"];
8
+ export const calendarNeeds = ["failed", "paused", "stopped", "review"];
9
9
  const keywordName = z
10
10
  .string()
11
11
  .min(1)
@@ -242,10 +242,22 @@ export const calendarProjectSchema = z
242
242
  createdAt: z.string(),
243
243
  finishedAt: z.string().nullable(),
244
244
  scheduleId: z.string().nullable(),
245
+ // The run's share done (`progressOf`), which tells a held run from a queued one. Older
246
+ // servers leave it out.
247
+ progress: z.number().min(0).max(1).default(0),
245
248
  // What the project waits on the person for; left out when nothing.
246
249
  needs: z.enum(calendarNeeds).optional(),
247
250
  // Finished, makes a video, not marked uploaded and not a bundled sample.
248
251
  readyToUpload: z.literal(true).optional(),
252
+ // What a held run waits for when it isn't the person (`reviews/holds.ts`), and a run
253
+ // Kept as is, as the Projects list says them.
254
+ setAside: z.literal(true).optional(),
255
+ prepared: z.literal(true).optional(),
256
+ waitingForAgent: z.literal(true).optional(),
257
+ stopped: z.literal(true).optional(),
258
+ // A step failed while the run still reads by a wait (`readsByWaitBesideFailure`): listed as a
259
+ // failed item beside what it waits for, as on Home. Older servers leave it out.
260
+ stepFailed: z.literal(true).optional(),
249
261
  // Stages waiting for a CLI plan's limits to reset.
250
262
  limitWaits: z
251
263
  .array(z