@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
@@ -0,0 +1,182 @@
1
+ import { z } from "zod";
2
+ import { plainText } from "../article/plain.js";
3
+ import { splitEndMatter } from "../article/split.js";
4
+ import { normalizeArticleIntent } from "../rebuild/recipe-save.js";
5
+ import { discardPreparedAssets } from "../storage/assets.js";
6
+ import { prepareStagedFile, prepareText } from "../storage/prepare.js";
7
+ import { outputSchema } from "../storage/schema.js";
8
+ import { bindUpload, measureAudio, providedAssetSelected, } from "./mutation-assets.js";
9
+ export async function prepareEditAssets(deps, base, edit) {
10
+ const prepared = [];
11
+ const allocated = [];
12
+ try {
13
+ const projectId = base.revision.projectId;
14
+ let content = normalizeArticleIntent(base, edit);
15
+ for (const upload of edit.uploads ?? []) {
16
+ const to = upload.destination;
17
+ const role = to.kind === "image"
18
+ ? "image"
19
+ : to.kind === "provided" && to.stage === "thumbnail"
20
+ ? "thumbnail"
21
+ : "audio_body";
22
+ const result = prepareStagedFile(deps, {
23
+ projectId,
24
+ stagedFileId: upload.stagedFileId,
25
+ role,
26
+ index: to.kind === "image" ? content.imageOrder.indexOf(to.imageKey) + 1 : 1,
27
+ });
28
+ if (!result.ok)
29
+ throw new Error("A validated upload became unavailable.");
30
+ const workKey = to.kind === "image"
31
+ ? `image:${to.imageKey}`
32
+ : to.kind === "narration"
33
+ ? to.key
34
+ : to.stage === "audio"
35
+ ? "audio:provided"
36
+ : "thumbnail:image";
37
+ const item = {
38
+ ...result,
39
+ upload,
40
+ workKey,
41
+ slot: to.kind === "image"
42
+ ? workKey
43
+ : to.kind === "narration"
44
+ ? workKey
45
+ : `${result.output.stageKind}:${role}`,
46
+ };
47
+ allocated.push(result.asset);
48
+ const durationMs = result.output.stageKind === "audio"
49
+ ? await measureAudio(deps, projectId, result.asset.path)
50
+ : item.output.durationMs;
51
+ prepared.push({ ...item, output: { ...item.output, durationMs } });
52
+ content = bindUpload(content, upload, result.asset.id);
53
+ }
54
+ for (const [index, key] of content.imageOrder.entries()) {
55
+ const definition = content.imageDefinitions[key];
56
+ const assetId = definition?.assetId;
57
+ const workKey = `image:${key}`;
58
+ if (definition?.source !== "provide" ||
59
+ assetId == null ||
60
+ prepared.some((row) => row.workKey === workKey) ||
61
+ base.outputs.some((row) => row.selected &&
62
+ row.workKey === workKey &&
63
+ row.assetId === assetId &&
64
+ row.output.role === "image"))
65
+ continue;
66
+ const asset = retainedAsset(deps, projectId, assetId);
67
+ const existing = deps.db
68
+ .prepare("SELECT descriptor FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')='images' AND json_extract(descriptor,'$.role')='image' LIMIT 1")
69
+ .get(projectId, assetId);
70
+ const descriptor = existing === undefined
71
+ ? {
72
+ projectId,
73
+ stageKind: "images",
74
+ role: "image",
75
+ path: asset.path,
76
+ originalFilename: null,
77
+ bytes: asset.bytes,
78
+ durationMs: null,
79
+ meta: {},
80
+ createdAt: asset.createdAt,
81
+ }
82
+ : outputSchema.parse(JSON.parse(z.string().parse(existing.descriptor)));
83
+ prepared.push({
84
+ asset,
85
+ output: {
86
+ ...descriptor,
87
+ id: deps.ids.next(),
88
+ meta: { ...descriptor.meta, index: index + 1 },
89
+ },
90
+ workKey,
91
+ slot: workKey,
92
+ });
93
+ }
94
+ for (const kind of ["audio", "thumbnail"]) {
95
+ const assetId = content.provided[kind];
96
+ if (edit.config.sources[kind] !== "provide" ||
97
+ assetId === undefined ||
98
+ providedAssetSelected(base, kind, assetId) ||
99
+ prepared.some((row) => row.asset.id === assetId))
100
+ continue;
101
+ const existing = deps.db
102
+ .prepare("SELECT descriptor FROM revision_outputs WHERE project_id=? AND asset_id=? AND json_extract(descriptor,'$.stageKind')=? LIMIT 1")
103
+ .get(projectId, assetId, kind);
104
+ if (existing === undefined)
105
+ throw new Error("Validated replacement asset has no descriptor.");
106
+ const descriptor = outputSchema.parse(JSON.parse(z.string().parse(existing.descriptor)));
107
+ const asset = retainedAsset(deps, projectId, assetId);
108
+ const role = kind === "audio" ? "audio_body" : "thumbnail";
109
+ const item = {
110
+ asset,
111
+ output: {
112
+ ...descriptor,
113
+ id: deps.ids.next(),
114
+ role,
115
+ durationMs: kind === "audio"
116
+ ? await measureAudio(deps, projectId, asset.path)
117
+ : descriptor.durationMs,
118
+ },
119
+ workKey: kind === "audio" ? "audio:provided" : "thumbnail:image",
120
+ slot: `${kind}:${role}`,
121
+ };
122
+ prepared.push(item);
123
+ }
124
+ const article = edit.config.sources.article === "provide" || content.articleEdited === true
125
+ ? (content.articleMarkdown ?? edit.config.provided.article)
126
+ : undefined;
127
+ if (article !== undefined &&
128
+ (article !== base.articleMarkdown ||
129
+ !base.outputs.some((row) => row.selected && row.available && row.output.role === "article_md"))) {
130
+ const end = splitEndMatter(article);
131
+ const texts = [
132
+ ["article_md", article],
133
+ ["article_txt", plainText(end.body)],
134
+ ["sources", end.sources],
135
+ ["glossary", end.glossary],
136
+ ];
137
+ for (const [role, text] of texts)
138
+ if (text.trim() !== "" || role === "article_md" || role === "article_txt") {
139
+ const row = prepareText(deps, { projectId, stageKind: "article", role, text });
140
+ allocated.push(row.asset);
141
+ prepared.push({ ...row, workKey: "article:body", slot: `article:${role}` });
142
+ }
143
+ }
144
+ if (edit.config.sources.research === "provide" &&
145
+ (edit.config.provided.research !== base.revision.config.provided.research ||
146
+ !base.outputs.some((row) => row.selected && row.output.role === "notes"))) {
147
+ const row = prepareText(deps, {
148
+ projectId,
149
+ stageKind: "research",
150
+ role: "notes",
151
+ text: edit.config.provided.research ?? "",
152
+ });
153
+ allocated.push(row.asset);
154
+ prepared.push({ ...row, workKey: "research:notes", slot: "research:notes" });
155
+ }
156
+ return { edit: { ...edit, content }, assets: prepared };
157
+ }
158
+ catch (error) {
159
+ discardPreparedAssets(deps, allocated);
160
+ throw error;
161
+ }
162
+ }
163
+ function retainedAsset(deps, projectId, assetId) {
164
+ const row = z
165
+ .object({
166
+ id: z.string(),
167
+ project_id: z.string(),
168
+ path: z.string(),
169
+ bytes: z.number(),
170
+ created_at: z.string(),
171
+ })
172
+ .parse(deps.db
173
+ .prepare("SELECT * FROM project_assets WHERE project_id=? AND id=?")
174
+ .get(projectId, assetId));
175
+ return {
176
+ id: row.id,
177
+ projectId: row.project_id,
178
+ path: row.path,
179
+ bytes: row.bytes,
180
+ createdAt: row.created_at,
181
+ };
182
+ }
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { currentRevisionId } from "./repo.js";
4
+ import { getRevisionView } from "./view.js";
5
+ export function requestHash(operation, baseRevisionId, input) {
6
+ return createHash("sha256")
7
+ .update(JSON.stringify([operation, baseRevisionId, input], (_key, value) => {
8
+ if (typeof value !== "object" || value === null || Array.isArray(value))
9
+ return value;
10
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
11
+ }))
12
+ .digest("hex");
13
+ }
14
+ export function refusal(deps, projectId, reason) {
15
+ return { ok: false, reason, currentRevisionId: currentRevisionId(deps.db, projectId) ?? null };
16
+ }
17
+ export function checkMutation(deps, input) {
18
+ const { db } = deps;
19
+ const row = db
20
+ .prepare("SELECT operation,request_hash,result_revision_id FROM revision_mutations WHERE project_id=? AND idempotency_key=?")
21
+ .get(input.projectId, input.idempotencyKey);
22
+ if (row !== undefined) {
23
+ const receipt = z
24
+ .object({ operation: z.string(), request_hash: z.string(), result_revision_id: z.string() })
25
+ .parse(row);
26
+ if (receipt.operation !== input.operation || receipt.request_hash !== input.hash)
27
+ return refusal(deps, input.projectId, "idempotency-conflict");
28
+ return {
29
+ ok: true,
30
+ view: requiredView(deps, input.projectId, receipt.result_revision_id),
31
+ duplicate: true,
32
+ };
33
+ }
34
+ if (db
35
+ .prepare("SELECT 1 FROM rebuild_admissions WHERE project_id=? AND idempotency_key=?")
36
+ .get(input.projectId, input.idempotencyKey) !== undefined)
37
+ return refusal(deps, input.projectId, "idempotency-conflict");
38
+ if (db
39
+ .prepare("SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=?")
40
+ .get(input.projectId, input.idempotencyKey) !== undefined)
41
+ return refusal(deps, input.projectId, "idempotency-conflict");
42
+ if (db.prepare("SELECT 1 FROM projects WHERE id=?").get(input.projectId) === undefined)
43
+ return refusal(deps, input.projectId, "no-project");
44
+ if (currentRevisionId(db, input.projectId) !== input.baseRevisionId)
45
+ return refusal(deps, input.projectId, "conflict");
46
+ return undefined;
47
+ }
48
+ export function requiredView(deps, projectId, revisionId) {
49
+ const view = getRevisionView(deps, projectId, revisionId);
50
+ if (view === undefined)
51
+ throw new Error("The retained revision was not found.");
52
+ return view;
53
+ }
54
+ export function insertReceipt(deps, input, revisionId) {
55
+ deps.db
56
+ .prepare("INSERT INTO revision_mutations(project_id,idempotency_key,operation,request_hash,base_revision_id,result_revision_id,created_at) VALUES (?,?,?,?,?,?,?)")
57
+ .run(input.projectId, input.idempotencyKey, input.operation, input.hash, input.baseRevisionId, revisionId, deps.clock.now().toISOString());
58
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { buildRecipes } from "../rebuild/recipe-build.js";
3
+ import { recipeInputSchema } from "../rebuild/recipe-input-schema.js";
4
+ export function preserveDeferredIntent(deps, base, plan) {
5
+ const rows = deps.db
6
+ .prepare("SELECT p.input_json,r.work_key,r.fingerprint,r.logical_key,r.desired_fingerprint FROM revision_work_reservations r JOIN revision_work_pieces p ON p.id=r.piece_id JOIN revision_work w ON w.id=r.work_id WHERE r.project_id=? AND r.revision_id=? AND (w.dispatch_state='allowed' OR p.dispatch_state='allowed')")
7
+ .all(base.revision.projectId, base.revision.id);
8
+ const before = buildRecipes({
9
+ config: base.revision.config,
10
+ content: base.revision.content,
11
+ manifest: {
12
+ outputs: base.outputs.filter((row) => row.selected),
13
+ pieces: base.pieces.filter((row) => row.selected),
14
+ },
15
+ resolved: {
16
+ articleMarkdown: null,
17
+ researchNotes: base.revision.config.provided.research ?? null,
18
+ },
19
+ });
20
+ const after = buildRecipes({
21
+ config: plan.config,
22
+ content: plan.content,
23
+ manifest: plan.manifest,
24
+ resolved: { articleMarkdown: null, researchNotes: plan.config.provided.research ?? null },
25
+ });
26
+ const fingerprints = { ...plan.fingerprints };
27
+ const baseFingerprints = { ...plan.baseFingerprints };
28
+ const recipes = [...plan.recipes];
29
+ for (const row of rows) {
30
+ const input = recipeInputSchema.parse(JSON.parse(z.string().parse(row.input_json)));
31
+ const key = z.string().parse(row.logical_key ?? row.work_key);
32
+ if (input.kind !== "deferred" || fingerprints[key] !== undefined)
33
+ continue;
34
+ const old = before.find((one) => one.key === key);
35
+ const next = after.find((one) => one.key === key);
36
+ const desired = z.string().parse(row.desired_fingerprint ?? row.fingerprint);
37
+ if (old !== undefined &&
38
+ next !== undefined &&
39
+ old.fingerprint === next.fingerprint &&
40
+ old.fingerprint === desired) {
41
+ fingerprints[key] = desired;
42
+ baseFingerprints[key] = desired;
43
+ recipes.push(next);
44
+ }
45
+ }
46
+ return { ...plan, fingerprints, baseFingerprints, recipes };
47
+ }
@@ -0,0 +1,194 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { carryCheckpointGates } from "../checkpoints/recovery.js";
4
+ import { narrationRegenerationKey } from "../narration/plan.js";
5
+ import { recipeInputSchema } from "../rebuild/recipe-input-schema.js";
6
+ import { planRevision } from "../rebuild/recipe-save.js";
7
+ import { transitionRevisionWork } from "../rebuild/repo.js";
8
+ import { projectStandings } from "../rebuild/runtime-store.js";
9
+ import { discardPreparedAssets } from "../storage/assets.js";
10
+ import { deleteStagedFile } from "../storage/repo.js";
11
+ import { releaseStagedFile } from "../storage/staging-refs.js";
12
+ import { validateAssetReferences, validateReplacementAvailability, validateUploads, } from "./mutation-assets.js";
13
+ import { cuesChanged, inspectCueAudio, measuredOutputs, validateProposedCues, } from "./mutation-cues.js";
14
+ import { prepareEditAssets } from "./mutation-prepare.js";
15
+ import { checkMutation, insertReceipt, requestHash, requiredView } from "./mutation-request.js";
16
+ import { preserveDeferredIntent } from "./mutation-work.js";
17
+ import { cloneManifest, ensureRevisionStages, projectSelected } from "./projection.js";
18
+ import { insertAsset, insertRevision } from "./repo.js";
19
+ import { validateNarrationIntent, validateTemplateIntent } from "./rules.js";
20
+ import { revisionEditSchema } from "./schema.js";
21
+ export { bindUpload } from "./mutation-assets.js";
22
+ export { restoreRevision } from "./restore.js";
23
+ export async function saveRevision(deps, input) {
24
+ const parsed = revisionEditSchema.safeParse(input.edit);
25
+ if (!parsed.success)
26
+ return {
27
+ ok: false,
28
+ reason: "invalid-edit",
29
+ currentRevisionId: input.baseRevisionId,
30
+ fields: parsed.error.issues.map((row) => ({
31
+ field: row.path.join("."),
32
+ message: row.message,
33
+ })),
34
+ };
35
+ const edit = parsed.data;
36
+ const identity = {
37
+ ...input,
38
+ operation: "save",
39
+ hash: requestHash("save", input.baseRevisionId, edit),
40
+ };
41
+ const checked = checkMutation(deps, identity);
42
+ if (checked !== undefined)
43
+ return checked;
44
+ const base = requiredView(deps, input.projectId, input.baseRevisionId);
45
+ const fields = [
46
+ ...validateUploads(deps, edit),
47
+ ...validateTemplateIntent(base, edit),
48
+ ...validateNarrationIntent(base, edit),
49
+ ...validateAssetReferences(deps, input.projectId, edit.content, []),
50
+ ];
51
+ if (fields.length > 0)
52
+ return { ok: false, reason: "invalid-edit", currentRevisionId: base.revision.id, fields };
53
+ const unavailable = validateReplacementAvailability(deps, base, edit);
54
+ if (unavailable.length > 0)
55
+ return {
56
+ ok: false,
57
+ reason: "invalid-edit",
58
+ currentRevisionId: base.revision.id,
59
+ fields: unavailable,
60
+ };
61
+ const { edit: supplied, assets: prepared } = await prepareEditAssets(deps, base, edit);
62
+ try {
63
+ const durations = await inspectCueAudio(deps, base, supplied, prepared);
64
+ const result = transact(deps.db, () => {
65
+ const checked = checkMutation(deps, identity);
66
+ if (checked !== undefined)
67
+ return checked;
68
+ const fresh = requiredView(deps, input.projectId, input.baseRevisionId);
69
+ const tokens = { ...fresh.revision.content.regenerationTokens };
70
+ for (const key of new Set((edit.regenerate ?? []).map(narrationRegenerationKey)))
71
+ tokens[key] = deps.ids.next();
72
+ const finalEdit = {
73
+ ...supplied,
74
+ content: { ...supplied.content, regenerationTokens: tokens },
75
+ };
76
+ const outputAssets = prepared.filter((row) => row.upload?.destination.kind !== "narration");
77
+ const inspected = measuredOutputs(deps, fresh, durations);
78
+ const preparedManifest = {
79
+ outputs: [
80
+ ...outputAssets.map((row) => ({
81
+ slot: row.slot,
82
+ workKey: row.workKey,
83
+ assetId: row.asset.id,
84
+ output: row.output,
85
+ fingerprint: "",
86
+ state: "ready",
87
+ })),
88
+ ],
89
+ pieces: [],
90
+ };
91
+ let plan = planRevision(fresh, finalEdit, preparedManifest, inspected);
92
+ if (!plan.ok)
93
+ return {
94
+ ok: false,
95
+ reason: "invalid-edit",
96
+ currentRevisionId: fresh.revision.id,
97
+ fields: plan.fields,
98
+ };
99
+ const fields = [
100
+ ...validateAssetReferences(deps, input.projectId, plan.content, prepared.map((row) => row.asset)),
101
+ ...validateProposedCues(fresh, finalEdit, plan, prepared, durations),
102
+ ];
103
+ if (fields.length > 0)
104
+ return { ok: false, reason: "invalid-edit", currentRevisionId: fresh.revision.id, fields };
105
+ if (cuesChanged(fresh, finalEdit) && plan.content.subtitleCues !== undefined) {
106
+ const timing = plan.recipes.find((row) => row.key === "subtitles:timing");
107
+ if (timing !== undefined) {
108
+ plan = planRevision(fresh, {
109
+ ...finalEdit,
110
+ content: {
111
+ ...plan.content,
112
+ subtitleCues: {
113
+ ...plan.content.subtitleCues,
114
+ audioFingerprint: timing.logicalFingerprint,
115
+ },
116
+ },
117
+ }, preparedManifest, inspected);
118
+ if (!plan.ok)
119
+ throw new Error("Validated captions could not bind to their narration timeline.");
120
+ }
121
+ }
122
+ plan = preserveDeferredIntent(deps, fresh, plan);
123
+ const revision = {
124
+ id: deps.ids.next(),
125
+ projectId: input.projectId,
126
+ parentId: fresh.revision.id,
127
+ restoredFromId: null,
128
+ config: plan.config,
129
+ content: plan.content,
130
+ fingerprints: plan.fingerprints,
131
+ createdAt: deps.clock.now().toISOString(),
132
+ };
133
+ insertRevision(deps.db, revision);
134
+ ensureRevisionStages(deps, revision);
135
+ for (const row of prepared)
136
+ insertAsset(deps.db, row.asset);
137
+ cloneManifest(deps, revision, plan.manifest);
138
+ if (!advanceHead(deps.db, input.projectId, input.baseRevisionId, revision))
139
+ throw new Error("Project head changed during the revision transaction.");
140
+ transitionRevisionWork(deps, {
141
+ projectId: input.projectId,
142
+ baseRevisionId: input.baseRevisionId,
143
+ revisionId: revision.id,
144
+ fingerprints: plan.fingerprints,
145
+ baseFingerprints: plan.baseFingerprints,
146
+ logicalKeys: logicalKeys(deps, input.projectId, input.baseRevisionId, plan.fingerprints),
147
+ recipes: plan.recipes,
148
+ });
149
+ carryCheckpointGates(deps, input.baseRevisionId, revision);
150
+ projectSelected(deps.db, revision);
151
+ projectStandings(deps, revision.projectId);
152
+ insertReceipt(deps, identity, revision.id);
153
+ for (const row of prepared)
154
+ if (row.upload !== undefined)
155
+ deleteStagedFile(deps.db, row.upload.stagedFileId);
156
+ return { ok: true, view: requiredView(deps, input.projectId, revision.id), duplicate: false };
157
+ });
158
+ if (result.ok && !result.duplicate)
159
+ for (const row of prepared)
160
+ if (row.upload !== undefined)
161
+ releaseStagedFile({ ...deps, emit: () => undefined }, row.upload.stagedFileId);
162
+ return result;
163
+ }
164
+ finally {
165
+ discardPreparedAssets(deps, prepared
166
+ .map((row) => row.asset)
167
+ .filter((asset) => deps.db.prepare("SELECT 1 FROM project_assets WHERE id=?").get(asset.id) === undefined));
168
+ }
169
+ }
170
+ export function advanceHead(db, projectId, baseRevisionId, revision) {
171
+ const result = db
172
+ .prepare("UPDATE project_heads SET revision_id = ? WHERE project_id = ? AND revision_id = ?")
173
+ .run(revision.id, projectId, baseRevisionId);
174
+ if (Number(result.changes) !== 1)
175
+ return false;
176
+ db.prepare("UPDATE projects SET title = ?, format = ?, config = ?, updated_at = ? WHERE id = ?").run(revision.config.title, revision.config.format, JSON.stringify(revision.config), revision.createdAt, projectId);
177
+ return true;
178
+ }
179
+ export function logicalKeys(deps, projectId, revisionId, fingerprints) {
180
+ const values = {};
181
+ const rows = deps.db
182
+ .prepare("SELECT p.work_key,p.input_json,r.logical_key FROM revision_work_reservations r JOIN revision_work_pieces p ON p.id=r.piece_id AND p.work_id=r.work_id WHERE r.project_id=? AND r.revision_id=?")
183
+ .all(projectId, revisionId);
184
+ for (const row of rows) {
185
+ const input = recipeInputSchema.parse(JSON.parse(z.string().parse(row.input_json)));
186
+ if (input.kind === "tts" && fingerprints[`${input.logicalKey}:1`] !== undefined)
187
+ values[z.string().parse(row.work_key)] = `${input.logicalKey}:1`;
188
+ else if (typeof row.logical_key === "string")
189
+ values[z.string().parse(row.work_key)] = row.logical_key;
190
+ else if (input.kind === "tts")
191
+ values[z.string().parse(row.work_key)] = `${input.logicalKey}:1`;
192
+ }
193
+ return values;
194
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+ import { stageKinds } from "../../kernel/pipeline.js";
3
+ import { insertPiece } from "../../kernel/runner/piece-repo.js";
4
+ import { recipeInputSchema } from "../rebuild/recipe-input-schema.js";
5
+ import { insertOutput } from "../storage/repo.js";
6
+ import { insertManifestOutput, insertManifestPiece, outputsForRevision, piecesForRevision, selectOutputRecord, selectPieceRecord, } from "./repo.js";
7
+ export function ensureRevisionStages(deps, revision) {
8
+ for (const kind of stageKinds) {
9
+ const source = revision.config.sources[kind];
10
+ deps.db
11
+ .prepare("INSERT INTO stages(id,project_id,kind,source,state) VALUES (?,?,?,?,?) ON CONFLICT(project_id,kind) DO UPDATE SET source=excluded.source")
12
+ .run(deps.ids.next(), revision.projectId, kind, source, source === "off" ? "skipped" : "pending");
13
+ }
14
+ }
15
+ export function positionedOutput(revision, row) {
16
+ if (row.output.role !== "image")
17
+ return row;
18
+ const index = revision.content.imageOrder.indexOf(row.workKey.replace(/^image:/, ""));
19
+ return index < 0
20
+ ? row
21
+ : { ...row, output: { ...row.output, meta: { ...row.output.meta, index: index + 1 } } };
22
+ }
23
+ export function positionedPiece(revision, row) {
24
+ if (row.piece.kind !== "image")
25
+ return row;
26
+ const index = revision.content.imageOrder.indexOf(row.key.replace(/^image:/, ""));
27
+ return index < 0 ? row : { ...row, piece: { ...row.piece, idx: index + 1 } };
28
+ }
29
+ export function cloneManifest(deps, revision, manifest) {
30
+ for (const row of manifest.outputs) {
31
+ if (row.output.role === "image" &&
32
+ !revision.content.imageOrder.includes(row.workKey.replace(/^image:/, "")))
33
+ continue;
34
+ const recordId = deps.ids.next();
35
+ insertManifestOutput(deps.db, revision, positionedOutput(revision, row), recordId);
36
+ selectOutputRecord(deps.db, revision.id, row.slot, recordId);
37
+ }
38
+ for (const row of manifest.pieces) {
39
+ if (row.piece.kind === "image" &&
40
+ !revision.content.imageOrder.includes(row.key.replace(/^image:/, "")))
41
+ continue;
42
+ const recordId = deps.ids.next();
43
+ insertManifestPiece(deps.db, revision, positionedPiece(revision, row), recordId);
44
+ selectPieceRecord(deps.db, revision.id, row.key, recordId);
45
+ }
46
+ }
47
+ export function projectSelected(db, revision) {
48
+ const outputs = outputsForRevision(db, revision.projectId, revision.id).filter((row) => row.selected);
49
+ const pieces = piecesForRevision(db, revision.projectId, revision.id).filter((row) => row.selected &&
50
+ (revision.fingerprints[row.key] !== undefined ||
51
+ desiredPhysicalPiece(db, revision, row) ||
52
+ db
53
+ .prepare("SELECT 1 FROM revision_work_reservations WHERE revision_id=? AND work_key=?")
54
+ .get(revision.id, row.key) !== undefined));
55
+ db.prepare("DELETE FROM outputs WHERE project_id=?").run(revision.projectId);
56
+ for (const row of outputs)
57
+ insertOutput(db, row.output);
58
+ db.prepare("DELETE FROM stage_pieces WHERE stage_id IN (SELECT id FROM stages WHERE project_id=?)").run(revision.projectId);
59
+ for (const row of pieces) {
60
+ const stage = db
61
+ .prepare("SELECT id FROM stages WHERE project_id=? AND kind=?")
62
+ .get(revision.projectId, row.stageKind);
63
+ if (stage === undefined || z.string().parse(stage.id) !== row.piece.stageId)
64
+ throw new Error("Manifest piece stage does not belong to this project.");
65
+ insertPiece(db, row.piece);
66
+ }
67
+ }
68
+ function desiredPhysicalPiece(db, revision, row) {
69
+ const piece = db
70
+ .prepare("SELECT p.input_json FROM revision_work_pieces p JOIN revision_work w ON w.id=p.work_id WHERE p.id=? AND w.project_id=?")
71
+ .get(row.piece.id, revision.projectId);
72
+ if (piece === undefined) {
73
+ if (row.stageKind !== "audio" || (row.piece.kind !== "chunk" && row.piece.kind !== "segment"))
74
+ return false;
75
+ const payload = z
76
+ .object({ logicalKey: z.string() })
77
+ .safeParse(JSON.parse(row.piece.payload ?? "null"));
78
+ return payload.success && revision.fingerprints[`${payload.data.logicalKey}:1`] !== undefined;
79
+ }
80
+ const input = recipeInputSchema.parse(JSON.parse(z.string().parse(piece.input_json)));
81
+ return input.kind === "tts" && revision.fingerprints[`${input.logicalKey}:1`] !== undefined;
82
+ }
@@ -0,0 +1 @@
1
+ export {};