@gentbajko/slopify 0.8.4 → 1.0.1

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-DX1_chKs.css +1 -0
  199. package/dist/web/assets/index-DwM-Y6nw.js +137 -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
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ import { fingerprint } from "../../kernel/runner/work.js";
2
+ export function checkpointClosure(stage, work) {
3
+ const held = new Set(work.filter((row) => row.stage === stage).map((row) => row.key));
4
+ let previous = -1;
5
+ while (previous !== held.size) {
6
+ previous = held.size;
7
+ for (const row of work)
8
+ if (row.dependsOn.some((key) => held.has(key)))
9
+ held.add(row.key);
10
+ }
11
+ return work.filter((row) => held.has(row.key)).toSorted(byKey);
12
+ }
13
+ function byKey(a, b) {
14
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
15
+ }
16
+ export function checkpointFingerprint(revision, closure) {
17
+ return fingerprint({
18
+ projectId: revision.projectId,
19
+ work: closure.toSorted(byKey).map((row) => ({
20
+ key: row.key,
21
+ stage: row.stage,
22
+ kind: row.kind,
23
+ requestFingerprint: row.requestFingerprint,
24
+ fingerprint: row.fingerprint,
25
+ revisionFingerprint: revision.fingerprints[row.key] ?? null,
26
+ unresolved: row.unresolved,
27
+ dependencies: [...new Set(row.dependsOn)].sort().map((key) => ({
28
+ key,
29
+ fingerprint: revision.fingerprints[key] ?? null,
30
+ })),
31
+ })),
32
+ });
33
+ }
@@ -0,0 +1,2 @@
1
+ export { approveCheckpoint, checkpointForWork, listCheckpoints, saveCheckpointSet, settleCheckpointClosures, } from "./repo.js";
2
+ export { checkpointApprovalSchema, checkpointRowSchema, checkpointSetSchema, checkpointStageSchema, checkpointStateSchema, } from "./schema.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,116 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { workExists } from "../../kernel/runner/work-authority.js";
4
+ import { recoverWork } from "../rebuild/repo.js";
5
+ import { currentRevisionId, revisionById } from "../revisions/repo.js";
6
+ import { refreshCheckpointGate, resolvedGate } from "./change.js";
7
+ import { listCheckpoints, settleCheckpointClosures } from "./repo.js";
8
+ import { isApprovalCurrent } from "./rules.js";
9
+ export function checkpointDecisionForWork(deps, work) {
10
+ if (!workExists(deps.db, work))
11
+ return { kind: "refused", reason: "not-found" };
12
+ const head = currentRevisionId(deps.db, work.projectId);
13
+ const revision = head === undefined ? undefined : revisionById(deps.db, work.projectId, head);
14
+ if (!revision)
15
+ return { kind: "refused", reason: "not-found" };
16
+ const rows = listCheckpoints(deps.db, work.projectId, revision.id);
17
+ const held = [];
18
+ for (const row of rows) {
19
+ const gate = refreshCheckpointGate(deps, row);
20
+ if (!gate)
21
+ return { kind: "refused", reason: "conflict" };
22
+ const owned = deps.db
23
+ .prepare("SELECT work_key FROM revision_work_reservations WHERE project_id=? AND revision_id=? AND work_id=?")
24
+ .all(work.projectId, revision.id, work.workId);
25
+ if (!owned.some((entry) => gate.workKeys.includes(z.string().parse(entry.work_key))))
26
+ continue;
27
+ if (gate.state === "canceled")
28
+ return { kind: "refused", reason: "conflict" };
29
+ // A release authorizes this immutable revision, including artifacts produced by its work.
30
+ // New revisions revalidate the reviewed inputs in carryCheckpointGates.
31
+ if (gate.state === "released" && gate.approvedAt !== null)
32
+ continue;
33
+ if (!isApprovalCurrent(gate, revision, gate.currentFingerprint))
34
+ held.push(gate.checkpointId);
35
+ }
36
+ return held.length ? { kind: "held", checkpointIds: held.sort() } : { kind: "eligible" };
37
+ }
38
+ export function carryCheckpointGates(deps, baseRevisionId, revision) {
39
+ for (const gate of listCheckpoints(deps.db, revision.projectId, baseRevisionId)) {
40
+ const stored = deps.db
41
+ .prepare("SELECT stage_id,recipe_context FROM revision_work WHERE id=?")
42
+ .get(gate.workId);
43
+ if (!stored || typeof stored.recipe_context !== "string")
44
+ throw new Error("Checkpoint snapshot is missing");
45
+ const anchor = deps.ids.next();
46
+ deps.db
47
+ .prepare(`INSERT INTO revision_work(id,project_id,revision_id,stage_id,kind,fingerprint,state,dispatch_state,recipe_context,created_at)
48
+ VALUES (?,?,?,?,?,?,'done','held',?,?)`)
49
+ .run(anchor, revision.projectId, revision.id, z.string().parse(stored.stage_id), gate.stage, gate.fingerprint, stored.recipe_context, revision.createdAt);
50
+ const next = {
51
+ ...gate,
52
+ revisionId: revision.id,
53
+ workId: anchor,
54
+ createdAt: revision.createdAt,
55
+ };
56
+ const resolved = resolvedGate(deps, next);
57
+ if (!resolved)
58
+ throw new Error("Checkpoint inputs could not be resolved");
59
+ const before = resolvedGate(deps, gate, true);
60
+ const after = resolvedGate(deps, next, true);
61
+ const same = before !== undefined &&
62
+ after !== undefined &&
63
+ before.currentFingerprint === after.currentFingerprint;
64
+ if (!same && gate.state !== "canceled")
65
+ deps.db
66
+ .prepare("UPDATE review_checkpoints SET state='invalidated',approved_at=NULL WHERE project_id=? AND revision_id=? AND checkpoint_id=?")
67
+ .run(gate.projectId, gate.revisionId, gate.checkpointId);
68
+ deps.db
69
+ .prepare(`INSERT INTO review_checkpoints(project_id,revision_id,checkpoint_id,stage,work_id,fingerprint,state,created_at,approved_at)
70
+ VALUES (?,?,?,?,?,?,?,?,?)`)
71
+ .run(revision.projectId, revision.id, gate.checkpointId, gate.stage, anchor, resolved.currentFingerprint, same && gate.state !== "canceled" ? gate.state : "held", revision.createdAt, same && gate.state !== "canceled" ? gate.approvedAt : null);
72
+ }
73
+ settleReleasedCheckpoints(deps, revision.projectId, revision.id);
74
+ }
75
+ export function settleReleasedCheckpoints(deps, projectId, revisionId = currentRevisionId(deps.db, projectId)) {
76
+ if (revisionId === undefined || currentRevisionId(deps.db, projectId) !== revisionId)
77
+ return [];
78
+ const closures = listCheckpoints(deps.db, projectId, revisionId).flatMap((row) => {
79
+ if (row.state !== "released" || row.approvedAt === null)
80
+ return [];
81
+ const resolved = resolvedGate(deps, row);
82
+ return resolved === undefined
83
+ ? []
84
+ : [
85
+ {
86
+ projectId,
87
+ revisionId,
88
+ checkpointId: row.checkpointId,
89
+ workKeys: resolved.workKeys,
90
+ },
91
+ ];
92
+ });
93
+ return settleCheckpointClosures(deps.db, closures);
94
+ }
95
+ export function recoverCheckpointWork(db) {
96
+ transact(db, () => {
97
+ const retained = db
98
+ .prepare(`SELECT DISTINCT w.id FROM revision_work w
99
+ JOIN revision_work_reservations r ON r.work_id=w.id
100
+ JOIN project_heads h ON h.project_id=r.project_id AND h.revision_id=r.revision_id
101
+ JOIN review_checkpoints c ON c.project_id=h.project_id AND c.revision_id=h.revision_id
102
+ WHERE w.state='pending' AND w.dispatch_state='allowed'
103
+ AND c.state IN ('held','pending-review','released')
104
+ AND (w.kind=c.stage OR w.kind='video')
105
+ AND NOT EXISTS (SELECT 1 FROM stages s WHERE s.project_id=w.project_id AND s.state='canceled')
106
+ AND NOT EXISTS (SELECT 1 FROM revision_work_pieces p WHERE p.work_id=w.id
107
+ AND (p.submitted_at IS NOT NULL OR p.continuation IS NOT NULL OR p.state IN ('running','done')))`)
108
+ .all()
109
+ .map((row) => z.string().parse(row.id));
110
+ recoverWork(db);
111
+ for (const id of retained) {
112
+ db.prepare("UPDATE revision_work SET dispatch_state='allowed' WHERE id=? AND state='pending'").run(id);
113
+ db.prepare("UPDATE revision_work_pieces SET state='pending',dispatch_state='allowed' WHERE work_id=? AND submitted_at IS NULL AND continuation IS NULL").run(id);
114
+ }
115
+ });
116
+ }
@@ -0,0 +1,132 @@
1
+ import { transact } from "../../kernel/db/tx.js";
2
+ import { checkpointApprovalSchema, checkpointRowSchema, checkpointSetSchema } from "./schema.js";
3
+ const select = `SELECT project_id AS projectId,revision_id AS revisionId,checkpoint_id AS checkpointId,
4
+ stage,work_id AS workId,fingerprint,state,created_at AS createdAt,approved_at AS approvedAt
5
+ FROM review_checkpoints`;
6
+ export function listCheckpoints(db, projectId, revisionId) {
7
+ return db
8
+ .prepare(`${select} WHERE project_id=? AND revision_id=? ORDER BY checkpoint_id`)
9
+ .all(projectId, revisionId)
10
+ .map((row) => checkpointRowSchema.parse(row));
11
+ }
12
+ export function checkpointForWork(db, workId) {
13
+ return db
14
+ .prepare(`${select} WHERE (project_id,revision_id) IN
15
+ (SELECT project_id,revision_id FROM revision_work WHERE id=?) ORDER BY checkpoint_id`)
16
+ .all(workId)
17
+ .map((row) => checkpointRowSchema.parse(row));
18
+ }
19
+ /**
20
+ * Marks released checkpoints satisfied once every reservation in their exact
21
+ * reviewed closure has reached a terminal state. Missing reservations keep a
22
+ * checkpoint released: an incomplete materialization must never look complete.
23
+ */
24
+ export function settleCheckpointClosures(db, closures) {
25
+ const settle = () => {
26
+ const satisfied = [];
27
+ for (const closure of closures) {
28
+ if (closure.workKeys.length === 0 ||
29
+ new Set(closure.workKeys).size !== closure.workKeys.length)
30
+ continue;
31
+ const terminal = db.prepare(`SELECT 1 FROM revision_work_reservations r
32
+ JOIN revision_work w ON w.id=r.work_id
33
+ WHERE r.project_id=? AND r.revision_id=? AND r.work_key=?
34
+ AND w.state IN ('done','failed','canceled')`);
35
+ if (!closure.workKeys.every((key) => terminal.get(closure.projectId, closure.revisionId, key) !== undefined))
36
+ continue;
37
+ const changed = db
38
+ .prepare(`UPDATE review_checkpoints SET state='satisfied'
39
+ WHERE project_id=? AND revision_id=? AND checkpoint_id=?
40
+ AND state='released' AND approved_at IS NOT NULL`)
41
+ .run(closure.projectId, closure.revisionId, closure.checkpointId);
42
+ if (Number(changed.changes) !== 1)
43
+ continue;
44
+ const row = listCheckpoints(db, closure.projectId, closure.revisionId).find((candidate) => candidate.checkpointId === closure.checkpointId);
45
+ if (row !== undefined)
46
+ satisfied.push(row);
47
+ }
48
+ return satisfied;
49
+ };
50
+ return db.isTransaction ? settle() : transact(db, settle);
51
+ }
52
+ export function saveCheckpointSet(db, input) {
53
+ const parsed = checkpointSetSchema.safeParse(input);
54
+ if (!parsed.success)
55
+ return { ok: false, reason: "invalid-input" };
56
+ const value = parsed.data;
57
+ return transact(db, () => {
58
+ if (!db
59
+ .prepare("SELECT 1 FROM project_revisions WHERE project_id=? AND id=?")
60
+ .get(value.projectId, value.revisionId))
61
+ return { ok: false, reason: "not-found" };
62
+ if (new Set(value.checkpoints.map((gate) => gate.checkpointId)).size !==
63
+ value.checkpoints.length ||
64
+ new Set(value.checkpoints.map((gate) => gate.stage)).size !== value.checkpoints.length)
65
+ return { ok: false, reason: "duplicate" };
66
+ const existing = listCheckpoints(db, value.projectId, value.revisionId);
67
+ if (existing.length) {
68
+ const same = existing.length === value.checkpoints.length &&
69
+ existing.every((row) => value.checkpoints.some((gate) => gate.checkpointId === row.checkpointId &&
70
+ gate.stage === row.stage &&
71
+ gate.workId === row.workId &&
72
+ gate.fingerprint === row.fingerprint));
73
+ return same
74
+ ? { ok: false, reason: "duplicate", value: existing }
75
+ : { ok: false, reason: "conflict" };
76
+ }
77
+ for (const gate of value.checkpoints) {
78
+ const work = db
79
+ .prepare("SELECT project_id,revision_id,kind FROM revision_work WHERE id=?")
80
+ .get(gate.workId);
81
+ if (!work)
82
+ return { ok: false, reason: "not-found" };
83
+ if (work.project_id !== value.projectId ||
84
+ work.revision_id !== value.revisionId ||
85
+ work.kind !== gate.stage)
86
+ return { ok: false, reason: "conflict" };
87
+ }
88
+ for (const gate of value.checkpoints)
89
+ db.prepare(`INSERT INTO review_checkpoints(project_id,revision_id,checkpoint_id,stage,work_id,fingerprint,state,created_at)
90
+ VALUES (?,?,?,?,?,?,?,?)`).run(value.projectId, value.revisionId, gate.checkpointId, gate.stage, gate.workId, gate.fingerprint, gate.state, value.createdAt);
91
+ return { ok: true, value: listCheckpoints(db, value.projectId, value.revisionId) };
92
+ });
93
+ }
94
+ export function approveCheckpoint(db, input) {
95
+ const parsed = checkpointApprovalSchema.safeParse(input);
96
+ if (!parsed.success)
97
+ return { ok: false, reason: "invalid-input" };
98
+ const value = parsed.data;
99
+ return transact(db, () => {
100
+ const receipt = db
101
+ .prepare("SELECT revision_id,checkpoint_id,fingerprint FROM review_checkpoint_approvals WHERE project_id=? AND idempotency_key=?")
102
+ .get(value.projectId, value.idempotencyKey);
103
+ if (receipt &&
104
+ (receipt.revision_id !== value.revisionId ||
105
+ receipt.checkpoint_id !== value.checkpointId ||
106
+ receipt.fingerprint !== value.fingerprint))
107
+ return { ok: false, reason: "conflict" };
108
+ const row = listCheckpoints(db, value.projectId, value.revisionId).find((gate) => gate.checkpointId === value.checkpointId);
109
+ if (!row)
110
+ return { ok: false, reason: "not-found" };
111
+ const head = db
112
+ .prepare("SELECT revision_id FROM project_heads WHERE project_id=?")
113
+ .get(value.projectId);
114
+ if (head?.revision_id !== value.revisionId || row.fingerprint !== value.fingerprint)
115
+ return { ok: false, reason: "conflict" };
116
+ if (row.state === "released" || (row.state === "satisfied" && receipt))
117
+ return { ok: false, reason: "duplicate", value: row };
118
+ if (receipt)
119
+ return { ok: false, reason: "conflict" };
120
+ if (row.state !== "held" && row.state !== "pending-review")
121
+ return { ok: false, reason: "conflict" };
122
+ const changed = db
123
+ .prepare(`UPDATE review_checkpoints SET state='released',approved_at=?
124
+ WHERE project_id=? AND revision_id=? AND checkpoint_id=? AND fingerprint=? AND state=?`)
125
+ .run(value.approvedAt, value.projectId, value.revisionId, value.checkpointId, value.fingerprint, row.state);
126
+ if (Number(changed.changes) !== 1)
127
+ return { ok: false, reason: "conflict" };
128
+ db.prepare(`INSERT INTO review_checkpoint_approvals(project_id,idempotency_key,revision_id,checkpoint_id,fingerprint,approved_at)
129
+ VALUES (?,?,?,?,?,?)`).run(value.projectId, value.idempotencyKey, value.revisionId, value.checkpointId, value.fingerprint, value.approvedAt);
130
+ return { ok: true, value: { ...row, state: "released", approvedAt: value.approvedAt } };
131
+ });
132
+ }
@@ -0,0 +1,30 @@
1
+ import { checkpointClosure, checkpointFingerprint } from "./fingerprint.js";
2
+ export function isApprovalCurrent(row, revision, fingerprint) {
3
+ return (row.projectId === revision.projectId &&
4
+ row.revisionId === revision.id &&
5
+ row.fingerprint === fingerprint &&
6
+ row.approvedAt !== null &&
7
+ (row.state === "released" || row.state === "satisfied"));
8
+ }
9
+ export function canChangeCheckpoint(stageState, submitted) {
10
+ return stageState === "pending" && !submitted
11
+ ? { kind: "eligible" }
12
+ : { kind: "refused", reason: "conflict" };
13
+ }
14
+ export function checkpointDecision(work, rows, revision, recipes) {
15
+ if (work.projectId !== revision.projectId || work.revisionId !== revision.id)
16
+ return { kind: "refused", reason: "conflict" };
17
+ const held = new Set();
18
+ for (const row of rows) {
19
+ if (row.projectId !== revision.projectId || row.revisionId !== revision.id)
20
+ return { kind: "refused", reason: "conflict" };
21
+ const closure = checkpointClosure(row.stage, recipes);
22
+ if (!closure.some((recipe) => recipe.stage === work.kind))
23
+ continue;
24
+ if (row.state === "canceled")
25
+ return { kind: "refused", reason: "conflict" };
26
+ if (!isApprovalCurrent(row, revision, checkpointFingerprint(revision, closure)))
27
+ held.add(row.checkpointId);
28
+ }
29
+ return held.size ? { kind: "held", checkpointIds: [...held].sort() } : { kind: "eligible" };
30
+ }
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ const id = z.string().min(1);
3
+ const fingerprint = z.string().regex(/^[a-f0-9]{64}$/);
4
+ export const checkpointStageSchema = z.enum(["audio", "images", "video"]);
5
+ export const checkpointStateSchema = z.enum([
6
+ "configured",
7
+ "pending-review",
8
+ "held",
9
+ "released",
10
+ "satisfied",
11
+ "invalidated",
12
+ "canceled",
13
+ ]);
14
+ export const checkpointRowSchema = z
15
+ .object({
16
+ projectId: id,
17
+ revisionId: id,
18
+ checkpointId: id,
19
+ stage: checkpointStageSchema,
20
+ workId: id,
21
+ fingerprint,
22
+ state: checkpointStateSchema,
23
+ createdAt: z.iso.datetime(),
24
+ approvedAt: z.iso.datetime().nullable(),
25
+ })
26
+ .strict()
27
+ .readonly();
28
+ export const checkpointSetSchema = z
29
+ .object({
30
+ projectId: id,
31
+ revisionId: id,
32
+ checkpoints: z
33
+ .array(z
34
+ .object({
35
+ checkpointId: id,
36
+ stage: checkpointStageSchema,
37
+ workId: id,
38
+ fingerprint,
39
+ state: z.enum(["configured", "pending-review", "held"]),
40
+ })
41
+ .strict()
42
+ .readonly())
43
+ .max(3)
44
+ .readonly(),
45
+ createdAt: z.iso.datetime(),
46
+ })
47
+ .strict()
48
+ .readonly();
49
+ export const checkpointApprovalSchema = z
50
+ .object({
51
+ projectId: id,
52
+ revisionId: id,
53
+ checkpointId: id,
54
+ fingerprint,
55
+ idempotencyKey: z.uuid(),
56
+ approvedAt: z.iso.datetime(),
57
+ })
58
+ .strict()
59
+ .readonly();