@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
@@ -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();
@@ -1,12 +1,20 @@
1
+ import { z } from "zod";
1
2
  import { transact } from "../../kernel/db/tx.js";
2
3
  import { derive, satisfied } from "../../kernel/runner/graph.js";
3
4
  import { finishStage, projectById, resetStage, setProjectPaused, stagesOf, updateProjectConfig, } from "../admission/repo.js";
5
+ import { listCheckpoints } from "../checkpoints/repo.js";
4
6
  import { sameChunking } from "../narration/chunk.js";
7
+ import { projectStandings } from "../rebuild/runtime-store.js";
5
8
  import { clearUnfinishedAudio } from "../reruns/index.js";
9
+ import { adoptBaseline } from "../revisions/adopt.js";
10
+ import { saveRevision } from "../revisions/mutations.js";
11
+ import { currentRevisionId } from "../revisions/repo.js";
12
+ import { getRevisionView } from "../revisions/view.js";
6
13
  import { providers as providerCatalog } from "../settings/model.js";
7
14
  import { hasKey, listVoices } from "../settings/repo.js";
8
15
  import { withProjectControl } from "./lock.js";
9
16
  import { validateLocalProviderChanges, validateProviderChanges } from "./providers.js";
17
+ import { checkRevisionControl, rememberRevisionControl, } from "./revision-control.js";
10
18
  export const providerCheckTimeoutMs = 15_000;
11
19
  async function providerFields(deps, changes) {
12
20
  let timeout;
@@ -38,33 +46,63 @@ function changed(deps, id, wake = false) {
38
46
  state: derive(stagesOf(deps.db, id), projectById(deps.db, id)?.paused),
39
47
  });
40
48
  }
41
- export function pauseProject(deps, id) {
49
+ export function pauseProject(deps, id, input) {
42
50
  return withProjectControl(deps.db, id, async () => {
43
- const project = projectById(deps.db, id);
44
- if (project === undefined)
45
- return { ok: false, reason: "no-project" };
46
- const before = stagesOf(deps.db, id);
47
- if (before.every((stage) => satisfied(stage.state)))
48
- return { ok: true };
49
- const running = before.filter((stage) => stage.state === "running").map((stage) => stage.id);
50
- if (project.paused === true && running.length === 0 && !deps.runner.hasInflight?.(id))
51
- return { ok: true };
52
- transact(deps.db, () => setProjectPaused(deps.db, id, true, deps.clock.now().toISOString()));
53
- deps.emit(id, { type: "project.updated", projectId: id });
54
- await deps.runner.abortProject(id, "pause");
55
- // Covers a failed final row write and runners that report interruption as canceled.
56
- for (const stage of stagesOf(deps.db, id)) {
57
- if (running.includes(stage.id) && (stage.state === "running" || stage.state === "canceled")) {
58
- finishStage(deps.db, stage.id, "pending", null, deps.clock.now().toISOString());
59
- deps.emit(id, { type: "stage.state", projectId: id, stage: stage.kind, state: "pending" });
60
- }
61
- }
62
- changed(deps, id);
63
- return { ok: true };
51
+ if (deps.catalogue !== undefined)
52
+ adoptBaseline(deps, id);
53
+ const checked = checkRevisionControl(deps, id, "pause", input, z.object({ ok: z.literal(true) }));
54
+ if (!checked.ok)
55
+ return checked;
56
+ if (checked.response !== undefined)
57
+ return checked.response;
58
+ const result = await pause(deps, id);
59
+ if (result.ok)
60
+ rememberRevisionControl(deps, checked.identity, result);
61
+ return result;
64
62
  });
65
63
  }
64
+ async function pause(deps, id) {
65
+ const project = projectById(deps.db, id);
66
+ if (project === undefined)
67
+ return { ok: false, reason: "no-project" };
68
+ const before = stagesOf(deps.db, id);
69
+ if (before.every((stage) => satisfied(stage.state)))
70
+ return { ok: true };
71
+ const running = before.filter((stage) => stage.state === "running").map((stage) => stage.id);
72
+ if (project.paused === true && running.length === 0 && !deps.runner.hasInflight?.(id))
73
+ return { ok: true };
74
+ transact(deps.db, () => setProjectPaused(deps.db, id, true, deps.clock.now().toISOString()));
75
+ deps.emit(id, { type: "project.updated", projectId: id });
76
+ await deps.runner.abortProject(id, "pause");
77
+ // Covers a failed final row write and runners that report interruption as canceled.
78
+ for (const stage of stagesOf(deps.db, id)) {
79
+ if (running.includes(stage.id) && (stage.state === "running" || stage.state === "canceled")) {
80
+ finishStage(deps.db, stage.id, "pending", null, deps.clock.now().toISOString());
81
+ deps.emit(id, { type: "stage.state", projectId: id, stage: stage.kind, state: "pending" });
82
+ }
83
+ }
84
+ changed(deps, id);
85
+ return { ok: true };
86
+ }
66
87
  export function resumeProject(deps, id) {
67
88
  return withProjectControl(deps.db, id, () => {
89
+ if (deps.catalogue !== undefined)
90
+ adoptBaseline(deps, id);
91
+ const revisionId = currentRevisionId(deps.db, id);
92
+ if (revisionId !== undefined) {
93
+ const admitted = projectById(deps.db, id)?.paused === true &&
94
+ deps.db
95
+ .prepare(`SELECT 1 FROM revision_work w JOIN revision_work_reservations r ON r.work_id=w.id
96
+ WHERE r.project_id=? AND r.revision_id=? AND w.state='pending' AND w.dispatch_state='allowed' LIMIT 1`)
97
+ .get(id, revisionId) !== undefined;
98
+ if (listCheckpoints(deps.db, id, revisionId).length === 0 && !admitted)
99
+ return { ok: false, reason: "rebuild-required" };
100
+ if (stagesOf(deps.db, id).some((stage) => stage.state === "canceled"))
101
+ return { ok: false, reason: "rebuild-required" };
102
+ setProjectPaused(deps.db, id, false, deps.clock.now().toISOString());
103
+ changed(deps, id, true);
104
+ return { ok: true };
105
+ }
68
106
  const project = projectById(deps.db, id);
69
107
  if (project === undefined)
70
108
  return { ok: false, reason: "no-project" };
@@ -134,6 +172,34 @@ export function changeProviders(deps, id, changes) {
134
172
  });
135
173
  if (localFields.length > 0)
136
174
  return { ok: false, reason: "invalid-providers", fields: localFields };
175
+ if (deps.catalogue !== undefined)
176
+ adoptBaseline(deps, id);
177
+ const revisionId = currentRevisionId(deps.db, id);
178
+ if (revisionId !== undefined) {
179
+ const view = getRevisionView(deps, id, revisionId);
180
+ if (view === undefined)
181
+ return { ok: false, reason: "no-project" };
182
+ const saved = await saveRevision(deps, {
183
+ projectId: id,
184
+ baseRevisionId: revisionId,
185
+ idempotencyKey: deps.ids.next(),
186
+ edit: {
187
+ config: {
188
+ ...view.revision.config,
189
+ ...(changes.llm === undefined ? {} : { llm: changes.llm }),
190
+ ...(changes.audio === undefined ? {} : { audio: changes.audio }),
191
+ ...(changes.images === undefined ? {} : { images: changes.images }),
192
+ ...(changes.chunking === undefined ? {} : { chunking: changes.chunking }),
193
+ },
194
+ content: view.revision.content,
195
+ },
196
+ });
197
+ if (!saved.ok)
198
+ return { ok: false, reason: "not-editable" };
199
+ projectStandings(deps, id);
200
+ deps.emit(id, { type: "project.updated", projectId: id });
201
+ return { ok: true };
202
+ }
137
203
  const audioChanged = changes.audio !== undefined &&
138
204
  (changes.audio.provider !== project.config.audio?.provider ||
139
205
  changes.audio.model !== project.config.audio?.model ||
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { thinkingModes } from "../../kernel/ports/llm.js";
3
+ import { readinessIsUsable, } from "../../kernel/ports/model.js";
3
4
  import { chunkModes } from "../narration/chunk.js";
4
5
  const choice = z
5
6
  .object({
@@ -36,12 +37,12 @@ export function validateLocalProviderChanges(changes, current) {
36
37
  fields.push({ field: key, message: "Choose a provider for this kind of generation." });
37
38
  continue;
38
39
  }
39
- const usable = provider.readiness.kind === "cli" ? provider.readiness.installed : provider.readiness.hasKey;
40
+ const usable = readinessIsUsable(provider.readiness);
40
41
  if (!usable) {
41
42
  fields.push({
42
43
  field: key,
43
44
  message: provider.readiness.kind === "cli"
44
- ? "Install and sign in to this CLI first."
45
+ ? (provider.readiness.issue ?? "Install and sign in to this CLI first.")
45
46
  : "Save this provider's API key in Settings first.",
46
47
  });
47
48
  }
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ export const revisionControlSchema = z
3
+ .object({
4
+ baseRevisionId: z.string().min(1).max(64),
5
+ idempotencyKey: z.uuid(),
6
+ })
7
+ .strict();
@@ -0,0 +1,59 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { projectExists } from "../admission/repo.js";
4
+ import { requestHash } from "../revisions/mutation-request.js";
5
+ import { currentRevisionId } from "../revisions/repo.js";
6
+ export { revisionControlSchema } from "./revision-control-schema.js";
7
+ import { revisionControlSchema } from "./revision-control-schema.js";
8
+ export function checkRevisionControl(deps, projectId, operation, input, responseSchema) {
9
+ const { db } = deps;
10
+ return transact(db, () => {
11
+ if (!projectExists(db, projectId))
12
+ return { ok: false, reason: "no-project" };
13
+ const head = currentRevisionId(db, projectId);
14
+ if (head === undefined)
15
+ return { ok: true, identity: null };
16
+ const parsed = revisionControlSchema.safeParse(input);
17
+ if (!parsed.success)
18
+ return { ok: false, reason: "revision-required" };
19
+ const identity = {
20
+ ...parsed.data,
21
+ projectId,
22
+ operation,
23
+ hash: requestHash(operation, parsed.data.baseRevisionId, parsed.data),
24
+ };
25
+ const row = db
26
+ .prepare("SELECT operation,request_hash,response_json FROM project_control_receipts WHERE project_id=? AND idempotency_key=?")
27
+ .get(projectId, identity.idempotencyKey);
28
+ if (row !== undefined) {
29
+ if (row.operation !== operation || row.request_hash !== identity.hash)
30
+ return { ok: false, reason: "idempotency-conflict" };
31
+ const response = JSON.parse(String(row.response_json));
32
+ if (z
33
+ .object({ pending: z.literal(true) })
34
+ .strict()
35
+ .safeParse(response).success)
36
+ return head === identity.baseRevisionId
37
+ ? { ok: true, identity }
38
+ : { ok: false, reason: "conflict" };
39
+ return { ok: true, identity: null, response: responseSchema.parse(response) };
40
+ }
41
+ if (db
42
+ .prepare("SELECT 1 FROM revision_mutations WHERE project_id=? AND idempotency_key=? UNION ALL SELECT 1 FROM rebuild_admissions WHERE project_id=? AND idempotency_key=?")
43
+ .get(projectId, identity.idempotencyKey, projectId, identity.idempotencyKey) !== undefined)
44
+ return { ok: false, reason: "idempotency-conflict" };
45
+ if (head !== identity.baseRevisionId)
46
+ return { ok: false, reason: "conflict" };
47
+ db.prepare("INSERT INTO project_control_receipts(project_id,idempotency_key,operation,request_hash,base_revision_id,response_json,created_at) VALUES (?,?,?,?,?,?,?)").run(identity.projectId, identity.idempotencyKey, identity.operation, identity.hash, identity.baseRevisionId, JSON.stringify({ pending: true }), deps.clock.now().toISOString());
48
+ return { ok: true, identity };
49
+ });
50
+ }
51
+ export function rememberRevisionControl(deps, identity, response) {
52
+ if (identity === null)
53
+ return;
54
+ const changed = deps.db
55
+ .prepare("UPDATE project_control_receipts SET response_json=? WHERE project_id=? AND idempotency_key=? AND operation=? AND request_hash=?")
56
+ .run(JSON.stringify(response), identity.projectId, identity.idempotencyKey, identity.operation, identity.hash);
57
+ if (changed.changes !== 1)
58
+ throw new Error("The control request lost its receipt.");
59
+ }