@gentbajko/slopify 0.8.3 → 1.0.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 (207) hide show
  1. package/README.md +28 -6
  2. package/SUBTITLES.md +2 -0
  3. package/dist/adapters/alignment/window.js +46 -5
  4. package/dist/adapters/alignment/worker.js +2 -2
  5. package/dist/adapters/llm/claude-code.js +6 -3
  6. package/dist/adapters/llm/codex.js +102 -12
  7. package/dist/adapters/llm/gemini.js +11 -8
  8. package/dist/adapters/llm/run-cli.js +61 -6
  9. package/dist/adapters/tts/inworld-async.js +4 -4
  10. package/dist/adapters/tts/inworld.js +73 -68
  11. package/dist/catalog/registry.js +13 -73
  12. package/dist/edge/cli.js +5 -2
  13. package/dist/edge/events/hub.js +7 -2
  14. package/dist/edge/events/preview-cache.js +7 -4
  15. package/dist/edge/events/visibility.js +33 -0
  16. package/dist/edge/http/actions.js +74 -79
  17. package/dist/edge/http/app.js +39 -6
  18. package/dist/edge/http/audio-preview.js +9 -1
  19. package/dist/edge/http/checkpoints.js +95 -0
  20. package/dist/edge/http/diagnostics.js +31 -0
  21. package/dist/edge/http/draft-files.js +69 -0
  22. package/dist/edge/http/draft-problem.js +15 -0
  23. package/dist/edge/http/drafts.js +95 -0
  24. package/dist/edge/http/multipart.js +86 -0
  25. package/dist/edge/http/mutations.js +49 -0
  26. package/dist/edge/http/planning.js +12 -1
  27. package/dist/edge/http/project-templates.js +74 -0
  28. package/dist/edge/http/projects.js +19 -2
  29. package/dist/edge/http/revision-files.js +84 -0
  30. package/dist/edge/http/revisions.js +103 -0
  31. package/dist/edge/http/schedules.js +100 -0
  32. package/dist/edge/http/staging.js +11 -52
  33. package/dist/edge/http/storage.js +110 -0
  34. package/dist/edge/http/subtitles.js +9 -79
  35. package/dist/edge/http/tutorial.js +26 -0
  36. package/dist/edge/signal-shutdown.js +23 -0
  37. package/dist/kernel/audio-preview.js +11 -5
  38. package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
  39. package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
  40. package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
  41. package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
  42. package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
  43. package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
  44. package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
  45. package/dist/kernel/ports/model.js +5 -0
  46. package/dist/kernel/runner/attempt-repo.js +15 -0
  47. package/dist/kernel/runner/attempt.js +14 -2
  48. package/dist/kernel/runner/checkpoint-authority.js +23 -0
  49. package/dist/kernel/runner/index.js +35 -11
  50. package/dist/kernel/runner/providers.js +18 -8
  51. package/dist/kernel/runner/work-attempt.js +47 -0
  52. package/dist/kernel/runner/work-authority.js +37 -0
  53. package/dist/kernel/runner/work.js +23 -0
  54. package/dist/main.js +227 -69
  55. package/dist/slices/admission/repo.js +5 -56
  56. package/dist/slices/admission/schema.js +64 -0
  57. package/dist/slices/admission/start.js +13 -4
  58. package/dist/slices/article/continuation.js +9 -3
  59. package/dist/slices/article/provided-entries.js +7 -3
  60. package/dist/slices/article/run.js +20 -7
  61. package/dist/slices/article/segments.js +13 -4
  62. package/dist/slices/batch/index.js +8 -11
  63. package/dist/slices/cancel/index.js +78 -34
  64. package/dist/slices/checkpoints/change.js +250 -0
  65. package/dist/slices/checkpoints/fingerprint.js +33 -0
  66. package/dist/slices/checkpoints/index.js +2 -0
  67. package/dist/slices/checkpoints/model.js +1 -0
  68. package/dist/slices/checkpoints/recovery.js +116 -0
  69. package/dist/slices/checkpoints/repo.js +132 -0
  70. package/dist/slices/checkpoints/rules.js +30 -0
  71. package/dist/slices/checkpoints/schema.js +59 -0
  72. package/dist/slices/control/index.js +88 -22
  73. package/dist/slices/control/providers.js +3 -2
  74. package/dist/slices/control/revision-control-schema.js +7 -0
  75. package/dist/slices/control/revision-control.js +59 -0
  76. package/dist/slices/estimate/index.js +77 -59
  77. package/dist/slices/estimate/requests.js +102 -0
  78. package/dist/slices/images/run.js +14 -3
  79. package/dist/slices/library/slots.js +11 -11
  80. package/dist/slices/library/snapshot.js +33 -0
  81. package/dist/slices/narration/legacy-plan.js +58 -0
  82. package/dist/slices/narration/live.js +2 -2
  83. package/dist/slices/narration/plan.js +48 -54
  84. package/dist/slices/narration/run.js +21 -8
  85. package/dist/slices/play-drafts/convert.js +120 -0
  86. package/dist/slices/play-drafts/model.js +1 -0
  87. package/dist/slices/play-drafts/readiness.js +80 -0
  88. package/dist/slices/play-drafts/repo.js +76 -0
  89. package/dist/slices/play-drafts/review-inputs.js +150 -0
  90. package/dist/slices/play-drafts/review.js +109 -0
  91. package/dist/slices/play-drafts/schema.js +218 -0
  92. package/dist/slices/play-drafts/service.js +242 -0
  93. package/dist/slices/play-drafts/start-repo.js +113 -0
  94. package/dist/slices/play-drafts/start.js +148 -0
  95. package/dist/slices/play-drafts/uploads.js +67 -0
  96. package/dist/slices/project-templates/from-project.js +159 -0
  97. package/dist/slices/project-templates/model.js +1 -0
  98. package/dist/slices/project-templates/repo.js +41 -0
  99. package/dist/slices/project-templates/schema.js +37 -0
  100. package/dist/slices/project-templates/service.js +136 -0
  101. package/dist/slices/project-templates/setup.js +55 -0
  102. package/dist/slices/rebuild/admission-repo.js +120 -0
  103. package/dist/slices/rebuild/dependencies.js +33 -0
  104. package/dist/slices/rebuild/model.js +94 -0
  105. package/dist/slices/rebuild/narration-history.js +43 -0
  106. package/dist/slices/rebuild/narration-reuse.js +20 -0
  107. package/dist/slices/rebuild/preview-details.js +144 -0
  108. package/dist/slices/rebuild/preview-plan.js +223 -0
  109. package/dist/slices/rebuild/preview-retained.js +128 -0
  110. package/dist/slices/rebuild/provided-review.js +32 -0
  111. package/dist/slices/rebuild/recipe-audio.js +172 -0
  112. package/dist/slices/rebuild/recipe-build.js +17 -0
  113. package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
  114. package/dist/slices/rebuild/recipe-exports.js +65 -0
  115. package/dist/slices/rebuild/recipe-fixture.js +150 -0
  116. package/dist/slices/rebuild/recipe-input-schema.js +82 -0
  117. package/dist/slices/rebuild/recipe-legacy.js +135 -0
  118. package/dist/slices/rebuild/recipe-model.js +77 -0
  119. package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
  120. package/dist/slices/rebuild/recipe-save.js +221 -0
  121. package/dist/slices/rebuild/recipe-text.js +170 -0
  122. package/dist/slices/rebuild/recipe-validation.js +86 -0
  123. package/dist/slices/rebuild/recipe-visual.js +111 -0
  124. package/dist/slices/rebuild/recipe-work.js +212 -0
  125. package/dist/slices/rebuild/recipes.js +4 -0
  126. package/dist/slices/rebuild/repo.js +64 -0
  127. package/dist/slices/rebuild/runtime-actions.js +76 -0
  128. package/dist/slices/rebuild/runtime-admission.js +71 -0
  129. package/dist/slices/rebuild/runtime-article.js +117 -0
  130. package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
  131. package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
  132. package/dist/slices/rebuild/runtime-export.js +139 -0
  133. package/dist/slices/rebuild/runtime-instructions.js +51 -0
  134. package/dist/slices/rebuild/runtime-local.js +137 -0
  135. package/dist/slices/rebuild/runtime-materialize.js +105 -0
  136. package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
  137. package/dist/slices/rebuild/runtime-plan.js +103 -0
  138. package/dist/slices/rebuild/runtime-provider.js +184 -0
  139. package/dist/slices/rebuild/runtime-publication.js +172 -0
  140. package/dist/slices/rebuild/runtime-run.js +35 -0
  141. package/dist/slices/rebuild/runtime-store.js +166 -0
  142. package/dist/slices/rebuild/runtime-subtitles.js +202 -0
  143. package/dist/slices/rebuild/service-readiness.js +110 -0
  144. package/dist/slices/rebuild/service.js +149 -0
  145. package/dist/slices/rebuild/transition-repo.js +108 -0
  146. package/dist/slices/rebuild/work-records.js +44 -0
  147. package/dist/slices/reruns/index.js +5 -0
  148. package/dist/slices/research/run.js +33 -12
  149. package/dist/slices/revisions/adopt-assets.js +31 -0
  150. package/dist/slices/revisions/adopt-content.js +112 -0
  151. package/dist/slices/revisions/adopt.js +100 -0
  152. package/dist/slices/revisions/downloads.js +80 -0
  153. package/dist/slices/revisions/index.js +5 -0
  154. package/dist/slices/revisions/manifest-repo.js +125 -0
  155. package/dist/slices/revisions/model.js +1 -0
  156. package/dist/slices/revisions/mutation-assets.js +143 -0
  157. package/dist/slices/revisions/mutation-cues.js +95 -0
  158. package/dist/slices/revisions/mutation-prepare.js +182 -0
  159. package/dist/slices/revisions/mutation-request.js +58 -0
  160. package/dist/slices/revisions/mutation-work.js +47 -0
  161. package/dist/slices/revisions/mutations.js +194 -0
  162. package/dist/slices/revisions/projection.js +82 -0
  163. package/dist/slices/revisions/publication-model.js +1 -0
  164. package/dist/slices/revisions/publication-rules.js +166 -0
  165. package/dist/slices/revisions/publish.js +115 -0
  166. package/dist/slices/revisions/repo.js +83 -0
  167. package/dist/slices/revisions/restore.js +60 -0
  168. package/dist/slices/revisions/rules.js +99 -0
  169. package/dist/slices/revisions/schema.js +160 -0
  170. package/dist/slices/revisions/view.js +44 -0
  171. package/dist/slices/schedules/calendar.js +47 -0
  172. package/dist/slices/schedules/model.js +1 -0
  173. package/dist/slices/schedules/repo.js +281 -0
  174. package/dist/slices/schedules/scheduler.js +144 -0
  175. package/dist/slices/schedules/schema.js +80 -0
  176. package/dist/slices/schedules/service.js +178 -0
  177. package/dist/slices/settings/cli-paths.js +1 -1
  178. package/dist/slices/settings/cli-status.js +45 -5
  179. package/dist/slices/settings/tutorial-schema.js +39 -0
  180. package/dist/slices/settings/tutorial.js +66 -0
  181. package/dist/slices/storage/assets.js +108 -0
  182. package/dist/slices/storage/delete-project.js +2 -1
  183. package/dist/slices/storage/downloads.js +4 -1
  184. package/dist/slices/storage/portable.js +871 -0
  185. package/dist/slices/storage/prepare.js +67 -0
  186. package/dist/slices/storage/reconcile.js +25 -5
  187. package/dist/slices/storage/repo.js +2 -15
  188. package/dist/slices/storage/schema.js +35 -0
  189. package/dist/slices/storage/staging-refs.js +26 -0
  190. package/dist/slices/storage/staging.js +16 -11
  191. package/dist/slices/thumbnail/run.js +26 -9
  192. package/dist/slices/video/audio-export-args.js +33 -0
  193. package/dist/slices/video/audio-export.js +13 -36
  194. package/dist/slices/video/audio-inputs.js +4 -3
  195. package/dist/slices/video/ffmpeg.js +6 -0
  196. package/dist/slices/video/reuse-audio.js +11 -4
  197. package/dist/updater/install-flow.js +27 -2
  198. package/dist/updater/plan.js +28 -2
  199. package/dist/updater/service.js +27 -2
  200. package/dist/updater/worker.js +2 -1
  201. package/dist/web/assets/index-CO7DnVX9.js +137 -0
  202. package/dist/web/assets/index-DX1_chKs.css +1 -0
  203. package/dist/web/index.html +2 -2
  204. package/package.json +2 -2
  205. package/dist/adapters/tts/inworld-text.js +0 -32
  206. package/dist/web/assets/index-Ce2HApus.css +0 -1
  207. package/dist/web/assets/index-xbD0MZOY.js +0 -130
@@ -43,19 +43,22 @@ export async function writeArticle(providers, choice, brief, onDelta) {
43
43
  onDelta(event.text);
44
44
  }
45
45
  };
46
- let answer = await providers.llm({
46
+ let result = await providers.llm({
47
47
  provider: choice.provider,
48
48
  model: choice.model,
49
49
  ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
50
50
  messages: base,
51
51
  check: written,
52
52
  }, stream);
53
+ if (!result.ok)
54
+ return result;
55
+ let answer = result.value;
53
56
  const pieces = [answer.text];
54
57
  let tokens = plusUsage(noTokens, answer.usage);
55
58
  for (let n = 1; truncated(answer) && n <= continuationLimit; n += 1) {
56
59
  const messages = continuationMessages(base, pieces.join(""));
57
60
  sent.push({ label: `Continuation ${String(n)}`, messages });
58
- answer = await providers.llm({
61
+ result = await providers.llm({
59
62
  provider: choice.provider,
60
63
  model: choice.model,
61
64
  ...(choice.thinking === undefined ? {} : { thinking: choice.thinking }),
@@ -65,10 +68,13 @@ export async function writeArticle(providers, choice, brief, onDelta) {
65
68
  // loop therefore never sees a truncated answer with its budget spent.
66
69
  check: n === continuationLimit ? finished : written,
67
70
  }, stream);
71
+ if (!result.ok)
72
+ return result;
73
+ answer = result.value;
68
74
  pieces.push(answer.text);
69
75
  tokens = plusUsage(tokens, answer.usage);
70
76
  }
71
- return { markdown: pieces.join(""), sent, tokens };
77
+ return { ok: true, value: { markdown: pieces.join(""), sent, tokens } };
72
78
  }
73
79
  function truncated(answer) {
74
80
  return answer.finishReason === truncatedReason;
@@ -14,9 +14,9 @@ export async function prepareProvidedArticleSegments(deps, audioContext, provide
14
14
  throw new Error(`project ${projectId} has no row`);
15
15
  const { config } = project;
16
16
  if (config.sources.article !== "provide" || config.sources.audio !== "generate")
17
- return;
17
+ return "done";
18
18
  if (!config.intro && !config.outro)
19
- return;
19
+ return "done";
20
20
  audioContext.signal.throwIfAborted();
21
21
  const article = stagesOf(deps.db, projectId).find((stage) => stage.kind === "article");
22
22
  if (!article)
@@ -36,8 +36,11 @@ export async function prepareProvidedArticleSegments(deps, audioContext, provide
36
36
  if (completed.some((piece) => piece.idx === index + 1 && piece.state === "done"))
37
37
  continue;
38
38
  const sent = [];
39
- const segment = await writeSegment(providers, config.llm, config, category, narration, sent);
39
+ const result = await writeSegment(providers, config.llm, config, category, narration, sent);
40
40
  audioContext.signal.throwIfAborted();
41
+ if (!result.ok)
42
+ return "held";
43
+ const segment = result.value;
41
44
  if (!segment)
42
45
  continue;
43
46
  if (sent.length > 0) {
@@ -59,4 +62,5 @@ export async function prepareProvidedArticleSegments(deps, audioContext, provide
59
62
  ...segment.tokens,
60
63
  });
61
64
  }
65
+ return "done";
62
66
  }
@@ -24,7 +24,10 @@ export async function runArticle(deps, context, providers) {
24
24
  }
25
25
  const notes = researchNotes(deps, projectId);
26
26
  const brief = { articlePrompt, ...(notes === undefined ? {} : { notes }) };
27
- const completed = await articleBody(deps, context, providers, choice, brief);
27
+ const result = await articleBody(deps, context, providers, choice, brief);
28
+ if (!result.ok)
29
+ return "held";
30
+ const completed = result.value;
28
31
  const { narration } = completed;
29
32
  const sent = [...completed.sent];
30
33
  for (const [index, category] of categories.entries()) {
@@ -33,7 +36,10 @@ export async function runArticle(deps, context, providers) {
33
36
  if (completed.resumed &&
34
37
  piecesOf(deps.db, context.stage.id, "segment").some((piece) => piece.idx === index + 1 && piece.state === "done"))
35
38
  continue;
36
- const segment = await writeSegment(providers, choice, project.config, category, narration, sent);
39
+ const result = await writeSegment(providers, choice, project.config, category, narration, sent);
40
+ if (!result.ok)
41
+ return "held";
42
+ const segment = result.value;
37
43
  if (segment !== undefined) {
38
44
  keepSegment(deps, context, segment, index + 1);
39
45
  checkpointArticle(deps, context, sent);
@@ -60,6 +66,7 @@ export async function runArticle(deps, context, providers) {
60
66
  stage: "article",
61
67
  detail: `${String(narration.length)} characters to narrate`,
62
68
  });
69
+ return "done";
63
70
  }
64
71
  // Once the article is stored, a pause during end matter resumes that end matter
65
72
  // instead of buying and replacing the article again. Re-run clears this checkpoint.
@@ -82,14 +89,20 @@ async function articleBody(deps, context, providers, choice, brief) {
82
89
  existsSync(outputPath(deps.paths, projectId, plain.path)) &&
83
90
  existsSync(outputPath(deps.paths, projectId, markdown.path))) {
84
91
  return {
85
- narration: readFileSync(outputPath(deps.paths, projectId, plain.path), "utf8"),
86
- sent: articleCheckpoint.parse(JSON.parse(saved.payload)).sent,
87
- resumed: true,
92
+ ok: true,
93
+ value: {
94
+ narration: readFileSync(outputPath(deps.paths, projectId, plain.path), "utf8"),
95
+ sent: articleCheckpoint.parse(JSON.parse(saved.payload)).sent,
96
+ resumed: true,
97
+ },
88
98
  };
89
99
  }
90
- const written = await writeArticle(providers, choice, brief, (text) => {
100
+ const result = await writeArticle(providers, choice, brief, (text) => {
91
101
  context.emit({ type: "article.delta", projectId, text });
92
102
  });
103
+ if (!result.ok)
104
+ return result;
105
+ const written = result.value;
93
106
  storeText(deps, { projectId, stageKind: "article", role: "article_md", text: written.markdown });
94
107
  const narration = storeArticleText(deps, { projectId, markdown: written.markdown });
95
108
  checkpointArticle(deps, context, written.sent);
@@ -99,7 +112,7 @@ async function articleBody(deps, context, providers, choice, brief) {
99
112
  model: choice.model,
100
113
  ...written.tokens,
101
114
  });
102
- return { narration, sent: written.sent, resumed: false };
115
+ return { ok: true, value: { narration, sent: written.sent, resumed: false } };
103
116
  }
104
117
  function checkpointArticle(deps, context, sent) {
105
118
  const existing = piecesOf(deps.db, context.stage.id, "article_written")[0];
@@ -4,13 +4,13 @@ export const categories = ["intro", "outro"];
4
4
  export async function writeSegment(providers, choice, config, category, article, sent) {
5
5
  const picked = config[category];
6
6
  if (picked === undefined)
7
- return undefined;
7
+ return { ok: true, value: undefined };
8
8
  const body = config.rendered[category];
9
9
  if (body === undefined)
10
10
  throw new Error(`the run has no rendered ${category} text`);
11
11
  const common = { category, name: picked.name, mode: picked.mode };
12
12
  if (picked.mode === "text")
13
- return { ...common, text: body, tokens: noTokens };
13
+ return { ok: true, value: { ...common, text: body, tokens: noTokens } };
14
14
  if (choice === undefined)
15
15
  throw new Error(`the ${category} has no LLM provider or model`);
16
16
  const messages = segmentMessages(body, config, article);
@@ -22,9 +22,18 @@ export async function writeSegment(providers, choice, config, category, article,
22
22
  messages,
23
23
  check: (given) => given.text.trim() === "" ? `the ${category} answered with nothing` : undefined,
24
24
  });
25
- return { ...common, text: answer.text.trim(), tokens: plusUsage(noTokens, answer.usage) };
25
+ if (!answer.ok)
26
+ return answer;
27
+ return {
28
+ ok: true,
29
+ value: {
30
+ ...common,
31
+ text: answer.value.text.trim(),
32
+ tokens: plusUsage(noTokens, answer.value.usage),
33
+ },
34
+ };
26
35
  }
27
- function segmentMessages(body, config, article) {
36
+ export function segmentMessages(body, config, article) {
28
37
  const values = Object.entries(config.values);
29
38
  return [
30
39
  {
@@ -3,9 +3,8 @@ import { transact } from "../../kernel/db/tx.js";
3
3
  import { derive } from "../../kernel/runner/graph.js";
4
4
  import { projectPaused, stagesOf } from "../admission/repo.js";
5
5
  import { startRun } from "../admission/start.js";
6
- import { stagingPath } from "../storage/layout.js";
7
- import { deleteStagedFile, stagedFileById } from "../storage/repo.js";
8
- import { dropStagedSource } from "../storage/staging.js";
6
+ import { deleteStagedFile } from "../storage/repo.js";
7
+ import { releaseStagedFile } from "../storage/staging-refs.js";
9
8
  const queueRow = z.object({
10
9
  projectId: z.string(),
11
10
  batchId: z.string(),
@@ -28,7 +27,7 @@ export function queueWaiting(db, projectId) {
28
27
  export function batchExists(db, id) {
29
28
  return db.prepare("SELECT 1 FROM batches WHERE id = ?").get(id) !== undefined;
30
29
  }
31
- export function enqueueBatch(deps, batchId, runs) {
30
+ export function enqueueBatch(deps, batchId, runs, retainStaged = false) {
32
31
  if (batchExists(deps.db, batchId))
33
32
  return queueEntries(deps.db, batchId);
34
33
  const sources = new Set();
@@ -37,8 +36,8 @@ export function enqueueBatch(deps, batchId, runs) {
37
36
  .prepare("INSERT INTO batches(id, created_at) VALUES (?, ?)")
38
37
  .run(batchId, deps.clock.now().toISOString());
39
38
  const used = new Set();
40
- for (const { draft, rendered } of runs) {
41
- const { project } = startRun(deps, draft, rendered, true);
39
+ for (const { draft, rendered, templates } of runs) {
40
+ const { project } = startRun(deps, draft, rendered, true, templates);
42
41
  deps.db
43
42
  .prepare("INSERT INTO project_queue(project_id, batch_id) VALUES (?, ?)")
44
43
  .run(project.id, batchId);
@@ -50,15 +49,13 @@ export function enqueueBatch(deps, batchId, runs) {
50
49
  for (const id of draft.provided.images ?? [])
51
50
  used.add(id);
52
51
  }
53
- for (const id of used) {
54
- const file = stagedFileById(deps.db, id);
55
- if (file)
56
- sources.add(stagingPath(deps.paths, file.path));
52
+ for (const id of retainStaged ? [] : used) {
53
+ sources.add(id);
57
54
  deleteStagedFile(deps.db, id);
58
55
  }
59
56
  });
60
57
  for (const source of sources)
61
- dropStagedSource(deps, source);
58
+ releaseStagedFile(deps, source);
62
59
  return queueEntries(deps.db, batchId);
63
60
  }
64
61
  // Only one batch video runs at once. A paused item holds its place; a failed or
@@ -1,84 +1,128 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { projectStates, stageKinds, } from "../../kernel/pipeline.js";
1
4
  import { derive } from "../../kernel/runner/graph.js";
2
5
  import { finishStage, projectExists, projectPaused, setProjectPaused, stagesOf, } from "../admission/repo.js";
6
+ import { withProjectControl } from "../control/lock.js";
7
+ import { checkRevisionControl, rememberRevisionControl, } from "../control/revision-control.js";
8
+ import { settleScheduleRunsForProject } from "../schedules/repo.js";
3
9
  // Cancel on the project header. Every in-flight call of this project is aborted at once, every
4
10
  // `done` output and every finished piece is kept for the resume, and the project sits
5
11
  // `canceled` until the user retries a stage.
6
12
  // The cancel rules and the stage transition table, in the words the stage row carries.
7
13
  export const canceledByUser = "canceled by user";
8
- export async function cancelProject(deps, projectId) {
14
+ export function cancelProject(deps, projectId, input) {
15
+ return withProjectControl(deps.db, projectId, async () => {
16
+ const checked = checkRevisionControl(deps, projectId, "cancel", input, z.object({
17
+ ok: z.literal(true),
18
+ canceled: z.array(z.enum(stageKinds)),
19
+ state: z.enum(projectStates),
20
+ }));
21
+ if (!checked.ok)
22
+ return checked;
23
+ if (checked.response !== undefined)
24
+ return checked.response;
25
+ const result = await cancel(deps, projectId);
26
+ if (result.ok)
27
+ rememberRevisionControl(deps, checked.identity, result);
28
+ return result;
29
+ });
30
+ }
31
+ async function cancel(deps, projectId) {
9
32
  if (!projectExists(deps.db, projectId)) {
10
33
  return { ok: false, reason: "no-project" };
11
34
  }
12
35
  const before = stagesOf(deps.db, projectId);
13
36
  const paused = projectPaused(deps.db, projectId);
14
- if (paused) {
15
- setProjectPaused(deps.db, projectId, false, deps.clock.now().toISOString());
16
- deps.emit(projectId, { type: "project.updated", projectId });
17
- }
18
37
  const running = before.filter((stage) => stage.state === "running").map((stage) => stage.kind);
19
- if (running.length === 0 && !paused) {
38
+ const admittedPending = deps.db
39
+ .prepare("SELECT 1 FROM revision_work w JOIN revision_work_reservations r ON r.work_id=w.id JOIN project_heads h ON h.project_id=r.project_id AND h.revision_id=r.revision_id WHERE w.project_id=? AND w.state='pending' LIMIT 1")
40
+ .get(projectId) !== undefined;
41
+ const draining = deps.hasInflight?.(projectId) === true;
42
+ if (running.length === 0 && !paused && !admittedPending && !draining) {
20
43
  // A second click is a no-op. Nothing is aborted and no state changes, so
21
44
  // the page is simply told what the project already reads.
22
45
  return { ok: true, canceled: [], state: derive(before) };
23
46
  }
24
47
  // Nothing waits for a response. The runner holds the controllers and its own
25
48
  // barrier, so a stage that finishes during this does not release its dependents.
26
- if (running.length > 0)
49
+ if (running.length > 0 || draining)
27
50
  await deps.abort(projectId);
28
- // The invariant: after cancel completes no stage of the project is `running`.
29
- // The runner writes that row as each aborted stage unwinds; this is the path where it
30
- // could not - a failed write is logged there and the run is left mid-flight otherwise.
31
- const after = stagesOf(deps.db, projectId);
32
- for (const stage of after) {
33
- if (stage.state !== "running") {
34
- continue;
51
+ const at = deps.clock.now().toISOString();
52
+ const transition = transact(deps.db, () => {
53
+ if (paused)
54
+ setProjectPaused(deps.db, projectId, false, at);
55
+ deps.db
56
+ .prepare("UPDATE revision_work SET state='canceled',dispatch_state='held',failure_reason=? WHERE project_id=? AND state!='done'")
57
+ .run(canceledByUser, projectId);
58
+ deps.db
59
+ .prepare("UPDATE revision_work_pieces SET state='held',dispatch_state='held' WHERE work_id IN (SELECT id FROM revision_work WHERE project_id=?) AND state!='done'")
60
+ .run(projectId);
61
+ deps.db
62
+ .prepare("UPDATE review_checkpoints SET state='canceled',approved_at=NULL WHERE project_id=? AND revision_id=(SELECT revision_id FROM project_heads WHERE project_id=?) AND state IN ('configured','pending-review','held')")
63
+ .run(projectId, projectId);
64
+ deps.settleCheckpoints(projectId);
65
+ // The invariant: after cancel commits no stage of the project is `running`.
66
+ // Usually the aborted runner already wrote these rows; the sweep covers a failed
67
+ // unwinding write and shares this transaction with schedule settlement.
68
+ const after = stagesOf(deps.db, projectId);
69
+ const swept = [];
70
+ for (const stage of after) {
71
+ if (stage.state !== "running")
72
+ continue;
73
+ finishStage(deps.db, stage.id, "canceled", canceledByUser, at);
74
+ swept.push(stage.kind);
35
75
  }
76
+ // An abort can race a successful stage completion. Mark one remaining pending
77
+ // stage canceled so cancellation is explicit, rather than guessing from any
78
+ // completed stage (independent images can now finish before Article).
79
+ const stopped = stagesOf(deps.db, projectId);
80
+ const waiting = stopped.find((stage) => stage.state === "pending");
81
+ const waitingCanceled = !stopped.some((stage) => stage.state === "canceled") && waiting !== undefined;
82
+ if (waitingCanceled)
83
+ finishStage(deps.db, waiting.id, "canceled", canceledByUser, at);
84
+ const settled = stagesOf(deps.db, projectId);
85
+ const state = derive(settled);
86
+ settleScheduleRunsForProject(deps.db, projectId, at);
87
+ return { settled, state, swept, waiting: waitingCanceled ? waiting : undefined };
88
+ });
89
+ if (paused)
90
+ deps.emit(projectId, { type: "project.updated", projectId });
91
+ for (const kind of transition.swept) {
36
92
  deps.log.write("warn", "cancel.sweep", {
37
93
  projectId,
38
- stage: stage.kind,
94
+ stage: kind,
39
95
  detail: "the stage was still running after its calls stopped",
40
96
  });
41
- finishStage(deps.db, stage.id, "canceled", canceledByUser, deps.clock.now().toISOString());
42
97
  deps.emit(projectId, {
43
98
  type: "stage.state",
44
99
  projectId,
45
- stage: stage.kind,
100
+ stage: kind,
46
101
  state: "canceled",
47
102
  failureReason: canceledByUser,
48
103
  });
49
104
  }
50
- // An abort can race a successful stage completion. Mark one remaining pending
51
- // stage canceled so cancellation is explicit, rather than guessing from any
52
- // completed stage (independent images can now finish before Article).
53
- const stopped = stagesOf(deps.db, projectId);
54
- const waiting = stopped.find((stage) => stage.state === "pending");
55
- if (!stopped.some((stage) => stage.state === "canceled") && waiting !== undefined) {
56
- finishStage(deps.db, waiting.id, "canceled", canceledByUser, deps.clock.now().toISOString());
105
+ if (transition.waiting !== undefined)
57
106
  deps.emit(projectId, {
58
107
  type: "stage.state",
59
108
  projectId,
60
- stage: waiting.kind,
109
+ stage: transition.waiting.kind,
61
110
  state: "canceled",
62
111
  failureReason: canceledByUser,
63
112
  });
64
- }
65
- const settled = stagesOf(deps.db, projectId);
66
- const state = derive(settled);
67
- if (paused ||
68
- after.some((stage) => stage.state === "running") ||
69
- (waiting !== undefined && !stopped.some((stage) => stage.state === "canceled"))) {
113
+ if (paused || transition.swept.length > 0 || transition.waiting !== undefined) {
70
114
  // Only when the sweep above moved a row: the runner already announced the state it
71
115
  // left the project in, and repeating it would tell every open page the same thing
72
116
  // twice.
73
- deps.emit(projectId, { type: "project.state", projectId, state });
117
+ deps.emit(projectId, { type: "project.state", projectId, state: transition.state });
74
118
  }
75
119
  return {
76
120
  ok: true,
77
121
  // A stage whose output was stored in the same instant as the cancel stays
78
122
  // `done`, so what was stopped is read back rather than assumed from what was running.
79
- canceled: settled
123
+ canceled: transition.settled
80
124
  .filter((stage) => stage.state === "canceled" && running.includes(stage.kind))
81
125
  .map((stage) => stage.kind),
82
- state,
126
+ state: transition.state,
83
127
  };
84
128
  }
@@ -0,0 +1,250 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { projectExists, stagesOf } from "../admission/repo.js";
4
+ import { executionPlan, executionView, savedCatalogue } from "../rebuild/runtime-plan.js";
5
+ import { currentRevisionId } from "../revisions/repo.js";
6
+ import { checkpointClosure, checkpointFingerprint } from "./fingerprint.js";
7
+ import { listCheckpoints } from "./repo.js";
8
+ import { canChangeCheckpoint } from "./rules.js";
9
+ import { checkpointRowSchema, checkpointStageSchema } from "./schema.js";
10
+ const id = z
11
+ .string()
12
+ .min(1)
13
+ .max(64)
14
+ .regex(/^[0-9A-Za-z_-]+$/);
15
+ export const checkpointChangeSchema = z
16
+ .object({
17
+ revisionId: id,
18
+ stages: z
19
+ .array(checkpointStageSchema)
20
+ .max(3)
21
+ .refine((values) => new Set(values).size === values.length)
22
+ .readonly(),
23
+ })
24
+ .strict();
25
+ function current(deps, projectId, revisionId) {
26
+ if (!projectExists(deps.db, projectId))
27
+ return { ok: false, reason: "not-found" };
28
+ if (currentRevisionId(deps.db, projectId) !== revisionId)
29
+ return { ok: false, reason: "conflict" };
30
+ if (stagesOf(deps.db, projectId).some((stage) => stage.state === "canceled"))
31
+ return { ok: false, reason: "conflict" };
32
+ return { ok: true, value: true };
33
+ }
34
+ export function resolvedGate(deps, row, currentInputs = false, catalogue) {
35
+ try {
36
+ const stored = deps.db
37
+ .prepare("SELECT recipe_context FROM revision_work WHERE id=? AND project_id=? AND revision_id=?")
38
+ .get(row.workId, row.projectId, row.revisionId);
39
+ const view = executionView(deps, row.projectId, row.revisionId);
40
+ if (!view || (catalogue === undefined && stored?.recipe_context == null))
41
+ throw new Error("Checkpoint work snapshot is missing");
42
+ const plan = executionPlan(deps, view, catalogue ?? savedCatalogue(stored?.recipe_context));
43
+ const closure = checkpointClosure(row.stage, plan.recipes);
44
+ return {
45
+ ...row,
46
+ currentFingerprint: checkpointFingerprint(currentInputs
47
+ ? {
48
+ ...view.revision,
49
+ fingerprints: Object.fromEntries(plan.recipes.map((recipe) => [recipe.key, recipe.fingerprint])),
50
+ }
51
+ : view.revision, closure),
52
+ dependents: [...new Set(closure.map((recipe) => recipe.stage))]
53
+ .filter((stage) => stage !== row.stage)
54
+ .sort(),
55
+ workKeys: closure.map((recipe) => recipe.key),
56
+ };
57
+ }
58
+ catch {
59
+ deps.log.write("warn", "checkpoint.resolve", { projectId: row.projectId, stage: row.stage });
60
+ return undefined;
61
+ }
62
+ }
63
+ export function readCheckpointStatus(deps, projectId) {
64
+ if (!projectExists(deps.db, projectId))
65
+ return { ok: false, reason: "not-found" };
66
+ const revisionId = currentRevisionId(deps.db, projectId);
67
+ if (!revisionId)
68
+ return { ok: false, reason: "not-found" };
69
+ const checkpoints = [];
70
+ for (const row of listCheckpoints(deps.db, projectId, revisionId)) {
71
+ const resolved = refreshCheckpointGate(deps, row);
72
+ if (!resolved)
73
+ return { ok: false, reason: "conflict" };
74
+ checkpoints.push(resolved);
75
+ }
76
+ return { ok: true, value: { revisionId, checkpoints } };
77
+ }
78
+ export function refreshCheckpointGate(deps, row) {
79
+ const resolved = resolvedGate(deps, row);
80
+ if (!resolved ||
81
+ resolved.currentFingerprint === row.fingerprint ||
82
+ row.approvedAt !== null ||
83
+ !["held", "pending-review"].includes(row.state))
84
+ return resolved;
85
+ const changed = deps.db
86
+ .prepare("UPDATE review_checkpoints SET fingerprint=? WHERE project_id=? AND revision_id=? AND checkpoint_id=? AND fingerprint=? AND approved_at IS NULL AND state IN ('held','pending-review')")
87
+ .run(resolved.currentFingerprint, row.projectId, row.revisionId, row.checkpointId, row.fingerprint);
88
+ return Number(changed.changes) === 1
89
+ ? { ...resolved, fingerprint: resolved.currentFingerprint }
90
+ : undefined;
91
+ }
92
+ export function validateCheckpointApproval(deps, input) {
93
+ const allowed = current(deps, input.projectId, input.revisionId);
94
+ if (!allowed.ok)
95
+ return { ok: false, reason: allowed.reason };
96
+ const row = listCheckpoints(deps.db, input.projectId, input.revisionId).find((gate) => gate.checkpointId === input.checkpointId);
97
+ if (!row)
98
+ return { ok: false, reason: "not-found" };
99
+ const stage = stagesOf(deps.db, input.projectId).find((stage) => stage.kind === row.stage);
100
+ const work = deps.db.prepare("SELECT state FROM revision_work WHERE id=?").get(row.workId);
101
+ const resolved = resolvedGate(deps, row);
102
+ const outstanding = resolved !== undefined &&
103
+ deps.db
104
+ .prepare(`SELECT r.work_key FROM revision_work_reservations r
105
+ JOIN revision_work w ON w.id=r.work_id WHERE r.project_id=? AND r.revision_id=? AND w.state IN ('pending','running')`)
106
+ .all(input.projectId, input.revisionId)
107
+ .some((entry) => resolved.workKeys.includes(z.string().parse(entry.work_key)));
108
+ if (!stage ||
109
+ !work ||
110
+ stage.state === "canceled" ||
111
+ (["done", "provided", "skipped"].includes(stage.state) && !outstanding) ||
112
+ work.state === "canceled")
113
+ return { ok: false, reason: "conflict" };
114
+ if ((stage.state === "running" || work.state === "running") && row.state !== "released")
115
+ return { ok: false, reason: "conflict" };
116
+ if (row.fingerprint !== input.fingerprint || resolved?.currentFingerprint !== input.fingerprint)
117
+ return { ok: false, reason: "conflict" };
118
+ return { ok: true, value: row };
119
+ }
120
+ export function replayCheckpointApproval(deps, input) {
121
+ const receipt = deps.db
122
+ .prepare("SELECT revision_id,checkpoint_id,fingerprint FROM review_checkpoint_approvals WHERE project_id=? AND idempotency_key=?")
123
+ .get(input.projectId, input.idempotencyKey);
124
+ if (!receipt)
125
+ return undefined;
126
+ const valid = current(deps, input.projectId, input.revisionId);
127
+ if (!valid.ok)
128
+ return { ok: false, reason: valid.reason };
129
+ if (receipt.revision_id !== input.revisionId ||
130
+ receipt.checkpoint_id !== input.checkpointId ||
131
+ receipt.fingerprint !== input.fingerprint)
132
+ return { ok: false, reason: "conflict" };
133
+ const row = listCheckpoints(deps.db, input.projectId, input.revisionId).find((gate) => gate.checkpointId === input.checkpointId);
134
+ return row &&
135
+ row.fingerprint === input.fingerprint &&
136
+ row.approvedAt !== null &&
137
+ ["released", "satisfied"].includes(row.state)
138
+ ? { ok: true, value: row }
139
+ : { ok: false, reason: "conflict" };
140
+ }
141
+ export function changeCheckpoints(deps, input) {
142
+ const parsed = checkpointChangeSchema.safeParse({
143
+ revisionId: input.revisionId,
144
+ stages: input.stages,
145
+ });
146
+ if (!parsed.success)
147
+ return { ok: false, reason: "invalid-input" };
148
+ return transact(deps.db, () => {
149
+ const allowed = current(deps, input.projectId, input.revisionId);
150
+ if (!allowed.ok)
151
+ return { ok: false, reason: allowed.reason };
152
+ const existing = listCheckpoints(deps.db, input.projectId, input.revisionId);
153
+ const remove = existing.filter((row) => !parsed.data.stages.includes(row.stage));
154
+ const add = parsed.data.stages.filter((stage) => !existing.some((row) => row.stage === stage));
155
+ const changedStages = new Set([...remove.map((row) => row.stage), ...add]);
156
+ const additions = [];
157
+ const stages = stagesOf(deps.db, input.projectId);
158
+ for (const kind of changedStages) {
159
+ const stage = stages.find((row) => row.kind === kind);
160
+ const work = deps.db
161
+ .prepare(`SELECT w.* FROM revision_work w WHERE w.project_id=? AND w.kind=? AND
162
+ (w.revision_id=? OR EXISTS (SELECT 1 FROM revision_work_reservations r WHERE r.work_id=w.id AND r.revision_id=?)) ORDER BY w.id`)
163
+ .all(input.projectId, kind, input.revisionId, input.revisionId);
164
+ const submitted = work.some((row) => {
165
+ if (row.revision_id !== input.revisionId && row.state === "done")
166
+ return false;
167
+ const workId = z.string().parse(row.id);
168
+ if (row.state === "done" &&
169
+ deps.db
170
+ .prepare("SELECT 1 FROM revision_work_pieces WHERE work_id=? AND (submitted_at IS NOT NULL OR state='done') LIMIT 1")
171
+ .get(workId) === undefined &&
172
+ deps.db
173
+ .prepare("SELECT 1 FROM revision_work_reservations WHERE revision_id=? AND work_id=? LIMIT 1")
174
+ .get(input.revisionId, workId) === undefined)
175
+ return false;
176
+ return (row.state !== "pending" ||
177
+ deps.db
178
+ .prepare("SELECT 1 FROM revision_work_pieces WHERE work_id=? AND (submitted_at IS NOT NULL OR state IN ('running','done')) LIMIT 1")
179
+ .get(workId) !== undefined);
180
+ });
181
+ if (!stage || canChangeCheckpoint(stage.state, submitted).kind !== "eligible")
182
+ return { ok: false, reason: "conflict" };
183
+ if (!add.includes(kind))
184
+ continue;
185
+ let anchor = work.find((row) => row.revision_id === input.revisionId);
186
+ let synthetic = false;
187
+ if (!anchor) {
188
+ const carried = work.find((row) => typeof row.recipe_context === "string");
189
+ if (!carried)
190
+ return { ok: false, reason: "conflict" };
191
+ anchor = { ...carried, id: deps.ids.next(), revision_id: input.revisionId };
192
+ synthetic = true;
193
+ }
194
+ if (typeof anchor?.id !== "string")
195
+ return { ok: false, reason: "conflict" };
196
+ let catalogue;
197
+ try {
198
+ catalogue = synthetic
199
+ ? savedCatalogue(anchor.recipe_context)
200
+ : anchor.recipe_context == null
201
+ ? deps.catalogue?.read()
202
+ : undefined;
203
+ }
204
+ catch {
205
+ deps.log.write("warn", "checkpoint.resolve", { projectId: input.projectId, stage: kind });
206
+ return { ok: false, reason: "conflict" };
207
+ }
208
+ const row = {
209
+ projectId: input.projectId,
210
+ revisionId: input.revisionId,
211
+ checkpointId: deps.ids.next(),
212
+ stage: kind,
213
+ workId: anchor.id,
214
+ fingerprint: "0".repeat(64),
215
+ state: "held",
216
+ createdAt: deps.clock.now().toISOString(),
217
+ approvedAt: null,
218
+ };
219
+ const resolved = resolvedGate(deps, row, false, catalogue);
220
+ if (!resolved || resolved.workKeys.length === 0)
221
+ return { ok: false, reason: "conflict" };
222
+ additions.push({
223
+ row: checkpointRowSchema.parse({ ...row, fingerprint: resolved.currentFingerprint }),
224
+ catalogue,
225
+ newStageId: synthetic ? stage.id : undefined,
226
+ });
227
+ }
228
+ for (const { row: gate, catalogue, newStageId: stageId } of additions) {
229
+ if (catalogue === undefined)
230
+ continue;
231
+ if (stageId !== undefined)
232
+ deps.db
233
+ .prepare("INSERT INTO revision_work(id,project_id,revision_id,stage_id,kind,fingerprint,state,dispatch_state,recipe_context,created_at) VALUES (?,?,?,?,?,?,'done','held',?,?)")
234
+ .run(gate.workId, input.projectId, input.revisionId, stageId, gate.stage, gate.fingerprint, JSON.stringify(catalogue), gate.createdAt);
235
+ else
236
+ deps.db
237
+ .prepare("UPDATE revision_work SET recipe_context=? WHERE id=? AND recipe_context IS NULL")
238
+ .run(JSON.stringify(catalogue), gate.workId);
239
+ }
240
+ for (const row of remove)
241
+ deps.db
242
+ .prepare("DELETE FROM review_checkpoints WHERE project_id=? AND revision_id=? AND checkpoint_id=?")
243
+ .run(row.projectId, row.revisionId, row.checkpointId);
244
+ for (const { row } of additions)
245
+ deps.db
246
+ .prepare("INSERT INTO review_checkpoints(project_id,revision_id,checkpoint_id,stage,work_id,fingerprint,state,created_at,approved_at) VALUES (?,?,?,?,?,?,?,?,?)")
247
+ .run(row.projectId, row.revisionId, row.checkpointId, row.stage, row.workId, row.fingerprint, row.state, row.createdAt, row.approvedAt);
248
+ return { ok: true, value: { changed: changedStages.size > 0, released: remove.length > 0 } };
249
+ });
250
+ }