@gentbajko/slopify 3.17.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 (136) 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 -10
  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/mcp/attribution.js +38 -0
  12. package/dist/edge/mcp/call-route.js +203 -0
  13. package/dist/edge/mcp/excluded-routes.js +95 -0
  14. package/dist/edge/mcp/file-tools.js +141 -0
  15. package/dist/edge/mcp/http.js +79 -0
  16. package/dist/edge/mcp/instructions.js +17 -0
  17. package/dist/edge/mcp/library-tools.js +143 -0
  18. package/dist/edge/mcp/remake-tools.js +90 -0
  19. package/dist/edge/mcp/review-tools.js +64 -0
  20. package/dist/edge/mcp/route-tool.js +61 -0
  21. package/dist/edge/mcp/server.js +48 -0
  22. package/dist/edge/mcp/state-shapes.js +60 -0
  23. package/dist/edge/mcp/tools-channels.js +153 -0
  24. package/dist/edge/mcp/tools-library.js +151 -0
  25. package/dist/edge/mcp/tools-planning.js +137 -0
  26. package/dist/edge/mcp/tools-projects.js +167 -0
  27. package/dist/edge/mcp/tools-publishing.js +148 -0
  28. package/dist/edge/mcp/tools-runs.js +94 -0
  29. package/dist/edge/mcp/tools-schedules.js +143 -0
  30. package/dist/edge/mcp/tools-setup.js +234 -0
  31. package/dist/edge/mcp/tools-storage.js +99 -0
  32. package/dist/edge/mcp/tools.js +21 -0
  33. package/dist/extension/slopify-studio-chrome.zip +0 -0
  34. package/dist/extension/slopify-studio-firefox.zip +0 -0
  35. package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
  36. package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
  37. package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
  38. package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
  39. package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
  40. package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
  41. package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
  42. package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
  43. package/dist/kernel/pipeline.js +6 -0
  44. package/dist/kernel/runner/graph.js +37 -2
  45. package/dist/kernel/runner/index.js +6 -3
  46. package/dist/main.js +29 -13
  47. package/dist/patch-notes/3.18.0.md +13 -0
  48. package/dist/patch-notes/index.json +6 -0
  49. package/dist/slices/admission/admitted-work.js +53 -0
  50. package/dist/slices/admission/heads.js +5 -0
  51. package/dist/slices/admission/repo.js +47 -29
  52. package/dist/slices/admission/schema.js +1 -0
  53. package/dist/slices/admission/stage-writes.js +21 -0
  54. package/dist/slices/admission/start.js +2 -1
  55. package/dist/slices/admission/state-words.js +110 -0
  56. package/dist/slices/agents/attention-work.js +233 -0
  57. package/dist/slices/agents/attention.js +168 -0
  58. package/dist/slices/agents/connection.js +15 -0
  59. package/dist/slices/agents/model.js +29 -0
  60. package/dist/slices/agents/repo.js +66 -0
  61. package/dist/slices/cancel/index.js +4 -1
  62. package/dist/slices/control/index.js +2 -1
  63. package/dist/slices/episodes/summarize.js +2 -1
  64. package/dist/slices/estimate/index.js +6 -2
  65. package/dist/slices/images/run.js +2 -1
  66. package/dist/slices/library/transfer.js +69 -0
  67. package/dist/slices/narration/run.js +2 -1
  68. package/dist/slices/notifications/address.js +40 -0
  69. package/dist/slices/notifications/notifier.js +43 -10
  70. package/dist/slices/notifications/rules.js +111 -45
  71. package/dist/slices/notifications/server.js +14 -0
  72. package/dist/slices/notifications/settings.js +1 -1
  73. package/dist/slices/notifications/subject.js +30 -0
  74. package/dist/slices/notifications/told.js +33 -0
  75. package/dist/slices/play-drafts/readiness.js +9 -2
  76. package/dist/slices/play-drafts/schema.js +2 -1
  77. package/dist/slices/rebuild/narration-words.js +76 -0
  78. package/dist/slices/rebuild/recipe-model.js +1 -0
  79. package/dist/slices/rebuild/recipe-reviews.js +44 -23
  80. package/dist/slices/rebuild/recovery-repo.js +0 -20
  81. package/dist/slices/rebuild/recovery-selection.js +14 -3
  82. package/dist/slices/rebuild/recovery.js +5 -2
  83. package/dist/slices/rebuild/remake-outdated.js +27 -0
  84. package/dist/slices/rebuild/repo.js +7 -2
  85. package/dist/slices/rebuild/review-redo.js +12 -0
  86. package/dist/slices/rebuild/runtime-agent-review.js +121 -0
  87. package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
  88. package/dist/slices/rebuild/runtime-review.js +12 -2
  89. package/dist/slices/rebuild/runtime-run.js +11 -2
  90. package/dist/slices/rebuild/runtime-store.js +32 -5
  91. package/dist/slices/reruns/index.js +2 -1
  92. package/dist/slices/research/run.js +2 -1
  93. package/dist/slices/reviews/external-model.js +21 -0
  94. package/dist/slices/reviews/external-release.js +38 -0
  95. package/dist/slices/reviews/external-repo.js +109 -0
  96. package/dist/slices/reviews/external-rows.js +68 -0
  97. package/dist/slices/reviews/external-waiting.js +75 -0
  98. package/dist/slices/reviews/holds.js +144 -0
  99. package/dist/slices/reviews/model.js +14 -0
  100. package/dist/slices/reviews/repo.js +27 -10
  101. package/dist/slices/reviews/rules.js +5 -3
  102. package/dist/slices/reviews/verdict.js +18 -8
  103. package/dist/slices/revisions/repo.js +3 -0
  104. package/dist/slices/revisions/schema.js +1 -0
  105. package/dist/slices/schedules/agenda.js +47 -17
  106. package/dist/slices/schedules/prepare.js +6 -0
  107. package/dist/slices/schedules/schema.js +13 -1
  108. package/dist/slices/storage/consequences.js +27 -0
  109. package/dist/slices/storage/portable.js +3 -0
  110. package/dist/slices/storage/project-files.js +66 -0
  111. package/dist/slices/subtitles/prepare.js +2 -1
  112. package/dist/slices/uploads/repo.js +12 -6
  113. package/dist/slices/uploads/rules.js +5 -0
  114. package/dist/slices/video/write-export.js +1 -1
  115. package/dist/slices/video/write-subtitles.js +1 -1
  116. package/dist/tutorials/Agents-MCP.md +168 -0
  117. package/dist/tutorials/Calendar.md +2 -1
  118. package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
  119. package/dist/tutorials/Home-and-Projects.md +16 -9
  120. package/dist/tutorials/Home.md +1 -0
  121. package/dist/tutorials/Notifications.md +25 -6
  122. package/dist/tutorials/Project-Page.md +8 -2
  123. package/dist/tutorials/Publishing-to-YouTube.md +1 -0
  124. package/dist/tutorials/Recovery-and-Retries.md +17 -3
  125. package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
  126. package/dist/tutorials/Schedules.md +1 -1
  127. package/dist/tutorials/Settings-Reference.md +15 -0
  128. package/dist/tutorials/Troubleshooting.md +3 -1
  129. package/dist/tutorials/Your-First-Short.md +1 -0
  130. package/dist/tutorials/_Sidebar.md +1 -0
  131. package/dist/web/assets/{index-DpdXRssU.css → index-BKbZIxAq.css} +1 -1
  132. package/dist/web/assets/index-Drth_MfQ.js +325 -0
  133. package/dist/web/assets/{pdf-B42WW32R.js → pdf-CQFUyijy.js} +1 -1
  134. package/dist/web/index.html +2 -2
  135. package/package.json +1 -1
  136. package/dist/web/assets/index-CbmBh8V-.js +0 -325
@@ -0,0 +1,20 @@
1
+ -- What an agent changed through MCP. A project's rows are its history ("by agent") and go with
2
+ -- it; a change that names no project (a schedule's topics, the Library, a setting) keeps none,
3
+ -- and Settings → Connections → Agents (MCP) lists it.
4
+ CREATE TABLE agent_actions (
5
+ id TEXT PRIMARY KEY,
6
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
7
+ schedule_id TEXT,
8
+ tool TEXT NOT NULL,
9
+ title TEXT NOT NULL,
10
+ revision_id TEXT,
11
+ created_at TEXT NOT NULL
12
+ );
13
+ CREATE INDEX agent_actions_project ON agent_actions(project_id, created_at);
14
+ CREATE INDEX agent_actions_time ON agent_actions(created_at);
15
+ -- Who last started a project's work, the person or an agent: the run's notices say so.
16
+ CREATE TABLE project_starts (
17
+ project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
18
+ actor TEXT NOT NULL CHECK(actor IN ('person','agent')),
19
+ started_at TEXT NOT NULL
20
+ );
@@ -0,0 +1,5 @@
1
+ -- An agent's call sent again with the same idempotencyKey (a retry after a lost answer) gets its
2
+ -- first answer back: it is one change, so it is recorded once.
3
+ ALTER TABLE agent_actions ADD COLUMN idempotency_key TEXT;
4
+ CREATE UNIQUE INDEX agent_actions_once ON agent_actions(tool, COALESCE(project_id, ''), idempotency_key)
5
+ WHERE idempotency_key IS NOT NULL;
@@ -0,0 +1,3 @@
1
+ -- Whether an admission has a failed sibling is asked for every idle project on each list,
2
+ -- calendar and needs_attention read (`reviews/holds.ts` nothingAdmitted): an index, not a scan.
3
+ CREATE INDEX revision_work_admission ON revision_work(admission_id) WHERE admission_id IS NOT NULL;
@@ -0,0 +1,29 @@
1
+ -- Cancel closes a run's waiting reviews as canceled, an outcome of their own, rather than as
2
+ -- replaced: their items weren't made again, and taken up again their steps ask the same review
3
+ -- once more. SQLite cannot change a CHECK in place, so the table is copied, as 0055 did.
4
+ CREATE TABLE external_reviews_canceled_upgrade (
5
+ id TEXT PRIMARY KEY,
6
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
7
+ revision_id TEXT NOT NULL,
8
+ work_id TEXT NOT NULL,
9
+ piece_id TEXT NOT NULL,
10
+ work_key TEXT NOT NULL,
11
+ item_key TEXT NOT NULL,
12
+ stage TEXT NOT NULL CHECK(stage IN ('article','images','narration','thumbnail','shorts')),
13
+ item_fingerprint TEXT NOT NULL,
14
+ review_fingerprint TEXT NOT NULL,
15
+ material TEXT NOT NULL CHECK(json_valid(material)),
16
+ state TEXT NOT NULL CHECK(state IN ('waiting','answered','auto-passed','superseded','canceled')),
17
+ waiting_since TEXT NOT NULL,
18
+ deadline_at TEXT NOT NULL,
19
+ verdict TEXT CHECK(verdict IS NULL OR json_valid(verdict)),
20
+ answered_by TEXT CHECK(answered_by IN ('person','agent')),
21
+ answered_at TEXT,
22
+ UNIQUE(project_id,review_fingerprint)
23
+ );
24
+ INSERT INTO external_reviews_canceled_upgrade (id,project_id,revision_id,work_id,piece_id,work_key,item_key,stage,item_fingerprint,review_fingerprint,material,state,waiting_since,deadline_at,verdict,answered_by,answered_at)
25
+ SELECT id,project_id,revision_id,work_id,piece_id,work_key,item_key,stage,item_fingerprint,review_fingerprint,material,state,waiting_since,deadline_at,verdict,answered_by,answered_at
26
+ FROM external_reviews ORDER BY rowid;
27
+ DROP TABLE external_reviews;
28
+ ALTER TABLE external_reviews_canceled_upgrade RENAME TO external_reviews;
29
+ CREATE INDEX external_reviews_waiting ON external_reviews(state, deadline_at);
@@ -0,0 +1,4 @@
1
+ -- Whether a step is admitted for its project's current version is asked of every stage on each
2
+ -- list, calendar and needs_attention read (`admission/admitted-work.ts`): its reservations are
3
+ -- found by the step, not by a scan of every reservation of every version.
4
+ CREATE INDEX revision_work_reservations_work ON revision_work_reservations(work_id);
@@ -0,0 +1,4 @@
1
+ -- How many times a project was paused, and stopped by a restart, so "While you were away" tells
2
+ -- a pause or a stop that came again from the one it already saw (`web/src/notifications/away.ts`).
3
+ ALTER TABLE project_controls ADD COLUMN pauses INTEGER NOT NULL DEFAULT 0;
4
+ ALTER TABLE project_controls ADD COLUMN stops INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,8 @@
1
+ -- Each failed step the Notification URL was told of (`notifications/rules.ts` runNotices), by its
2
+ -- identity (`kind@finishedAt`), only while its project still has it: a restart reads it back, so
3
+ -- a failure is told once, and one never told is still told when the run ends.
4
+ CREATE TABLE told_failures (
5
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
6
+ failure_id TEXT NOT NULL,
7
+ PRIMARY KEY(project_id, failure_id)
8
+ );
@@ -28,6 +28,12 @@ export const stageStates = [
28
28
  "provided",
29
29
  "skipped",
30
30
  ];
31
+ // A failed step's identity: its step and when it failed, so one that fails again after Try again
32
+ // is another failure (`admission/repo.ts` stepFailuresByProject, `notifications/rules.ts`
33
+ // failuresOf).
34
+ export function failureId(kind, finishedAt) {
35
+ return `${kind}@${finishedAt ?? ""}`;
36
+ }
31
37
  // A persisted pause overrides the stage-derived status. A completed run need not
32
38
  // include a video: all selected stages being satisfied is enough. `partial` is a run whose
33
39
  // main output was made while another step failed (the video is there, its thumbnail is
@@ -38,17 +38,24 @@ export function releases(kind, dependency, state) {
38
38
  const headline = ["video", "audio", "article"];
39
39
  // Stage state is derived; an explicit persisted pause takes precedence while calls
40
40
  // drain. Completed independent images do not imply that a run was canceled. A stage
41
- // waiting out a rate limit is still part of a run in progress.
41
+ // waiting out a rate limit is still part of a run in progress, and so is one taken up again
42
+ // after a cancel: the run reads by what that stage waits on until it is done.
42
43
  export function derive(stages, paused = false) {
43
44
  if (paused)
44
45
  return "paused";
45
46
  if (stages.some((stage) => stage.state === "running" || waiting(stage))) {
46
47
  return "running";
47
48
  }
48
- if (stages.some((stage) => stage.state === "canceled")) {
49
+ if (stages.some((stage) => stage.state === "canceled") &&
50
+ !stages.some((stage) => readmitted(stage, stages))) {
49
51
  return "canceled";
50
52
  }
51
53
  if (stages.some((stage) => stage.state === "failed")) {
54
+ // A failed step ends the run only once nothing else can go on: while a step it doesn't
55
+ // depend on waits for the agent or the person, the run reads by that wait (`reviews/holds.ts`
56
+ // says which), the failed step still shown and listed as failed.
57
+ if (waitsForAnswer(stages))
58
+ return "pending";
52
59
  return headlineMade(stages) ? "partial" : "failed";
53
60
  }
54
61
  if (stages.length > 0 && stages.every((stage) => satisfied(stage.state))) {
@@ -57,6 +64,34 @@ export function derive(stages, paused = false) {
57
64
  // Includes a run created but not yet claimed and unfinished work after restart.
58
65
  return "pending";
59
66
  }
67
+ // A stage taken up again after a Cancel, by Retry, Resume or Continue the run: beside a canceled
68
+ // stage, it waits to run with its work admitted again, or that work failed. Cancel cancels every
69
+ // unfinished step, so failed work beside a canceled stage was taken up after it. While one is,
70
+ // the run reads by what it does: going on, or failed.
71
+ export function readmitted(stage, stages) {
72
+ return ((stage.state === "pending" || stage.state === "failed") &&
73
+ stage.admitted === true &&
74
+ stages.some((other) => other.state === "canceled"));
75
+ }
76
+ // Whether a step of the run waits for the agent, the person or its day: parked on a review its
77
+ // own inputs let it reach, so it goes on once answered.
78
+ export function waitsForAnswer(stages) {
79
+ return stages.some((stage) => movesOnWhenAnswered(stage, stages));
80
+ }
81
+ // A stage parked on a review that its own inputs let it reach, as the runner reads them for the
82
+ // project's sources: answered, it goes on.
83
+ function movesOnWhenAnswered(stage, stages) {
84
+ const sourceOf = (kind) => stages.find((other) => other.kind === kind)?.source ?? "generate";
85
+ return (stage.state === "pending" &&
86
+ stage.parked === true &&
87
+ dependenciesOf(stage.kind, {
88
+ thumbnail: sourceOf("thumbnail"),
89
+ video: sourceOf("video"),
90
+ }).every((kind) => {
91
+ const dependency = stages.find((other) => other.kind === kind);
92
+ return dependency === undefined || releases(stage.kind, kind, dependency.state);
93
+ }));
94
+ }
60
95
  function waiting(stage) {
61
96
  return stage.state === "pending" && stage.retryAt !== undefined && stage.retryAt !== null;
62
97
  }
@@ -3,6 +3,9 @@ import { derive, deps as graph, releases } from "./graph.js";
3
3
  import { progressGate } from "./progress.js";
4
4
  import { faultOf } from "./retry-policy.js";
5
5
  const pauseReason = new Error("paused by user");
6
+ // A shutdown stops a stage without the person asking: it concludes pending and held, as a
7
+ // restart leaves it, for Continue the run to start again - never canceled.
8
+ const shutdownReason = new Error("Slopify is stopping.");
6
9
  export function createRunner(deps) {
7
10
  const inflight = new Map();
8
11
  // ceiling: one entry per project ticked since boot, never evicted - a few dozen bytes
@@ -126,8 +129,8 @@ export function createRunner(deps) {
126
129
  if (controller.signal.aborted) {
127
130
  // A late rejection from the aborted call is how a stage learns it was canceled, so
128
131
  // it is not logged as a fault.
129
- const paused = controller.signal.reason === pauseReason;
130
- conclude(stage, paused ? "pending" : "canceled", paused ? null : "canceled by user");
132
+ const held = [pauseReason, shutdownReason].includes(controller.signal.reason);
133
+ conclude(stage, held ? "pending" : "canceled", held ? null : "canceled by user");
131
134
  return;
132
135
  }
133
136
  const fault = faultOf(error);
@@ -256,7 +259,7 @@ export function createRunner(deps) {
256
259
  abortAll: async () => {
257
260
  shuttingDown = true;
258
261
  for (const entry of inflight.values()) {
259
- entry.controller.abort();
262
+ entry.controller.abort(shutdownReason);
260
263
  }
261
264
  await settled();
262
265
  },
package/dist/main.js CHANGED
@@ -41,7 +41,7 @@ import { createProviderQueue } from "./kernel/runner/queue.js";
41
41
  import { standaloneImage, standaloneLlm } from "./kernel/runner/standalone.js";
42
42
  import { readVersion } from "./kernel/version.js";
43
43
  import { modelSources } from "./model-catalog.js";
44
- import { projectById, projectPaused, projectTrashed } from "./slices/admission/repo.js";
44
+ import { projectPaused, projectTrashed } from "./slices/admission/repo.js";
45
45
  import { createBackupService } from "./slices/backups/service.js";
46
46
  import { pumpQueue, queueWaiting } from "./slices/batch/index.js";
47
47
  import { settleInterruptedCastImages } from "./slices/channels/cast-images.js";
@@ -50,10 +50,9 @@ import { checkpointDecisionForWork, recoverCheckpointWork, settleReleasedCheckpo
50
50
  import { createEpisodeMemoryWatcher, } from "./slices/episodes/summarize.js";
51
51
  import { resolveFont } from "./slices/fonts/index.js";
52
52
  import { decodePeaks } from "./slices/narration/peaks.js";
53
- import { createRunNotifier } from "./slices/notifications/notifier.js";
54
- import { projectLink } from "./slices/notifications/rules.js";
53
+ import { projectLink } from "./slices/notifications/address.js";
55
54
  import { createNotificationSender } from "./slices/notifications/send.js";
56
- import { readNotificationUrl } from "./slices/notifications/settings.js";
55
+ import { createServerNotifier } from "./slices/notifications/server.js";
57
56
  import { seedSamples } from "./slices/onboarding/sample.js";
58
57
  import { templateById } from "./slices/project-templates/repo.js";
59
58
  import { createNarrationRetries } from "./slices/rebuild/narration-retry.js";
@@ -61,9 +60,11 @@ import { recoverProject } from "./slices/rebuild/recovery.js";
61
60
  import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
62
61
  import { waitToRetry, wakeRetries } from "./slices/rebuild/retry.js";
63
62
  import { createReviewRedos, reviewHold } from "./slices/rebuild/review-redo.js";
63
+ import { agentReviewHold } from "./slices/rebuild/runtime-agent-review.js";
64
64
  import { materializeAdmittedWork } from "./slices/rebuild/runtime-materialize.js";
65
65
  import { runRevisionInvocation } from "./slices/rebuild/runtime-run.js";
66
66
  import { executionStandings, invocationReady, projectStandings, recordWorkProgress, } from "./slices/rebuild/runtime-store.js";
67
+ import { releaseDueReviews } from "./slices/reviews/external-release.js";
67
68
  import { currentRevisionId } from "./slices/revisions/repo.js";
68
69
  import { createLimitGate, resumeAfterRestart } from "./slices/run-cost/limits.js";
69
70
  import { createStandaloneMeter, createUsageMeter } from "./slices/run-cost/meter.js";
@@ -210,14 +211,7 @@ export async function boot(config, options = {}) {
210
211
  });
211
212
  const eventDb = db;
212
213
  const sendNotification = createNotificationSender(globalThis.fetch);
213
- const notifier = createRunNotifier({
214
- url: () => readNotificationUrl(eventDb),
215
- subject: (projectId) => {
216
- const project = projectById(eventDb, projectId);
217
- return project === undefined
218
- ? undefined
219
- : { title: project.title, makesVideo: project.config.sources.video !== "off" };
220
- },
214
+ const notifier = createServerNotifier(eventDb, {
221
215
  // Read when a notification goes out, by which time the server is listening.
222
216
  link: (projectId) => projectLink(config.host, listeningPort, projectId),
223
217
  send: sendNotification,
@@ -527,6 +521,7 @@ export async function boot(config, options = {}) {
527
521
  ...(dockerState === undefined ? {} : { installationPending: () => updater.locked() }),
528
522
  db,
529
523
  paths,
524
+ host: config.host,
530
525
  hub,
531
526
  runner,
532
527
  updater,
@@ -569,6 +564,23 @@ export async function boot(config, options = {}) {
569
564
  ...(hostCli === undefined ? { cliLogin: readHostLogin } : {}),
570
565
  });
571
566
  const server = await listen(app, config, log);
567
+ // An agent's review past its two hours passes without review; the deadline is a row, so
568
+ // the ones a restart left waiting are settled here first.
569
+ const releaseReviews = () => {
570
+ try {
571
+ releaseDueReviews({
572
+ db: updateDb,
573
+ now: () => clock.now(),
574
+ emit: hub.emit,
575
+ tick: runner.tick,
576
+ log,
577
+ });
578
+ }
579
+ catch (error) {
580
+ log.write("error", "review.deadline", { detail: causedBy(error) });
581
+ }
582
+ };
583
+ releaseReviews();
572
584
  const queueTimer = setInterval(() => {
573
585
  const release = updater.beginMutation();
574
586
  if (!release)
@@ -594,6 +606,7 @@ export async function boot(config, options = {}) {
594
606
  return;
595
607
  try {
596
608
  wakeRetries(updateDb, clock, runner);
609
+ releaseReviews();
597
610
  }
598
611
  catch (error) {
599
612
  log.write("error", "stage.retry", { detail: causedBy(error) });
@@ -790,7 +803,10 @@ export function wireRunner({ onFinished, audioPreviews, db, paths, clock, ids, l
790
803
  // A review waiting to send its item back holds the item's dependents like a checkpoint.
791
804
  decide: (work) => {
792
805
  const review = reviewHold(execution, work);
793
- return review.kind === "held" ? review : checkpointDecisionForWork(execution, work);
806
+ if (review.kind === "held")
807
+ return review;
808
+ const agent = agentReviewHold(execution, work);
809
+ return agent.kind === "held" ? agent : checkpointDecisionForWork(execution, work);
794
810
  },
795
811
  approve: (projectId, checkpointId, identity) => approveCheckpoint(db, {
796
812
  ...identity,
@@ -0,0 +1,13 @@
1
+ # Slopify 3.18.0
2
+
3
+ Released 11 October 2026.
4
+
5
+ - **AI agents can run Slopify.** A new **Settings → Connections → Agents (MCP)** tab shows the address `http://127.0.0.1:6969/mcp` and the setup line for Claude Code or Codex, with Copy. An agent on this computer can then start, check and fix runs, answer reviews, edit the Library and plan schedules, with the same checks, costs and refusals as the buttons. It needs no key; web pages from other addresses are refused. Agents stop at **Ready to upload** and never publish. What an agent changes is marked **by agent** in the project's history and in notifications, and the tab lists the latest 20 changes. The prompt **attend** walks an agent through everything that needs attention.
6
+ - **External agent reviews.** In a template's Reviews, **Who reviews** can be **External agent (MCP)**. The step then waits for your agent ("Waiting for the agent"), the first verdict wins, and a review nobody answers passes after 2 hours ("Passed without review").
7
+ - **Clearer states.** Every screen uses the same words: Waiting for your review, Waiting for the agent, Prepared, Stopped and Kept as is. A run stopped by a restart reads **Stopped** and is listed under Needs you with **Continue the run**. Projects has one **Running** filter that matches Home's "See all N running".
8
+ - **Fewer, better failure notices.** A failed step is told right away only while something else waits for you, the agent or its day; otherwise you get the end-of-run notice with its chime. A restart no longer repeats a notice or loses one. **While you were away** counts failed steps.
9
+
10
+ ## Upgrading from 3.17.0
11
+
12
+ - **Nothing becomes outdated.** The database updates itself to schema 62 on the first start.
13
+ - Agents are off until you connect one; nothing changes for runs you start yourself.
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "id": "3.18.0",
4
+ "title": "Slopify 3.18.0",
5
+ "version": "3.18.0",
6
+ "date": "2026-10-11"
7
+ },
2
8
  {
3
9
  "id": "3.17.0",
4
10
  "title": "Slopify 3.17.0",
@@ -0,0 +1,53 @@
1
+ // A step `w` a run admitted (`recipe_context`) for its project's current version, of a stage not
2
+ // finished: it still has to run, though a restart, a pause or an agent's review held it, and
3
+ // Continue the run or the agent's answer starts it again. A saved edit's step nobody admitted
4
+ // yet has no `recipe_context`, and a step reserved only by an older version is a leftover.
5
+ export function admittedUnfinished(w) {
6
+ return `${w}.recipe_context IS NOT NULL
7
+ AND EXISTS (SELECT 1 FROM stages s WHERE s.project_id=${w}.project_id AND s.id=${w}.stage_id
8
+ AND s.state NOT IN ('done','provided','skipped'))
9
+ AND EXISTS (SELECT 1 FROM revision_work_reservations r
10
+ JOIN project_heads h ON h.project_id=r.project_id AND h.revision_id=r.revision_id
11
+ WHERE r.work_id=${w}.id)`;
12
+ }
13
+ // Work `w` admitted and waiting to run (`admittedUnfinished`), as Retry, Resume or Continue
14
+ // leave a canceled run's step.
15
+ export function pendingAdmitted(w) {
16
+ return `${w}.state='pending' AND ${admittedUnfinished(w)}`;
17
+ }
18
+ // Whether stage `s` has work admitted (`admittedUnfinished`) in the stage's own state: the
19
+ // fact `derive` reads (`readmitted`) to tell a stage taken up again after a cancel. A finished
20
+ // stage, most of every library, is answered by its own row before any step is looked up.
21
+ export function admittedInItsState(s) {
22
+ return `(${s}.state NOT IN ('done','provided','skipped')
23
+ AND EXISTS (SELECT 1 FROM revision_work w WHERE w.project_id=${s}.project_id AND w.stage_id=${s}.id
24
+ AND w.state=${s}.state AND ${admittedUnfinished("w")}))`;
25
+ }
26
+ // The same fact for a project's work, as `${kind}:${state}` of each step admitted, for a caller
27
+ // that derives stage states from the work itself (`rebuild/runtime-store.ts`).
28
+ export function admittedStates(db, projectId) {
29
+ return new Set(db
30
+ .prepare(`SELECT DISTINCT w.kind, w.state FROM revision_work w WHERE w.project_id=? AND ${admittedUnfinished("w")}`)
31
+ .all(projectId)
32
+ .map((row) => `${String(row.kind)}:${String(row.state)}`));
33
+ }
34
+ // Whether stage `s` is parked on a review someone answers, so it can still move on: work of it
35
+ // waits for an External agent's review, or a checkpoint of the current version on its stage is
36
+ // not approved yet (the person's, or a schedule's own hold for its day). `derive` reads it
37
+ // beside a failed stage.
38
+ export function parkedOnReview(s) {
39
+ return `(EXISTS (SELECT 1 FROM revision_work w JOIN external_reviews e ON e.work_id=w.id
40
+ WHERE w.project_id=${s}.project_id AND w.stage_id=${s}.id AND e.state='waiting'
41
+ AND ${pendingAdmitted("w")})
42
+ OR EXISTS (SELECT 1 FROM review_checkpoints c
43
+ JOIN project_heads h ON h.project_id=c.project_id AND h.revision_id=c.revision_id
44
+ WHERE c.project_id=${s}.project_id AND c.stage=${s}.kind
45
+ AND c.state IN ('held','pending-review') AND c.approved_at IS NULL))`;
46
+ }
47
+ // The same fact for each stage of a project, by kind, for the runner's own standings.
48
+ export function parkedKinds(db, projectId) {
49
+ return new Set(db
50
+ .prepare(`SELECT s.kind FROM stages s WHERE s.project_id=? AND ${parkedOnReview("s")}`)
51
+ .all(projectId)
52
+ .map((row) => String(row.kind)));
53
+ }
@@ -13,6 +13,8 @@ const headRow = z.object({
13
13
  created_at: z.string(),
14
14
  updated_at: z.string(),
15
15
  paused: z.number().default(0),
16
+ pauses: z.number(),
17
+ stops: z.number(),
16
18
  sources: z.string(),
17
19
  article_prompt: z.string().nullable(),
18
20
  book: z.string().nullable(),
@@ -24,6 +26,7 @@ const listingConfigSchema = z.object({
24
26
  });
25
27
  const headSelect = `SELECT projects.id, projects.title, projects.format, projects.created_at,
26
28
  projects.updated_at, COALESCE(project_controls.paused, 0) AS paused,
29
+ COALESCE(project_controls.pauses, 0) AS pauses, COALESCE(project_controls.stops, 0) AS stops,
27
30
  json_extract(projects.config, '$.sources') AS sources,
28
31
  json_extract(projects.config, '$.articlePrompt') AS article_prompt,
29
32
  json_extract(projects.config, '$.voices.book') AS book
@@ -42,6 +45,8 @@ export function listProjectHeads(db) {
42
45
  createdAt: row.created_at,
43
46
  updatedAt: row.updated_at,
44
47
  paused: row.paused === 1,
48
+ ...(row.paused === 1 ? { pauseCount: row.pauses } : {}),
49
+ ...(row.stops > 0 ? { stopCount: row.stops } : {}),
45
50
  };
46
51
  });
47
52
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { stageKinds, stageStates } from "../../kernel/pipeline.js";
2
+ import { failureId, stageKinds, stageStates } from "../../kernel/pipeline.js";
3
+ import { admittedInItsState, parkedOnReview } from "./admitted-work.js";
3
4
  import { formats, stageSources } from "./model.js";
4
5
  import { runConfigSchema } from "./schema.js";
5
6
  export { runConfigSchema, runDraftSchema } from "./schema.js";
@@ -18,6 +19,9 @@ const standingRow = z.object({
18
19
  state: z.enum(stageStates),
19
20
  progress_current: z.number().nullable(),
20
21
  progress_total: z.number().nullable(),
22
+ source: z.enum(stageSources),
23
+ admitted: z.number(),
24
+ parked: z.number(),
21
25
  });
22
26
  const stageRow = z.object({
23
27
  id: z.string(),
@@ -33,21 +37,26 @@ const stageRow = z.object({
33
37
  finished_at: z.string().nullable(),
34
38
  failure_kind: z.string().nullable().optional(),
35
39
  retry_at: z.string().nullable().optional(),
40
+ admitted: z.number(),
41
+ parked: z.number(),
36
42
  });
37
43
  export function insertProject(db, project) {
38
44
  db.prepare("INSERT INTO projects (id, title, format, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(project.id, project.title, project.format, JSON.stringify(project.config), project.createdAt, project.updatedAt);
39
45
  if (project.paused === true)
40
46
  setProjectPaused(db, project.id, true, project.updatedAt);
41
47
  }
42
- export function insertStage(db, stage) {
43
- db.prepare("INSERT INTO stages (id, project_id, kind, source, state, attempt_count) VALUES (?, ?, ?, ?, ?, ?)").run(stage.id, stage.projectId, stage.kind, stage.source, stage.state, stage.attemptCount);
44
- }
45
48
  // A project in the trash (`project_trash`, Settings → Trash) is not found by any of these
46
49
  // reads: it is listed nowhere, opens nowhere and takes no action until it is restored.
47
50
  // `slices/trash` and `slices/storage/delete-project.ts` read the table directly.
48
51
  export function liveProject(alias = "projects", column = "id") {
49
52
  return `NOT EXISTS (SELECT 1 FROM project_trash WHERE project_trash.project_id = ${alias}.${column})`;
50
53
  }
54
+ // The clause and its parameter that limit a query to one project, when `projectId` is given.
55
+ export function oneProject(alias, projectId) {
56
+ return projectId === undefined
57
+ ? { sql: "", params: [] }
58
+ : { sql: ` AND ${alias}.project_id=?`, params: [projectId] };
59
+ }
51
60
  export function projectTrashed(db, id) {
52
61
  return db.prepare("SELECT 1 FROM project_trash WHERE project_id = ?").get(id) !== undefined;
53
62
  }
@@ -76,12 +85,12 @@ export function listProjects(db) {
76
85
  // What 07 Projects needs of every project's stages and no more: the state each one is in
77
86
  // and how far through itself a running one is. One statement for the whole list rather
78
87
  // than `stagesOf` per row, and five columns rather than the whole stage table, because
79
- // the screen shows a status word and a meter and reads nothing else from a stage.
80
- export function stageStandingsByProject(db) {
88
+ // the screen shows a status word and a meter and reads nothing else from a stage. One project's
89
+ // alone when `projectId` is given.
90
+ export function stageStandingsByProject(db, projectId) {
81
91
  const grouped = new Map();
82
- const rows = db
83
- .prepare("SELECT project_id, kind, state, progress_current, progress_total FROM stages ORDER BY rowid")
84
- .all();
92
+ const select = `SELECT project_id, kind, state, progress_current, progress_total, source, ${admittedInItsState("stages")} AS admitted, ${parkedOnReview("stages")} AS parked FROM stages${projectId === undefined ? "" : " WHERE project_id=?"} ORDER BY rowid`;
93
+ const rows = projectId === undefined ? db.prepare(select).all() : db.prepare(select).all(projectId);
85
94
  for (const row of rows) {
86
95
  const parsed = standingRow.parse(row);
87
96
  const standing = {
@@ -89,6 +98,9 @@ export function stageStandingsByProject(db) {
89
98
  state: parsed.state,
90
99
  progressCurrent: parsed.progress_current,
91
100
  progressTotal: parsed.progress_total,
101
+ source: parsed.source,
102
+ ...(parsed.admitted === 1 ? { admitted: true } : {}),
103
+ ...(parsed.parked === 1 ? { parked: true } : {}),
92
104
  };
93
105
  const seen = grouped.get(parsed.project_id);
94
106
  if (seen === undefined) {
@@ -100,30 +112,32 @@ export function stageStandingsByProject(db) {
100
112
  }
101
113
  return grouped;
102
114
  }
115
+ const failedRow = z.object({
116
+ project_id: z.string(),
117
+ kind: z.enum(stageKinds),
118
+ finished_at: z.string().nullable(),
119
+ });
120
+ // Each project's failed steps by their identity (`kernel/pipeline.ts` failureId), in the
121
+ // run's order, so a failure is told apart from one before it.
122
+ export function stepFailuresByProject(db) {
123
+ const failures = new Map();
124
+ for (const row of db
125
+ .prepare("SELECT project_id, kind, finished_at FROM stages WHERE state='failed' ORDER BY rowid")
126
+ .all()) {
127
+ const failed = failedRow.parse(row);
128
+ failures.set(failed.project_id, [
129
+ ...(failures.get(failed.project_id) ?? []),
130
+ failureId(failed.kind, failed.finished_at),
131
+ ]);
132
+ }
133
+ return failures;
134
+ }
103
135
  export function stagesOf(db, projectId) {
104
136
  return db
105
- .prepare("SELECT * FROM stages WHERE project_id = ? ORDER BY rowid")
137
+ .prepare(`SELECT *, ${admittedInItsState("stages")} AS admitted, ${parkedOnReview("stages")} AS parked FROM stages WHERE project_id = ? ORDER BY rowid`)
106
138
  .all(projectId)
107
139
  .map((row) => toStage(stageRow.parse(row)));
108
140
  }
109
- // The runner's claim: one statement, so two callers cannot both find the stage pending.
110
- export function claimStage(db, stageId, at) {
111
- const result = db
112
- .prepare("UPDATE stages SET state = 'running', started_at = ?, finished_at = NULL, failure_reason = NULL WHERE id = ? AND state = 'pending' AND NOT EXISTS (SELECT 1 FROM project_controls WHERE project_id = stages.project_id AND paused = 1)")
113
- .run(at, stageId);
114
- return Number(result.changes) === 1;
115
- }
116
- export function finishStage(db, stageId, state, failureReason, at) {
117
- db.prepare("UPDATE stages SET state = ?, failure_reason = ?, retry_at = NULL, finished_at = ? WHERE id = ?").run(state, failureReason, state === "pending" ? null : at, stageId);
118
- }
119
- // A stage put back to `pending` by a re-run, a cascade or a retry starts again from a clean row
120
- // - no error text, no progress from the run before it, and a fresh attempt budget.
121
- export function resetStage(db, stageId) {
122
- db.prepare("UPDATE stages SET state = 'pending', failure_reason = NULL, failure_kind = NULL, retry_at = NULL, attempt_count = 0, progress_current = NULL, progress_total = NULL, started_at = NULL, finished_at = NULL WHERE id = ?").run(stageId);
123
- }
124
- export function setStageProgress(db, stageId, current, total) {
125
- db.prepare("UPDATE stages SET progress_current = ?, progress_total = ? WHERE id = ?").run(current, total, stageId);
126
- }
127
141
  function toProject(row) {
128
142
  return {
129
143
  id: row.id,
@@ -141,7 +155,9 @@ export function projectPaused(db, projectId) {
141
155
  ?.paused === 1);
142
156
  }
143
157
  export function setProjectPaused(db, projectId, paused, at) {
144
- db.prepare("INSERT INTO project_controls (project_id, paused) VALUES (?, ?) ON CONFLICT(project_id) DO UPDATE SET paused = excluded.paused").run(projectId, paused ? 1 : 0);
158
+ db.prepare(`INSERT INTO project_controls (project_id, paused, pauses) VALUES (?1, ?2, ?2)
159
+ ON CONFLICT(project_id) DO UPDATE SET pauses = pauses + (excluded.paused = 1 AND paused = 0),
160
+ paused = excluded.paused`).run(projectId, paused ? 1 : 0);
145
161
  db.prepare("UPDATE projects SET updated_at = ? WHERE id = ?").run(at, projectId);
146
162
  }
147
163
  export function updateProjectConfig(db, projectId, config, at) {
@@ -171,5 +187,7 @@ function toStage(row) {
171
187
  ? {}
172
188
  : { failureKind: row.failure_kind }),
173
189
  ...(row.retry_at === null || row.retry_at === undefined ? {} : { retryAt: row.retry_at }),
190
+ ...(row.admitted === 1 ? { admitted: true } : {}),
191
+ ...(row.parked === 1 ? { parked: true } : {}),
174
192
  };
175
193
  }
@@ -172,6 +172,7 @@ export const runDraftSchema = z.object({
172
172
  provider: z.string(),
173
173
  model: z.string(),
174
174
  thinking: z.enum(thinkingModes).optional(),
175
+ reviewer: z.literal("agent").optional(),
175
176
  retries: z.number().optional(),
176
177
  stages: z.partialRecord(z.enum(reviewStages), z.object({ mode: z.enum(reviewModes), prompt: z.string().optional() })),
177
178
  })
@@ -0,0 +1,21 @@
1
+ export function insertStage(db, stage) {
2
+ db.prepare("INSERT INTO stages (id, project_id, kind, source, state, attempt_count) VALUES (?, ?, ?, ?, ?, ?)").run(stage.id, stage.projectId, stage.kind, stage.source, stage.state, stage.attemptCount);
3
+ }
4
+ // The runner's claim: one statement, so two callers cannot both find the stage pending.
5
+ export function claimStage(db, stageId, at) {
6
+ const result = db
7
+ .prepare("UPDATE stages SET state = 'running', started_at = ?, finished_at = NULL, failure_reason = NULL WHERE id = ? AND state = 'pending' AND NOT EXISTS (SELECT 1 FROM project_controls WHERE project_id = stages.project_id AND paused = 1)")
8
+ .run(at, stageId);
9
+ return Number(result.changes) === 1;
10
+ }
11
+ export function finishStage(db, stageId, state, failureReason, at) {
12
+ db.prepare("UPDATE stages SET state = ?, failure_reason = ?, retry_at = NULL, finished_at = ? WHERE id = ?").run(state, failureReason, state === "pending" ? null : at, stageId);
13
+ }
14
+ // A stage put back to `pending` by a re-run, a cascade or a retry starts again from a clean row
15
+ // - no error text, no progress from the run before it, and a fresh attempt budget.
16
+ export function resetStage(db, stageId) {
17
+ db.prepare("UPDATE stages SET state = 'pending', failure_reason = NULL, failure_kind = NULL, retry_at = NULL, attempt_count = 0, progress_current = NULL, progress_total = NULL, started_at = NULL, finished_at = NULL WHERE id = ?").run(stageId);
18
+ }
19
+ export function setStageProgress(db, stageId, current, total) {
20
+ db.prepare("UPDATE stages SET progress_current = ?, progress_total = ? WHERE id = ?").run(current, total, stageId);
21
+ }
@@ -15,8 +15,9 @@ import { attachStagedFile, storeText } from "../storage/staging.js";
15
15
  import { releaseStagedFile } from "../storage/staging-refs.js";
16
16
  import { usesAmbientBed } from "../video/ambient-bed.js";
17
17
  import { sourceOf, } from "./model.js";
18
- import { insertProject, insertStage } from "./repo.js";
18
+ import { insertProject } from "./repo.js";
19
19
  import { usesPronunciationGlossary, usesReference } from "./rules.js";
20
+ import { insertStage } from "./stage-writes.js";
20
21
  import { detectSlots, render } from "./substitute.js";
21
22
  // Provide → `provided` with its output attached; Off → `skipped`;
22
23
  // everything else → `pending`.