@gentbajko/slopify 0.8.4 → 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 (204) hide show
  1. package/README.md +28 -6
  2. package/dist/adapters/llm/claude-code.js +6 -3
  3. package/dist/adapters/llm/codex.js +102 -12
  4. package/dist/adapters/llm/gemini.js +11 -8
  5. package/dist/adapters/llm/run-cli.js +61 -6
  6. package/dist/adapters/tts/inworld-async.js +4 -4
  7. package/dist/adapters/tts/inworld.js +73 -68
  8. package/dist/catalog/registry.js +13 -73
  9. package/dist/edge/cli.js +5 -2
  10. package/dist/edge/events/hub.js +7 -2
  11. package/dist/edge/events/preview-cache.js +7 -4
  12. package/dist/edge/events/visibility.js +33 -0
  13. package/dist/edge/http/actions.js +74 -79
  14. package/dist/edge/http/app.js +39 -6
  15. package/dist/edge/http/audio-preview.js +9 -1
  16. package/dist/edge/http/checkpoints.js +95 -0
  17. package/dist/edge/http/diagnostics.js +31 -0
  18. package/dist/edge/http/draft-files.js +69 -0
  19. package/dist/edge/http/draft-problem.js +15 -0
  20. package/dist/edge/http/drafts.js +95 -0
  21. package/dist/edge/http/multipart.js +86 -0
  22. package/dist/edge/http/mutations.js +49 -0
  23. package/dist/edge/http/planning.js +12 -1
  24. package/dist/edge/http/project-templates.js +74 -0
  25. package/dist/edge/http/projects.js +19 -2
  26. package/dist/edge/http/revision-files.js +84 -0
  27. package/dist/edge/http/revisions.js +103 -0
  28. package/dist/edge/http/schedules.js +100 -0
  29. package/dist/edge/http/staging.js +11 -52
  30. package/dist/edge/http/storage.js +110 -0
  31. package/dist/edge/http/subtitles.js +9 -79
  32. package/dist/edge/http/tutorial.js +26 -0
  33. package/dist/edge/signal-shutdown.js +23 -0
  34. package/dist/kernel/audio-preview.js +11 -5
  35. package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
  36. package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
  37. package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
  38. package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
  39. package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
  40. package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
  41. package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
  42. package/dist/kernel/ports/model.js +5 -0
  43. package/dist/kernel/runner/attempt-repo.js +15 -0
  44. package/dist/kernel/runner/attempt.js +14 -2
  45. package/dist/kernel/runner/checkpoint-authority.js +23 -0
  46. package/dist/kernel/runner/index.js +35 -11
  47. package/dist/kernel/runner/providers.js +18 -8
  48. package/dist/kernel/runner/work-attempt.js +47 -0
  49. package/dist/kernel/runner/work-authority.js +37 -0
  50. package/dist/kernel/runner/work.js +23 -0
  51. package/dist/main.js +227 -69
  52. package/dist/slices/admission/repo.js +5 -56
  53. package/dist/slices/admission/schema.js +64 -0
  54. package/dist/slices/admission/start.js +13 -4
  55. package/dist/slices/article/continuation.js +9 -3
  56. package/dist/slices/article/provided-entries.js +7 -3
  57. package/dist/slices/article/run.js +20 -7
  58. package/dist/slices/article/segments.js +13 -4
  59. package/dist/slices/batch/index.js +8 -11
  60. package/dist/slices/cancel/index.js +78 -34
  61. package/dist/slices/checkpoints/change.js +250 -0
  62. package/dist/slices/checkpoints/fingerprint.js +33 -0
  63. package/dist/slices/checkpoints/index.js +2 -0
  64. package/dist/slices/checkpoints/model.js +1 -0
  65. package/dist/slices/checkpoints/recovery.js +116 -0
  66. package/dist/slices/checkpoints/repo.js +132 -0
  67. package/dist/slices/checkpoints/rules.js +30 -0
  68. package/dist/slices/checkpoints/schema.js +59 -0
  69. package/dist/slices/control/index.js +88 -22
  70. package/dist/slices/control/providers.js +3 -2
  71. package/dist/slices/control/revision-control-schema.js +7 -0
  72. package/dist/slices/control/revision-control.js +59 -0
  73. package/dist/slices/estimate/index.js +77 -59
  74. package/dist/slices/estimate/requests.js +102 -0
  75. package/dist/slices/images/run.js +14 -3
  76. package/dist/slices/library/slots.js +11 -11
  77. package/dist/slices/library/snapshot.js +33 -0
  78. package/dist/slices/narration/legacy-plan.js +58 -0
  79. package/dist/slices/narration/live.js +2 -2
  80. package/dist/slices/narration/plan.js +48 -54
  81. package/dist/slices/narration/run.js +21 -8
  82. package/dist/slices/play-drafts/convert.js +120 -0
  83. package/dist/slices/play-drafts/model.js +1 -0
  84. package/dist/slices/play-drafts/readiness.js +80 -0
  85. package/dist/slices/play-drafts/repo.js +76 -0
  86. package/dist/slices/play-drafts/review-inputs.js +150 -0
  87. package/dist/slices/play-drafts/review.js +109 -0
  88. package/dist/slices/play-drafts/schema.js +218 -0
  89. package/dist/slices/play-drafts/service.js +242 -0
  90. package/dist/slices/play-drafts/start-repo.js +113 -0
  91. package/dist/slices/play-drafts/start.js +148 -0
  92. package/dist/slices/play-drafts/uploads.js +67 -0
  93. package/dist/slices/project-templates/from-project.js +159 -0
  94. package/dist/slices/project-templates/model.js +1 -0
  95. package/dist/slices/project-templates/repo.js +41 -0
  96. package/dist/slices/project-templates/schema.js +37 -0
  97. package/dist/slices/project-templates/service.js +136 -0
  98. package/dist/slices/project-templates/setup.js +55 -0
  99. package/dist/slices/rebuild/admission-repo.js +120 -0
  100. package/dist/slices/rebuild/dependencies.js +33 -0
  101. package/dist/slices/rebuild/model.js +94 -0
  102. package/dist/slices/rebuild/narration-history.js +43 -0
  103. package/dist/slices/rebuild/narration-reuse.js +20 -0
  104. package/dist/slices/rebuild/preview-details.js +144 -0
  105. package/dist/slices/rebuild/preview-plan.js +223 -0
  106. package/dist/slices/rebuild/preview-retained.js +128 -0
  107. package/dist/slices/rebuild/provided-review.js +32 -0
  108. package/dist/slices/rebuild/recipe-audio.js +172 -0
  109. package/dist/slices/rebuild/recipe-build.js +17 -0
  110. package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
  111. package/dist/slices/rebuild/recipe-exports.js +65 -0
  112. package/dist/slices/rebuild/recipe-fixture.js +150 -0
  113. package/dist/slices/rebuild/recipe-input-schema.js +82 -0
  114. package/dist/slices/rebuild/recipe-legacy.js +135 -0
  115. package/dist/slices/rebuild/recipe-model.js +77 -0
  116. package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
  117. package/dist/slices/rebuild/recipe-save.js +221 -0
  118. package/dist/slices/rebuild/recipe-text.js +170 -0
  119. package/dist/slices/rebuild/recipe-validation.js +86 -0
  120. package/dist/slices/rebuild/recipe-visual.js +111 -0
  121. package/dist/slices/rebuild/recipe-work.js +212 -0
  122. package/dist/slices/rebuild/recipes.js +4 -0
  123. package/dist/slices/rebuild/repo.js +64 -0
  124. package/dist/slices/rebuild/runtime-actions.js +76 -0
  125. package/dist/slices/rebuild/runtime-admission.js +71 -0
  126. package/dist/slices/rebuild/runtime-article.js +117 -0
  127. package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
  128. package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
  129. package/dist/slices/rebuild/runtime-export.js +139 -0
  130. package/dist/slices/rebuild/runtime-instructions.js +51 -0
  131. package/dist/slices/rebuild/runtime-local.js +137 -0
  132. package/dist/slices/rebuild/runtime-materialize.js +105 -0
  133. package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
  134. package/dist/slices/rebuild/runtime-plan.js +103 -0
  135. package/dist/slices/rebuild/runtime-provider.js +184 -0
  136. package/dist/slices/rebuild/runtime-publication.js +172 -0
  137. package/dist/slices/rebuild/runtime-run.js +35 -0
  138. package/dist/slices/rebuild/runtime-store.js +166 -0
  139. package/dist/slices/rebuild/runtime-subtitles.js +202 -0
  140. package/dist/slices/rebuild/service-readiness.js +110 -0
  141. package/dist/slices/rebuild/service.js +149 -0
  142. package/dist/slices/rebuild/transition-repo.js +108 -0
  143. package/dist/slices/rebuild/work-records.js +44 -0
  144. package/dist/slices/reruns/index.js +5 -0
  145. package/dist/slices/research/run.js +33 -12
  146. package/dist/slices/revisions/adopt-assets.js +31 -0
  147. package/dist/slices/revisions/adopt-content.js +112 -0
  148. package/dist/slices/revisions/adopt.js +100 -0
  149. package/dist/slices/revisions/downloads.js +80 -0
  150. package/dist/slices/revisions/index.js +5 -0
  151. package/dist/slices/revisions/manifest-repo.js +125 -0
  152. package/dist/slices/revisions/model.js +1 -0
  153. package/dist/slices/revisions/mutation-assets.js +143 -0
  154. package/dist/slices/revisions/mutation-cues.js +95 -0
  155. package/dist/slices/revisions/mutation-prepare.js +182 -0
  156. package/dist/slices/revisions/mutation-request.js +58 -0
  157. package/dist/slices/revisions/mutation-work.js +47 -0
  158. package/dist/slices/revisions/mutations.js +194 -0
  159. package/dist/slices/revisions/projection.js +82 -0
  160. package/dist/slices/revisions/publication-model.js +1 -0
  161. package/dist/slices/revisions/publication-rules.js +166 -0
  162. package/dist/slices/revisions/publish.js +115 -0
  163. package/dist/slices/revisions/repo.js +83 -0
  164. package/dist/slices/revisions/restore.js +60 -0
  165. package/dist/slices/revisions/rules.js +99 -0
  166. package/dist/slices/revisions/schema.js +160 -0
  167. package/dist/slices/revisions/view.js +44 -0
  168. package/dist/slices/schedules/calendar.js +47 -0
  169. package/dist/slices/schedules/model.js +1 -0
  170. package/dist/slices/schedules/repo.js +281 -0
  171. package/dist/slices/schedules/scheduler.js +144 -0
  172. package/dist/slices/schedules/schema.js +80 -0
  173. package/dist/slices/schedules/service.js +178 -0
  174. package/dist/slices/settings/cli-paths.js +1 -1
  175. package/dist/slices/settings/cli-status.js +45 -5
  176. package/dist/slices/settings/tutorial-schema.js +39 -0
  177. package/dist/slices/settings/tutorial.js +66 -0
  178. package/dist/slices/storage/assets.js +108 -0
  179. package/dist/slices/storage/delete-project.js +2 -1
  180. package/dist/slices/storage/downloads.js +4 -1
  181. package/dist/slices/storage/portable.js +871 -0
  182. package/dist/slices/storage/prepare.js +67 -0
  183. package/dist/slices/storage/reconcile.js +25 -5
  184. package/dist/slices/storage/repo.js +2 -15
  185. package/dist/slices/storage/schema.js +35 -0
  186. package/dist/slices/storage/staging-refs.js +26 -0
  187. package/dist/slices/storage/staging.js +16 -11
  188. package/dist/slices/thumbnail/run.js +26 -9
  189. package/dist/slices/video/audio-export-args.js +33 -0
  190. package/dist/slices/video/audio-export.js +13 -36
  191. package/dist/slices/video/audio-inputs.js +4 -3
  192. package/dist/slices/video/ffmpeg.js +6 -0
  193. package/dist/slices/video/reuse-audio.js +11 -4
  194. package/dist/updater/install-flow.js +27 -2
  195. package/dist/updater/plan.js +28 -2
  196. package/dist/updater/service.js +27 -2
  197. package/dist/updater/worker.js +2 -1
  198. package/dist/web/assets/index-CO7DnVX9.js +137 -0
  199. package/dist/web/assets/index-DX1_chKs.css +1 -0
  200. package/dist/web/index.html +2 -2
  201. package/package.json +2 -2
  202. package/dist/adapters/tts/inworld-text.js +0 -32
  203. package/dist/web/assets/index-Ce2HApus.css +0 -1
  204. package/dist/web/assets/index-xbD0MZOY.js +0 -130
@@ -2,6 +2,9 @@ import { randomUUID } from "node:crypto";
2
2
  import { attempt } from "./attempt.js";
3
3
  export function stageProviders(deps, context, pieceId) {
4
4
  const ctx = {
5
+ work: context.work,
6
+ maySubmit: () => context.maySubmit(pieceId),
7
+ ...(pieceId === undefined ? {} : { workPieceId: pieceId }),
5
8
  clock: deps.clock,
6
9
  log: deps.log,
7
10
  attempts: deps.attempts,
@@ -20,6 +23,7 @@ export function stageProviders(deps, context, pieceId) {
20
23
  const callId = randomUUID();
21
24
  const preview = (text, reset) => context.emit({
22
25
  type: "llm.preview",
26
+ ...(pieceId === undefined ? {} : { workPieceId: pieceId }),
23
27
  projectId: context.stage.projectId,
24
28
  stage: context.stage.kind,
25
29
  callId,
@@ -34,6 +38,7 @@ export function stageProviders(deps, context, pieceId) {
34
38
  let finishReason = null;
35
39
  for await (const event of port.complete({
36
40
  model: call.model,
41
+ ...(call.thinkingConfig === undefined ? {} : { thinkingConfig: call.thinkingConfig }),
37
42
  ...(call.thinking === undefined ? {} : { thinking: call.thinking }),
38
43
  messages: call.messages,
39
44
  ...(call.webSearch === undefined ? {} : { webSearch: call.webSearch }),
@@ -64,7 +69,17 @@ export function stageProviders(deps, context, pieceId) {
64
69
  },
65
70
  tts: (call, observe) => {
66
71
  const port = deps.registry.tts(call.provider);
67
- let continuation;
72
+ let transientContinuation;
73
+ const continuation = {
74
+ read: () => pieceId === undefined
75
+ ? transientContinuation
76
+ : (deps.attempts.readContinuation?.(context.work, pieceId) ?? transientContinuation),
77
+ write: (token) => {
78
+ if (pieceId !== undefined)
79
+ deps.attempts.writeContinuation?.(context.work, pieceId, token);
80
+ transientContinuation = token;
81
+ },
82
+ };
68
83
  const notify = (event) => {
69
84
  try {
70
85
  observe?.(event);
@@ -78,7 +93,7 @@ export function stageProviders(deps, context, pieceId) {
78
93
  });
79
94
  }
80
95
  };
81
- return schedule(call.provider, () => attempt(ctx, async (signal, progress) => {
96
+ return schedule(call.provider, () => attempt({ ...ctx, continuation }, async (signal, progress) => {
82
97
  notify({ type: "start" });
83
98
  try {
84
99
  const spoken = await port.synthesize({
@@ -87,12 +102,7 @@ export function stageProviders(deps, context, pieceId) {
87
102
  text: call.text,
88
103
  signal,
89
104
  onActivity: progress,
90
- continuation: {
91
- read: () => continuation,
92
- write: (token) => {
93
- continuation = token;
94
- },
95
- },
105
+ continuation,
96
106
  });
97
107
  const reader = spoken.audio.getReader();
98
108
  const chunks = [];
@@ -0,0 +1,47 @@
1
+ import { transact } from "../db/tx.js";
2
+ import { maySubmit, ownsCurrentWork, workExists } from "./work-authority.js";
3
+ export function startWorkAttempt(db, ids, input, work, pieceId, operation = "submit") {
4
+ return transact(db, () => {
5
+ if (input.stageId !== work.stageId || !workExists(db, work))
6
+ return { ok: false, reason: "held" };
7
+ if (operation === "retrieve") {
8
+ if (pieceId === null || readWorkContinuation(db, work, pieceId) === null)
9
+ return { ok: false, reason: "held" };
10
+ const submitted = db
11
+ .prepare("SELECT id FROM attempts WHERE work_id=? AND work_piece_id=? ORDER BY rowid DESC LIMIT 1")
12
+ .get(work.workId, pieceId);
13
+ return typeof submitted?.id === "string"
14
+ ? { ok: true, value: submitted.id }
15
+ : { ok: false, reason: "held" };
16
+ }
17
+ else if (!maySubmit(db, work, pieceId ?? undefined) ||
18
+ (pieceId !== null && readWorkContinuation(db, work, pieceId) !== null))
19
+ return { ok: false, reason: "held" };
20
+ const id = ids.next();
21
+ db.prepare(`INSERT INTO attempts(id,stage_id,piece_id,n,started_at,revision_id,work_id,work_piece_id) VALUES (?,?,?,?,?,?,?,?)`).run(id, work.stageId, null, input.n, input.startedAt, work.revisionId, work.workId, pieceId);
22
+ if (operation === "submit") {
23
+ if (pieceId !== null)
24
+ db.prepare(`UPDATE revision_work_pieces SET submitted_at=COALESCE(submitted_at,?),state='running' WHERE id=? AND work_id=?`).run(input.startedAt, pieceId, work.workId);
25
+ if (ownsCurrentWork(db, work, pieceId))
26
+ db.prepare("UPDATE stages SET attempt_count=? WHERE id=?").run(input.n, work.stageId);
27
+ }
28
+ return { ok: true, value: id };
29
+ });
30
+ }
31
+ export function readWorkContinuation(db, work, pieceId) {
32
+ if (!workExists(db, work))
33
+ return null;
34
+ const row = db
35
+ .prepare("SELECT continuation FROM revision_work_pieces WHERE id=? AND work_id=? AND submitted_at IS NOT NULL")
36
+ .get(pieceId, work.workId);
37
+ // A malformed persisted token still records an accepted operation. Let the adapter
38
+ // reject it rather than turning corruption into another paid submission.
39
+ return typeof row?.continuation === "string" ? row.continuation : null;
40
+ }
41
+ export function writeWorkContinuation(db, work, pieceId, continuation) {
42
+ if (!workExists(db, work) || continuation.length === 0)
43
+ return false;
44
+ return (db
45
+ .prepare(`UPDATE revision_work_pieces SET continuation=? WHERE id=? AND work_id=? AND submitted_at IS NOT NULL AND (continuation IS NULL OR continuation=?)`)
46
+ .run(continuation, pieceId, work.workId, continuation).changes === 1);
47
+ }
@@ -0,0 +1,37 @@
1
+ export function workExists(db, work) {
2
+ return (db
3
+ .prepare(`SELECT 1 FROM revision_work WHERE id=? AND project_id=? AND revision_id=? AND stage_id=? AND kind=? AND fingerprint=?`)
4
+ .get(work.workId, work.projectId, work.revisionId, work.stageId, work.kind, work.fingerprint) !== undefined);
5
+ }
6
+ export function claimWork(db, work) {
7
+ return (db
8
+ .prepare(`UPDATE revision_work SET state='running',progress_current=NULL,progress_total=NULL WHERE id=? AND project_id=? AND revision_id=? AND stage_id=? AND kind=? AND fingerprint=? AND state='pending' AND dispatch_state='allowed'`)
9
+ .run(work.workId, work.projectId, work.revisionId, work.stageId, work.kind, work.fingerprint)
10
+ .changes === 1);
11
+ }
12
+ export function maySubmit(db, work, pieceId) {
13
+ if (!workExists(db, work))
14
+ return false;
15
+ const allowed = db
16
+ .prepare(`SELECT 1 FROM revision_work WHERE id=? AND state IN ('pending','running') AND dispatch_state='allowed'`)
17
+ .get(work.workId) !== undefined;
18
+ if (!allowed || !ownsCurrentWork(db, work, pieceId ?? null))
19
+ return false;
20
+ return (pieceId === undefined ||
21
+ db
22
+ .prepare(`SELECT 1 FROM revision_work_pieces WHERE id=? AND work_id=? AND dispatch_state='allowed' AND state IN ('pending','running')`)
23
+ .get(pieceId, work.workId) !== undefined);
24
+ }
25
+ export function ownsCurrentWork(db, work, pieceId) {
26
+ if (!workExists(db, work))
27
+ return false;
28
+ return (db
29
+ .prepare(`SELECT 1 FROM revision_work_reservations r JOIN project_heads h ON h.project_id=r.project_id AND h.revision_id=r.revision_id JOIN project_revisions v ON v.id=r.revision_id JOIN json_each(v.fingerprints) f ON f.key=COALESCE(r.logical_key,r.work_key) AND f.value=COALESCE(r.desired_fingerprint,r.fingerprint) WHERE r.project_id=? AND r.work_id=? AND r.piece_id IS ? AND (r.piece_id IS NULL OR EXISTS(SELECT 1 FROM revision_work_pieces p WHERE p.id=r.piece_id AND p.work_id=r.work_id AND p.work_key=r.work_key AND p.fingerprint=r.fingerprint))`)
30
+ .get(work.projectId, work.workId, pieceId) !== undefined);
31
+ }
32
+ export function finishWork(db, work, state, reason) {
33
+ if (!workExists(db, work))
34
+ return;
35
+ const normalized = state === "provided" || state === "skipped" ? "done" : state;
36
+ db.prepare(`UPDATE revision_work SET state=?,failure_reason=?,dispatch_state=CASE WHEN ?='pending' THEN 'held' ELSE dispatch_state END WHERE id=?`).run(normalized, reason, normalized, work.workId);
37
+ }
@@ -0,0 +1,23 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ export function fingerprint(value) {
4
+ function canonical(item) {
5
+ if (typeof item === "number")
6
+ z.number().finite().parse(item);
7
+ if (item === null || typeof item !== "object")
8
+ return JSON.stringify(item);
9
+ if (Array.isArray(item))
10
+ return `[${item.map(canonical).join(",")}]`;
11
+ const record = item;
12
+ return `{${Object.keys(record)
13
+ .sort()
14
+ .map((key) => {
15
+ const part = record[key];
16
+ if (part === undefined)
17
+ throw new Error("Missing fingerprint input");
18
+ return `${JSON.stringify(key)}:${canonical(part)}`;
19
+ })
20
+ .join(",")}}`;
21
+ }
22
+ return createHash("sha256").update(canonical(value)).digest("hex");
23
+ }
package/dist/main.js CHANGED
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { serve } from "@hono/node-server";
@@ -10,37 +10,45 @@ import { nodeRunCli } from "./adapters/llm/run-cli.js";
10
10
  import { curateRegistry } from "./catalog/registry.js";
11
11
  import { createCatalogueStore } from "./catalog/store.js";
12
12
  import { createHub } from "./edge/events/hub.js";
13
+ import { currentProjectEvent } from "./edge/events/visibility.js";
13
14
  import { createApp } from "./edge/http/app.js";
15
+ import { createMutationLifecycle, drainMutationsWithDeadline } from "./edge/http/mutations.js";
14
16
  import { openFolder } from "./edge/open-folder.js";
15
17
  import { createAudioPreviewStore } from "./kernel/audio-preview.js";
16
18
  import { systemClock } from "./kernel/clock.js";
17
19
  import { openDb } from "./kernel/db/index.js";
18
20
  import { migrate } from "./kernel/db/migrate.js";
21
+ import { transact } from "./kernel/db/tx.js";
19
22
  import { ulidIds } from "./kernel/ids.js";
20
23
  import { acquireInstanceLock } from "./kernel/lock.js";
21
24
  import { openLog } from "./kernel/log.js";
22
25
  import { ensureDirs, layout } from "./kernel/paths.js";
23
26
  import { sqliteAttempts } from "./kernel/runner/attempt-repo.js";
24
- import { dependenciesOf } from "./kernel/runner/graph.js";
27
+ import { createCheckpointAuthority, } from "./kernel/runner/checkpoint-authority.js";
25
28
  import { createRunner } from "./kernel/runner/index.js";
26
29
  import { stageProviders } from "./kernel/runner/providers.js";
27
30
  import { createProviderQueue } from "./kernel/runner/queue.js";
28
31
  import { readVersion } from "./kernel/version.js";
29
32
  import { modelSources } from "./model-catalog.js";
30
- import { claimStage, finishStage, projectById, projectPaused, stagesOf, } from "./slices/admission/repo.js";
31
- import { prepareProvidedArticleSegments } from "./slices/article/provided-entries.js";
32
- import { runArticle } from "./slices/article/run.js";
33
+ import { projectPaused } from "./slices/admission/repo.js";
33
34
  import { pumpQueue, queueWaiting } from "./slices/batch/index.js";
34
- import { runImages } from "./slices/images/run.js";
35
- import { runNarration } from "./slices/narration/run.js";
36
- import { runResearch } from "./slices/research/run.js";
35
+ import { approveCheckpoint } from "./slices/checkpoints/index.js";
36
+ import { checkpointDecisionForWork, recoverCheckpointWork, settleReleasedCheckpoints, } from "./slices/checkpoints/recovery.js";
37
+ import { resolveFont } from "./slices/fonts/index.js";
38
+ import { templateById } from "./slices/project-templates/repo.js";
39
+ import { claimWork, finishWork, maySubmit } from "./slices/rebuild/repo.js";
40
+ import { materializeAdmittedWork } from "./slices/rebuild/runtime-materialize.js";
41
+ import { runRevisionInvocation } from "./slices/rebuild/runtime-run.js";
42
+ import { executionStages, executionStandings, invocationReady, projectStandings, recordWorkProgress, } from "./slices/rebuild/runtime-store.js";
43
+ import { settleTerminalScheduleRuns } from "./slices/schedules/repo.js";
44
+ import { createScheduleRunner } from "./slices/schedules/scheduler.js";
37
45
  import { nodeCliProbe } from "./slices/settings/cli-status.js";
46
+ import { providerStatuses } from "./slices/settings/readiness.js";
38
47
  import { reconcileStorage } from "./slices/storage/reconcile.js";
39
48
  import { collectorEndpoint, httpPostEvents } from "./slices/telemetry/collector-client.js";
40
49
  import { createFlusher } from "./slices/telemetry/flush.js";
41
50
  import { record } from "./slices/telemetry/record.js";
42
- import { runThumbnail } from "./slices/thumbnail/run.js";
43
- import { renderVideo } from "./slices/video/run.js";
51
+ import { probeDurationMs } from "./slices/video/ffmpeg.js";
44
52
  import { watchActivation } from "./updater/candidate.js";
45
53
  import { launchUpdate } from "./updater/install.js";
46
54
  import { isUpdateToken } from "./updater/model.js";
@@ -52,10 +60,58 @@ import { createUpdater } from "./updater/service.js";
52
60
  // stay queued.
53
61
  const flushDelayMs = 1000;
54
62
  const collectorTimeoutMs = 10_000;
63
+ const mutationDrainTimeoutMs = 5_000;
64
+ export function createScheduleTickLifecycle(deps) {
65
+ let stopping = false;
66
+ let inflight;
67
+ const tick = () => {
68
+ if (stopping)
69
+ return Promise.resolve();
70
+ if (inflight !== undefined)
71
+ return inflight;
72
+ const release = deps.beginMutation();
73
+ if (release === undefined)
74
+ return Promise.resolve();
75
+ const running = (async () => {
76
+ try {
77
+ await deps.tick();
78
+ }
79
+ catch {
80
+ try {
81
+ deps.report();
82
+ }
83
+ catch {
84
+ // A logging failure must not turn a timer callback into an unhandled rejection.
85
+ }
86
+ }
87
+ finally {
88
+ release();
89
+ }
90
+ })();
91
+ inflight = running;
92
+ void running.then(() => {
93
+ if (inflight === running)
94
+ inflight = undefined;
95
+ }, () => {
96
+ if (inflight === running)
97
+ inflight = undefined;
98
+ });
99
+ return running;
100
+ };
101
+ return {
102
+ tick,
103
+ stop: () => {
104
+ stopping = true;
105
+ return inflight ?? Promise.resolve();
106
+ },
107
+ };
108
+ }
55
109
  export async function boot(config) {
56
110
  const clock = systemClock;
57
111
  const ids = ulidIds;
58
112
  const paths = layout(config.dataDir);
113
+ const candidateToken = process.env.SLOPIFY_UPDATE_TOKEN ?? "";
114
+ const pendingActivation = isUpdateToken(candidateToken) && process.env.SLOPIFY_UPDATE_PENDING === "1";
59
115
  ensureDirs(paths, { mode: 0o700 });
60
116
  const lock = acquireInstanceLock(paths.lock);
61
117
  let db;
@@ -68,13 +124,26 @@ export async function boot(config) {
68
124
  });
69
125
  db = openDb(paths.db);
70
126
  migrate(db, clock);
127
+ const runtimeDb = db;
71
128
  const interrupted = markInterruptedStages(db, clock);
72
- const reconciled = reconcileStorage(db, paths);
129
+ recoverCheckpointWork(db);
130
+ const settledSchedules = settleTerminalScheduleRuns(db, clock.now().toISOString());
131
+ // The updater can restore the database after a failed candidate boot, but it cannot
132
+ // restore files deleted by reconciliation. Leave the filesystem untouched until the
133
+ // candidate's committed activation pointer has been verified.
134
+ const reconciled = pendingActivation ? undefined : reconcileStorage(db, paths);
73
135
  const log = openLog(paths.logs, clock);
74
136
  log.write("info", "boot", {
75
- detail: `interrupted stages ${interrupted}, orphan files ${reconciled.orphanFiles}, staged files ${reconciled.stagedFiles}`,
137
+ detail: reconciled === undefined
138
+ ? `interrupted stages ${interrupted}, settled schedules ${settledSchedules}, storage reconciliation deferred during update activation`
139
+ : `interrupted stages ${interrupted}, settled schedules ${settledSchedules}, orphan files ${reconciled.orphanFiles}, staged files ${reconciled.stagedFiles}`,
140
+ });
141
+ const eventDb = db;
142
+ const hub = createHub({
143
+ ids,
144
+ log,
145
+ acceptEvent: (event) => currentProjectEvent(eventDb, event),
76
146
  });
77
- const hub = createHub({ ids, log });
78
147
  const version = readVersion();
79
148
  const telemetry = { db, ids, clock, log, appVersion: version };
80
149
  const flusher = createFlusher({
@@ -92,7 +161,7 @@ export async function boot(config) {
92
161
  probe: nodeCliProbe,
93
162
  }), catalogue);
94
163
  const audioPreviews = createAudioPreviewStore();
95
- const runner = wire({
164
+ const runner = wireRunner({
96
165
  db,
97
166
  paths,
98
167
  clock,
@@ -122,8 +191,6 @@ export async function boot(config) {
122
191
  throw new Error("The server is not ready.");
123
192
  };
124
193
  let listeningPort = config.port;
125
- const candidateToken = process.env.SLOPIFY_UPDATE_TOKEN ?? "";
126
- const pendingActivation = isUpdateToken(candidateToken) && process.env.SLOPIFY_UPDATE_PENDING === "1";
127
194
  const updater = createUpdater({
128
195
  ...(isUpdateToken(candidateToken)
129
196
  ? {
@@ -131,6 +198,21 @@ export async function boot(config) {
131
198
  token: candidateToken,
132
199
  pending: pendingActivation,
133
200
  committed: () => updateCommitted(paths.dataDir, version, candidateToken),
201
+ settle: () => {
202
+ try {
203
+ const settled = reconcileStorage(updateDb, paths);
204
+ log.write("info", "update", {
205
+ detail: `activation storage reconciliation removed ${settled.orphanFiles} orphan files and ${settled.stagedFiles} staged files`,
206
+ });
207
+ }
208
+ catch {
209
+ // The pointer is already committed, so storage maintenance can no longer
210
+ // safely reject or roll back this candidate. A normal restart retries it.
211
+ log.write("error", "update", {
212
+ detail: "Storage reconciliation failed after update activation.",
213
+ });
214
+ }
215
+ },
134
216
  },
135
217
  }
136
218
  : {}),
@@ -169,13 +251,64 @@ export async function boot(config) {
169
251
  });
170
252
  },
171
253
  });
254
+ const rebuild = {
255
+ db,
256
+ paths,
257
+ ids,
258
+ clock,
259
+ log,
260
+ runner,
261
+ catalogue,
262
+ measureAudio: (path, signal) => probeDurationMs(ffmpeg, path, signal ?? AbortSignal.timeout(30_000), log),
263
+ providers: () => providerStatuses({ db: updateDb, probe: nodeCliProbe }),
264
+ modelsFor: modelSources(registry).modelsFor,
265
+ emit: (projectId, event) => hub.emit(projectId, event),
266
+ };
267
+ const draftDeps = {
268
+ db,
269
+ paths,
270
+ ids,
271
+ clock,
272
+ log,
273
+ runner,
274
+ catalogue,
275
+ uuid: randomUUID,
276
+ resolveFont: (fontId) => resolveFont(paths, fontId),
277
+ providers: rebuild.providers,
278
+ modelsFor: rebuild.modelsFor,
279
+ emit: (event) => hub.emitGlobal(event),
280
+ recordStarted: (projectIds) => {
281
+ for (const _projectId of projectIds)
282
+ record(telemetry, "project.created", {});
283
+ flusher.soon();
284
+ },
285
+ };
286
+ const scheduleDeps = {
287
+ ...draftDeps,
288
+ template: (id, templateVersion) => templateById(runtimeDb, id, templateVersion),
289
+ };
290
+ const scheduleRunner = createScheduleRunner(scheduleDeps);
291
+ scheduleRunner.recover(clock.now());
292
+ const scheduleTicks = createScheduleTickLifecycle({
293
+ beginMutation: updater.beginMutation,
294
+ tick: scheduleRunner.tick,
295
+ report: () => log.write("error", "schedule.tick", {
296
+ detail: "Scheduled jobs could not be advanced.",
297
+ }),
298
+ });
299
+ const mutations = createMutationLifecycle();
172
300
  const app = createApp({
301
+ rebuild,
302
+ drafts: draftDeps,
303
+ schedules: scheduleDeps,
304
+ ...(rebuild.measureAudio === undefined ? {} : { measureAudio: rebuild.measureAudio }),
173
305
  openFolder,
174
306
  db,
175
307
  paths,
176
308
  hub,
177
309
  runner,
178
310
  updater,
311
+ mutations,
179
312
  audioPreviews,
180
313
  ...modelSources(registry),
181
314
  catalogue,
@@ -202,6 +335,10 @@ export async function boot(config) {
202
335
  release();
203
336
  }
204
337
  }, 1000);
338
+ const scheduleTimer = setInterval(() => {
339
+ void scheduleTicks.tick();
340
+ }, 15_000);
341
+ void scheduleTicks.tick();
205
342
  listeningPort = portOf(server) ?? config.port;
206
343
  // Whatever last run left queued goes out at start. Nothing waits for
207
344
  // it, and an unreachable collector costs one refused socket.
@@ -212,25 +349,34 @@ export async function boot(config) {
212
349
  shutdown = () => {
213
350
  stopping ??= (async () => {
214
351
  clearInterval(queueTimer);
352
+ clearInterval(scheduleTimer);
353
+ const mutationDrain = mutations.stop();
354
+ const scheduleDrain = scheduleTicks.stop();
355
+ const serverClose = beginServerClose(server);
215
356
  stopActivation();
216
357
  audioPreviews.close();
217
358
  try {
218
- // The listener goes first. Aborting the runner while the socket still accepted
219
- // requests let a Play arriving during the await start a stage under a fresh
220
- // controller nobody had aborted, which abortAll would then have waited out.
221
- // Only once nothing new can arrive is the runner drained and the database shut.
222
- await close(server);
359
+ // Admission closes before the listener. Requests already admitted keep the
360
+ // database until their response settles; anything racing shutdown receives 503.
361
+ await drainMutationsWithDeadline(mutationDrain, serverClose.terminate, mutationDrainTimeoutMs);
362
+ await scheduleDrain;
223
363
  await runner.abortAll();
224
364
  }
225
365
  finally {
226
- // The pending timer is cancelled rather than awaited: a shutdown must not wait
227
- // on the collector. A flush already in flight may land after the database
228
- // closes and fail to mark its batch delivered, which costs one re-send that
229
- // the collector deduplicates by event id.
230
- flusher.stop();
231
- log.write("info", "shutdown");
232
- open.close();
233
- lock.release();
366
+ serverClose.terminate();
367
+ try {
368
+ await serverClose.closed;
369
+ }
370
+ finally {
371
+ // The pending timer is cancelled rather than awaited: a shutdown must not wait
372
+ // on the collector. A flush already in flight may land after the database
373
+ // closes and fail to mark its batch delivered, which costs one re-send that
374
+ // the collector deduplicates by event id.
375
+ flusher.stop();
376
+ log.write("info", "shutdown");
377
+ open.close();
378
+ lock.release();
379
+ }
234
380
  }
235
381
  })();
236
382
  return stopping;
@@ -245,7 +391,7 @@ export async function boot(config) {
245
391
  throw error;
246
392
  }
247
393
  }
248
- function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, catalogue, ffmpeg, }) {
394
+ export function wireRunner({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flusher, registry, catalogue, ffmpeg, }) {
249
395
  // A stage counts what it did and the queue is flushed after each new event. `record`
250
396
  // swallows its own failures, so this can neither fail a stage nor widen what leaves the
251
397
  // machine - the payload allow-list is checked inside it.
@@ -253,12 +399,7 @@ function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flush
253
399
  record(telemetry, type, counters);
254
400
  flusher.soon();
255
401
  };
256
- // The audio stage joins its chunks with the same binary the render uses, so it takes
257
- // the same seven dependencies.
258
- const video = { db, paths, ids, clock, log, ffmpeg, count, alignSubtitles };
259
- // Research, the article, the images and the thumbnail all write into the same project
260
- // folder from the same database handle, so they take the same six dependencies.
261
- const writing = { db, paths, ids, clock, log, count };
402
+ const execution = { db, paths, ids, clock, log, ffmpeg, alignSubtitles, audioPreviews, count };
262
403
  // A stage slice is handed the wrapped calls, never the registry: every provider call
263
404
  // it makes is already inside the retry policy (kernel/runner/providers.ts).
264
405
  const providers = {
@@ -268,43 +409,55 @@ function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flush
268
409
  log,
269
410
  queue: createProviderQueue((provider) => catalogue.read().providers[provider]?.maxConcurrent ?? 1),
270
411
  };
271
- return createRunner({
412
+ const checkpoints = createCheckpointAuthority({
413
+ decide: (work) => checkpointDecisionForWork(execution, work),
414
+ approve: (projectId, checkpointId, identity) => approveCheckpoint(db, {
415
+ ...identity,
416
+ projectId,
417
+ checkpointId,
418
+ approvedAt: clock.now().toISOString(),
419
+ }),
420
+ inTransaction: () => db.isTransaction,
421
+ wake: (projectId) => runner.tick(projectId),
422
+ log,
423
+ });
424
+ const runner = createRunner({
425
+ checkpoints,
272
426
  stages: {
273
- stagesOf: (projectId) => stagesOf(db, projectId),
427
+ stagesOf: (projectId) => {
428
+ materializeAdmittedWork(execution, projectId);
429
+ projectStandings(execution, projectId);
430
+ return executionStages(execution, projectId);
431
+ },
432
+ standingsOf: (projectId) => executionStandings(execution, projectId),
433
+ ready: (work) => invocationReady(execution, work),
274
434
  paused: (projectId) => projectPaused(db, projectId) || queueWaiting(db, projectId),
275
- dependenciesOf: (projectId, kind) => {
276
- const project = projectById(db, projectId);
277
- return project === undefined ? [] : dependenciesOf(kind, project.config.sources);
435
+ claim: (work) => {
436
+ const claimed = claimWork(db, work);
437
+ projectStandings(execution, work.projectId);
438
+ return claimed;
278
439
  },
279
- claim: (stageId) => claimStage(db, stageId, clock.now().toISOString()),
280
- finish: (stageId, state, failureReason) => finishStage(db, stageId, state, failureReason, clock.now().toISOString()),
281
- },
282
- // Only the stages that exist. A pending stage with no entry here fails loudly with
283
- // that sentence rather than waiting for a runner that will never call it.
284
- runs: {
285
- research: (context) => runResearch(writing, context, stageProviders(providers, context)),
286
- article: (context) => runArticle(writing, context, stageProviders(providers, context)),
287
- audio: async (context) => {
288
- const wrapped = stageProviders(providers, context);
289
- await prepareProvidedArticleSegments(writing, context, wrapped);
290
- await runNarration({
291
- ...video,
292
- audioPreviews,
293
- maxCharacters: (provider, model) => {
294
- const entry = catalogue.models(provider, "tts").find((m) => m.id === model);
295
- return entry && "tts" in entry ? entry.tts.maxCharacters : 4000;
296
- },
297
- }, context, wrapped);
440
+ maySubmit: (work, pieceId) => maySubmit(db, work, pieceId),
441
+ finish: (work, state, reason) => {
442
+ transact(db, () => {
443
+ finishWork(db, work, state, reason);
444
+ materializeAdmittedWork(execution, work.projectId);
445
+ projectStandings(execution, work.projectId);
446
+ settleReleasedCheckpoints(execution, work.projectId);
447
+ });
298
448
  },
299
- images: (context) => runImages(writing, context, stageProviders(providers, context)),
300
- thumbnail: (context) => runThumbnail(writing, context, stageProviders(providers, context)),
301
- video: (context) => renderVideo(video, context),
302
449
  },
450
+ runs: Object.fromEntries(["research", "article", "audio", "images", "thumbnail", "video"].map((kind) => [
451
+ kind,
452
+ (context) => runRevisionInvocation(execution, context, stageProviders(providers, context)),
453
+ ])),
303
454
  // Counting is finer than a stage reaching `done` - each intro and outro text, each
304
455
  // narrated segment - and the counters are provider names, token usage and durations
305
456
  // only the stage slice ever sees. Each slice records its own units through `count`
306
457
  // above, and the runner counts nothing: the kernel may not import a slice.
307
458
  emit: (projectId, event) => {
459
+ if (event.type === "stage.progress")
460
+ recordWorkProgress(execution, event);
308
461
  hub.emit(projectId, event);
309
462
  },
310
463
  emitRunningCount: (running) => {
@@ -312,6 +465,7 @@ function wire({ audioPreviews, db, paths, clock, ids, log, hub, telemetry, flush
312
465
  },
313
466
  log,
314
467
  });
468
+ return { ...runner, checkpoints };
315
469
  }
316
470
  function listen(app, config, log) {
317
471
  return new Promise((resolve, reject) => {
@@ -325,13 +479,10 @@ function listen(app, config, log) {
325
479
  server.once("error", reject);
326
480
  });
327
481
  }
328
- function close(server) {
482
+ function beginServerClose(server) {
329
483
  // An SSE response never ends by itself, and close() waits for every open connection,
330
- // so a shutdown that only called close() would hang for as long as a page is open.
331
- if ("closeAllConnections" in server) {
332
- server.closeAllConnections();
333
- }
334
- return new Promise((resolve, reject) => {
484
+ // so admitted mutations drain first and then the remaining sockets are terminated.
485
+ const closed = new Promise((resolve, reject) => {
335
486
  server.close((error) => {
336
487
  if (error === undefined || error === null) {
337
488
  resolve();
@@ -340,6 +491,13 @@ function close(server) {
340
491
  reject(error);
341
492
  });
342
493
  });
494
+ return {
495
+ closed,
496
+ terminate: () => {
497
+ if ("closeAllConnections" in server)
498
+ server.closeAllConnections();
499
+ },
500
+ };
343
501
  }
344
502
  function portOf(server) {
345
503
  const address = server.address();