@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,110 @@
1
+ import { readinessIsUsable } from "../../kernel/ports/model.js";
2
+ import { cliPathStatus } from "../settings/cli-paths.js";
3
+ import { hasKey, listVoices } from "../settings/repo.js";
4
+ import { requiresNewSubmission } from "./preview-retained.js";
5
+ import { recipeProviderChoice } from "./recipe-provider-choice.js";
6
+ export function paidChoices(snapshot, view, deps) {
7
+ const choices = [];
8
+ for (const recipe of snapshot.recipes) {
9
+ if (recipe.kind !== "provider" ||
10
+ snapshot.dispositions.find((row) => row.key === recipe.key)?.disposition === "reuse")
11
+ continue;
12
+ if (!requiresNewSubmission(deps, view.revision.id, recipe.key, recipe.fingerprint))
13
+ continue;
14
+ const choice = recipeProviderChoice(recipe, view.revision.config);
15
+ if (choice !== undefined)
16
+ choices.push(choice);
17
+ }
18
+ return [
19
+ ...new Map(choices.map((choice) => [`${choice.family}:${choice.provider}:${choice.model}`, choice])).values(),
20
+ ];
21
+ }
22
+ export async function checkReadiness(deps, snapshot, view) {
23
+ const choices = paidChoices(snapshot, view, deps);
24
+ if (choices.length === 0)
25
+ return { fields: [], providers: [] };
26
+ const providers = await deps.providers();
27
+ const fields = [];
28
+ for (const choice of choices) {
29
+ const provider = providers.find((row) => row.id === choice.provider && row.family === choice.family);
30
+ if (provider === undefined || !readinessIsUsable(provider.readiness)) {
31
+ fields.push({
32
+ field: choice.family,
33
+ message: provider?.readiness.kind === "cli" && provider.readiness.issue
34
+ ? provider.readiness.issue
35
+ : "Configure this provider before rebuilding.",
36
+ });
37
+ continue;
38
+ }
39
+ const models = await deps.modelsFor(choice.provider, choice.family);
40
+ if (!models.some((row) => row.id === choice.model))
41
+ fields.push({
42
+ field: `${choice.family}.model`,
43
+ message: "Choose an available model before rebuilding.",
44
+ });
45
+ }
46
+ return { fields, providers };
47
+ }
48
+ export function localReadiness(deps, snapshot, view, providers, catalogue) {
49
+ const fields = [];
50
+ for (const choice of paidChoices(snapshot, view, deps)) {
51
+ const model = catalogue[choice.family].find((row) => row.provider === choice.provider &&
52
+ row.id === choice.model &&
53
+ row.enabled &&
54
+ !row.deprecated);
55
+ if (model === undefined)
56
+ fields.push({
57
+ field: `${choice.family}.model`,
58
+ message: "The selected model is no longer available.",
59
+ });
60
+ const provider = providers.find((row) => row.id === choice.provider && row.family === choice.family);
61
+ if (provider?.cliPath !== undefined &&
62
+ cliPathStatus(deps.db, provider.id).command !== provider.cliPath.command)
63
+ fields.push({
64
+ field: `${choice.family}.cliPath`,
65
+ message: "The configured CLI command changed. Check readiness again before rebuilding.",
66
+ });
67
+ if (provider?.readiness.kind === "keyed" && !hasKey(deps.db, provider.id))
68
+ fields.push({ field: choice.family, message: "Save the provider key before rebuilding." });
69
+ if (choice.voice !== undefined &&
70
+ !listVoices(deps.db).some((row) => row.provider === choice.provider && row.voiceId === choice.voice))
71
+ fields.push({ field: "audio.voice", message: "Choose a saved voice before rebuilding." });
72
+ }
73
+ for (const recipe of snapshot.recipes) {
74
+ if (snapshot.dispositions.find((row) => row.key === recipe.key)?.disposition === "reuse" ||
75
+ !requiresNewSubmission(deps, view.revision.id, recipe.key, recipe.fingerprint))
76
+ continue;
77
+ const input = recipe.input;
78
+ if (input.kind === "tts") {
79
+ const model = catalogue.tts.find((row) => row.provider === input.provider && row.id === input.model);
80
+ const maximum = model?.tts.maxCharacters;
81
+ if (maximum !== undefined && input.text.length > maximum)
82
+ fields.push({
83
+ field: `work.${recipe.key}.text`,
84
+ message: "This saved physical request exceeds the current model limit. Regenerate this narration group to plan new parts.",
85
+ });
86
+ }
87
+ else if (input.kind === "llm") {
88
+ const model = catalogue.llm.find((row) => row.provider === input.provider && row.id === input.model);
89
+ if (input.thinking !== null && model?.llm.thinking?.[input.thinking] === undefined)
90
+ fields.push({
91
+ field: `work.${recipe.key}.thinking`,
92
+ message: "The selected thinking setting is no longer supported.",
93
+ });
94
+ if (input.webSearch && model?.llm.webSearch !== true)
95
+ fields.push({
96
+ field: `work.${recipe.key}.webSearch`,
97
+ message: "This model no longer supports the required web search.",
98
+ });
99
+ }
100
+ else if (input.kind === "image") {
101
+ const model = catalogue.image.find((row) => row.provider === input.provider && row.id === input.model);
102
+ if (model !== undefined && !model.image.aspectRatios.includes(input.aspect))
103
+ fields.push({
104
+ field: `work.${recipe.key}.aspect`,
105
+ message: "The model does not support this output shape.",
106
+ });
107
+ }
108
+ }
109
+ return fields;
110
+ }
@@ -0,0 +1,149 @@
1
+ import { withProjectControl } from "../control/lock.js";
2
+ import { requestHash } from "../revisions/mutation-request.js";
3
+ import { currentRevisionId } from "../revisions/repo.js";
4
+ import { getRevisionView } from "../revisions/view.js";
5
+ import { previewRebuildSchema, startRebuildSchema, } from "./model.js";
6
+ import { executionSnapshotSchema, planPreview, reviewStillCovers } from "./preview-plan.js";
7
+ import { admissionReceipt, admitPreview, previewById, storePreview } from "./repo.js";
8
+ import { checkReadiness, localReadiness } from "./service-readiness.js";
9
+ export function rebuildConfirmations(required, confirmed, unknownCosts, acknowledged) {
10
+ const given = new Set(confirmed);
11
+ if (given.size !== confirmed.length ||
12
+ required.length !== given.size ||
13
+ required.some((key) => !given.has(key)))
14
+ return "review-required";
15
+ if (unknownCosts > 0 && !acknowledged)
16
+ return "cost-ack-required";
17
+ return undefined;
18
+ }
19
+ export async function previewRebuild(deps, input) {
20
+ const parsed = previewRebuildSchema.safeParse({
21
+ baseRevisionId: input.baseRevisionId,
22
+ request: input.request,
23
+ });
24
+ if (!parsed.success)
25
+ return {
26
+ ok: false,
27
+ reason: "invalid-selection",
28
+ fields: parsed.error.issues.map((issue) => ({
29
+ field: issue.path.join("."),
30
+ message: issue.message,
31
+ })),
32
+ };
33
+ const view = getRevisionView(deps, input.projectId, input.baseRevisionId);
34
+ if (view === undefined)
35
+ return {
36
+ ok: false,
37
+ reason: currentRevisionId(deps.db, input.projectId) === undefined ? "no-project" : "conflict",
38
+ };
39
+ if (!view.current)
40
+ return { ok: false, reason: "conflict" };
41
+ const plan = planPreview(deps, view, deps.catalogue.read(), input.request, deps.ids.next());
42
+ if (!plan.ok)
43
+ return plan;
44
+ storePreview(deps, plan.value.preview, plan.value.execution);
45
+ return { ok: true, value: plan.value.preview };
46
+ }
47
+ export async function startRebuild(deps, input) {
48
+ const { projectId, ...body } = input;
49
+ const parsed = startRebuildSchema.safeParse(body);
50
+ if (!parsed.success)
51
+ return {
52
+ ok: false,
53
+ reason: "invalid-selection",
54
+ fields: parsed.error.issues.map((issue) => ({
55
+ field: issue.path.join("."),
56
+ message: issue.message,
57
+ })),
58
+ };
59
+ const hash = requestHash("rebuild", input.baseRevisionId, parsed.data);
60
+ const replay = admissionReceipt(deps.db, projectId, input.idempotencyKey, hash);
61
+ if (replay !== undefined) {
62
+ if (replay.ok)
63
+ wake(deps, projectId, replay.value);
64
+ return replay;
65
+ }
66
+ const preview = previewById(deps.db, projectId, input.previewId);
67
+ const view = getRevisionView(deps, projectId, input.baseRevisionId);
68
+ if (view === undefined)
69
+ return {
70
+ ok: false,
71
+ reason: currentRevisionId(deps.db, projectId) === undefined ? "no-project" : "conflict",
72
+ };
73
+ if (!view.current)
74
+ return { ok: false, reason: "conflict" };
75
+ if (preview === undefined || preview.baseRevisionId !== input.baseRevisionId)
76
+ return { ok: false, reason: "stale-preview" };
77
+ const confirmation = rebuildConfirmations(preview.providedReuseRequired, input.confirmedProvidedWorkKeys, preview.costs.unknown, input.acknowledgeUnknownCosts);
78
+ if (confirmation !== undefined)
79
+ return { ok: false, reason: confirmation };
80
+ const row = deps.db
81
+ .prepare("SELECT execution_json FROM rebuild_previews WHERE id=? AND project_id=?")
82
+ .get(preview.id, projectId);
83
+ if (typeof row?.execution_json !== "string")
84
+ return { ok: false, reason: "stale-preview" };
85
+ const snapshot = executionSnapshotSchema.parse(JSON.parse(row.execution_json));
86
+ let ready;
87
+ try {
88
+ ready = await checkReadiness(deps, snapshot, view);
89
+ }
90
+ catch {
91
+ deps.log.write("warn", "project.rebuild", {
92
+ projectId,
93
+ detail: "Provider readiness could not be loaded.",
94
+ });
95
+ return { ok: false, reason: "readiness" };
96
+ }
97
+ if (ready.fields.length > 0)
98
+ return { ok: false, reason: "readiness", fields: ready.fields };
99
+ return withProjectControl(deps.db, projectId, () => {
100
+ const again = admissionReceipt(deps.db, projectId, input.idempotencyKey, hash);
101
+ if (again !== undefined) {
102
+ if (again.ok)
103
+ wake(deps, projectId, again.value);
104
+ return again;
105
+ }
106
+ if (currentRevisionId(deps.db, projectId) !== input.baseRevisionId)
107
+ return { ok: false, reason: "conflict" };
108
+ const current = getRevisionView(deps, projectId, input.baseRevisionId);
109
+ if (current === undefined)
110
+ return { ok: false, reason: "no-project" };
111
+ const catalogue = deps.catalogue.read();
112
+ const fresh = planPreview(deps, current, catalogue, preview.selection, preview.id);
113
+ if (!fresh.ok || !reviewStillCovers(preview, snapshot, fresh.value))
114
+ return { ok: false, reason: "stale-preview" };
115
+ const fields = localReadiness(deps, snapshot, current, ready.providers, catalogue);
116
+ if (fields.length > 0)
117
+ return { ok: false, reason: "readiness", fields };
118
+ const admitted = admitPreview(deps, {
119
+ preview,
120
+ idempotencyKey: input.idempotencyKey,
121
+ requestHash: hash,
122
+ });
123
+ if (admitted.ok) {
124
+ deps.emit(projectId, { type: "project.updated", projectId });
125
+ wake(deps, projectId, admitted.value);
126
+ }
127
+ return admitted;
128
+ });
129
+ }
130
+ function wake(deps, projectId, admission) {
131
+ const owned = deps.db
132
+ .prepare("SELECT 1 FROM revision_work WHERE project_id=? AND admission_id=? AND state!='done' AND dispatch_state='allowed' LIMIT 1")
133
+ .get(projectId, admission.admissionId) !== undefined;
134
+ const unfinished = owned ||
135
+ admission.workIds.some((id) => deps.db
136
+ .prepare("SELECT 1 FROM revision_work WHERE id=? AND project_id=? AND state!='done' AND dispatch_state='allowed'")
137
+ .get(id, projectId) !== undefined);
138
+ if (!unfinished && admission.replayed)
139
+ return;
140
+ try {
141
+ deps.runner.tick(projectId);
142
+ }
143
+ catch {
144
+ deps.log.write("warn", "project.rebuild", {
145
+ projectId,
146
+ detail: "Rebuild was admitted but the runner could not be notified.",
147
+ });
148
+ }
149
+ }
@@ -0,0 +1,108 @@
1
+ import { z } from "zod";
2
+ import { transact } from "../../kernel/db/tx.js";
3
+ import { currentRevisionId, revisionById } from "../revisions/repo.js";
4
+ import { insertWorkPiece } from "./work-records.js";
5
+ const reservationSchema = z.object({
6
+ work_key: z.string(),
7
+ work_id: z.string(),
8
+ piece_id: z.string().nullable(),
9
+ fingerprint: z.string(),
10
+ logical_key: z.string().nullable(),
11
+ desired_fingerprint: z.string().nullable(),
12
+ });
13
+ export function transitionRevisionWork(deps, input) {
14
+ const { db } = deps;
15
+ transact(db, () => {
16
+ const current = currentRevisionId(db, input.projectId);
17
+ if (current !== input.baseRevisionId && current !== input.revisionId)
18
+ throw new Error("Work transition requires the current head.");
19
+ const base = revisionById(db, input.projectId, input.baseRevisionId);
20
+ const next = revisionById(db, input.projectId, input.revisionId);
21
+ if (base === undefined || next === undefined)
22
+ throw new Error("Work transition requires owned revisions.");
23
+ if (JSON.stringify(Object.entries(input.fingerprints).sort()) !==
24
+ JSON.stringify(Object.entries(next.fingerprints).sort()))
25
+ throw new Error("Desired work differs from revision fingerprints.");
26
+ const reserved = db
27
+ .prepare(`SELECT r.* FROM revision_work_reservations r JOIN revision_work w ON w.id=r.work_id WHERE r.project_id=? AND r.revision_id=? AND w.state IN ('pending','running','done')`)
28
+ .all(input.projectId, input.baseRevisionId)
29
+ .map((row) => reservationSchema.parse(row));
30
+ const carried = new Set();
31
+ const baseFingerprints = input.baseFingerprints ?? base.fingerprints;
32
+ for (const row of reserved) {
33
+ const logicalKey = input.logicalKeys?.[row.work_key] ?? row.logical_key ?? row.work_key;
34
+ const before = baseFingerprints[logicalKey];
35
+ const after = input.fingerprints[logicalKey];
36
+ if (before !== undefined &&
37
+ before === after &&
38
+ (input.baseFingerprints !== undefined ||
39
+ before === (row.desired_fingerprint ?? row.fingerprint))) {
40
+ db.prepare(`INSERT INTO revision_work_reservations(project_id,revision_id,work_key,work_id,piece_id,fingerprint,logical_key,desired_fingerprint) VALUES (?,?,?,?,?,?,?,?)`).run(input.projectId, input.revisionId, row.work_key, row.work_id, row.piece_id, row.fingerprint, logicalKey, after);
41
+ carried.add(logicalKey);
42
+ }
43
+ }
44
+ const live = db.prepare("SELECT id FROM revision_work WHERE project_id=?").all(input.projectId);
45
+ for (const row of live) {
46
+ const id = z.string().parse(row.id);
47
+ db.prepare(`UPDATE revision_work_pieces SET dispatch_state=CASE WHEN submitted_at IS NOT NULL THEN 'draining' ELSE 'held' END WHERE work_id=? AND state!='done' AND id NOT IN (SELECT piece_id FROM revision_work_reservations WHERE revision_id=? AND piece_id IS NOT NULL)`).run(id, input.revisionId);
48
+ const keeps = db
49
+ .prepare("SELECT 1 FROM revision_work_reservations WHERE revision_id=? AND work_id=?")
50
+ .get(input.revisionId, id) !== undefined;
51
+ if (!keeps)
52
+ db.prepare(`UPDATE revision_work SET dispatch_state=CASE WHEN EXISTS(SELECT 1 FROM attempts WHERE work_id=?) OR EXISTS(SELECT 1 FROM revision_work_pieces WHERE work_id=? AND submitted_at IS NOT NULL) THEN 'draining' ELSE 'held' END WHERE id=? AND state!='done'`).run(id, id, id);
53
+ }
54
+ db.prepare(`DELETE FROM revision_work_reservations WHERE project_id=? AND revision_id=? AND work_key NOT IN (SELECT work_key FROM revision_work_reservations WHERE revision_id=?)`).run(input.projectId, input.baseRevisionId, input.revisionId);
55
+ for (const [key, fp] of Object.entries(input.fingerprints)) {
56
+ if (carried.has(key))
57
+ continue;
58
+ const exact = input.recipes?.find((row) => row.key === key);
59
+ if (exact !== undefined && exact.fingerprint !== fp)
60
+ throw new Error("Recipe differs from desired fingerprint.");
61
+ const kind = exact?.stage ?? stageForKey(key);
62
+ const stage = db
63
+ .prepare("SELECT id FROM stages WHERE project_id=? AND kind=?")
64
+ .get(input.projectId, kind);
65
+ if (stage === undefined)
66
+ throw new Error("Desired work has no project stage.");
67
+ const workId = deps.ids.next();
68
+ const pieceId = deps.ids.next();
69
+ db.prepare(`INSERT INTO revision_work(id,project_id,revision_id,stage_id,kind,fingerprint,state,dispatch_state,created_at) VALUES (?,?,?,?,?,?,'pending','held',?)`).run(workId, input.projectId, input.revisionId, z.string().parse(stage.id), kind, fp, deps.clock.now().toISOString());
70
+ insertWorkPiece(db, {
71
+ id: pieceId,
72
+ workId,
73
+ key,
74
+ requestFingerprint: exact?.requestFingerprint ?? fp,
75
+ fingerprint: fp,
76
+ ...(exact === undefined ? {} : { logicalFingerprint: exact.logicalFingerprint }),
77
+ input: exact?.input ?? {
78
+ kind: "deferred",
79
+ version: 1,
80
+ operation: "resolve-revision-recipe",
81
+ template: { key, revisionId: input.revisionId, fingerprint: fp },
82
+ },
83
+ continuation: null,
84
+ generationToken: null,
85
+ state: "held",
86
+ dispatchState: "held",
87
+ submittedAt: null,
88
+ });
89
+ db.prepare(`INSERT INTO revision_work_reservations(project_id,revision_id,work_key,work_id,piece_id,fingerprint) VALUES (?,?,?,?,?,?)`).run(input.projectId, input.revisionId, key, workId, pieceId, fp);
90
+ }
91
+ });
92
+ }
93
+ function stageForKey(key) {
94
+ const prefix = key.split(":")[0];
95
+ if (prefix === "research")
96
+ return "research";
97
+ if (prefix === "article" || prefix === "entry")
98
+ return "article";
99
+ if (prefix === "audio")
100
+ return "audio";
101
+ if (prefix === "image")
102
+ return "images";
103
+ if (prefix === "thumbnail")
104
+ return "thumbnail";
105
+ if (prefix === "export" || prefix === "subtitles" || prefix === "video")
106
+ return "video";
107
+ throw new Error("Unknown revision work key.");
108
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { recipeInputSchema } from "./recipe-input-schema.js";
3
+ const pieceRow = z.object({
4
+ id: z.string(),
5
+ work_id: z.string(),
6
+ work_key: z.string(),
7
+ request_fingerprint: z.string(),
8
+ fingerprint: z.string(),
9
+ input_json: z.string(),
10
+ logical_fingerprint: z.string().nullable(),
11
+ continuation: z.string().nullable(),
12
+ generation_token: z.string().nullable(),
13
+ state: z.enum(["pending", "running", "done", "failed", "held"]),
14
+ dispatch_state: z.enum(["held", "allowed", "draining"]),
15
+ submitted_at: z.string().nullable(),
16
+ });
17
+ export function workPieces(db, workId) {
18
+ return db
19
+ .prepare("SELECT * FROM revision_work_pieces WHERE work_id=? ORDER BY rowid")
20
+ .all(workId)
21
+ .map((value) => {
22
+ const row = pieceRow.parse(value);
23
+ return {
24
+ id: row.id,
25
+ workId: row.work_id,
26
+ key: row.work_key,
27
+ requestFingerprint: row.request_fingerprint,
28
+ fingerprint: row.fingerprint,
29
+ ...(row.logical_fingerprint === null
30
+ ? {}
31
+ : { logicalFingerprint: row.logical_fingerprint }),
32
+ input: recipeInputSchema.parse(JSON.parse(row.input_json)),
33
+ continuation: row.continuation,
34
+ generationToken: row.generation_token,
35
+ state: row.state,
36
+ dispatchState: row.dispatch_state,
37
+ submittedAt: row.submitted_at,
38
+ };
39
+ });
40
+ }
41
+ export function insertWorkPiece(db, piece) {
42
+ const input = recipeInputSchema.parse(piece.input);
43
+ db.prepare(`INSERT INTO revision_work_pieces(id,work_id,work_key,request_fingerprint,fingerprint,input_json,continuation,generation_token,state,dispatch_state,submitted_at,logical_fingerprint) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`).run(piece.id, piece.workId, piece.key, piece.requestFingerprint, piece.fingerprint, JSON.stringify(input), piece.continuation, piece.generationToken, piece.state, piece.dispatchState, piece.submittedAt, piece.logicalFingerprint ?? null);
44
+ }
@@ -4,6 +4,7 @@ import { transact } from "../../kernel/db/tx.js";
4
4
  import { allPiecesOf, deletePiece, deletePieces, setPiece, } from "../../kernel/runner/piece-repo.js";
5
5
  import { projectById, resetStage, stagesOf } from "../admission/repo.js";
6
6
  import { storeArticleText } from "../article/store.js";
7
+ import { currentRevisionId } from "../revisions/repo.js";
7
8
  import { outputPath } from "../storage/layout.js";
8
9
  import { pieceFile } from "../storage/reconcile.js";
9
10
  import { deleteOutput, outputsOf } from "../storage/repo.js";
@@ -126,6 +127,8 @@ export function regenerateImage(deps, projectId, outputId) {
126
127
  });
127
128
  }
128
129
  function load(deps, projectId) {
130
+ if (currentRevisionId(deps.db, projectId) !== undefined)
131
+ return { ok: false, reason: "not-rerunnable" };
129
132
  const project = projectById(deps.db, projectId);
130
133
  if (project === undefined) {
131
134
  return { ok: false, reason: "no-project" };
@@ -200,6 +203,8 @@ function clearStage(deps, stage, outputs) {
200
203
  // The caller includes these row changes with the config update's transaction and
201
204
  // runs the returned cleanup only after commit. Finished narration is left intact.
202
205
  export function clearUnfinishedAudio(deps, projectId) {
206
+ if (currentRevisionId(deps.db, projectId) !== undefined)
207
+ throw new Error("Versioned narration must be edited through revisions.");
203
208
  const stage = stagesOf(deps.db, projectId).find((one) => one.kind === "audio");
204
209
  if (stage === undefined ||
205
210
  stage.state === "done" ||
@@ -28,7 +28,7 @@ export async function runResearch(deps, context, providers) {
28
28
  }
29
29
  const brief = { articlePrompt, values: project.config.values };
30
30
  try {
31
- await research(deps, context, providers, choice, brief);
31
+ return await research(deps, context, providers, choice, brief);
32
32
  }
33
33
  catch (error) {
34
34
  // The adapter says the model cannot ground on the web and the wrapper has already
@@ -47,8 +47,13 @@ async function research(deps, context, providers, choice, brief) {
47
47
  const add = (usage) => {
48
48
  tokens = plusUsage(tokens, usage);
49
49
  };
50
- const chapters = await plan(deps, context, providers, choice, brief, add);
51
- const findings = await researchChapters(deps, context, providers, choice, brief, chapters, add);
50
+ const planned = await plan(deps, context, providers, choice, brief, add);
51
+ if (!planned.ok)
52
+ return "held";
53
+ const researched = await researchChapters(deps, context, providers, choice, brief, planned.value, add);
54
+ if (!researched.ok)
55
+ return "held";
56
+ const findings = researched.value;
52
57
  const answer = await providers.llm({
53
58
  provider: choice.provider,
54
59
  model: choice.model,
@@ -57,11 +62,18 @@ async function research(deps, context, providers, choice, brief) {
57
62
  previewLabel: "Writing research notes",
58
63
  check: (given) => sourcedAnswer("the synthesis", given.text),
59
64
  });
60
- add(answer.usage);
65
+ if (!answer.ok)
66
+ return "held";
67
+ add(answer.value.usage);
61
68
  const { projectId } = context.stage;
62
69
  // The notes and every instruction sent are stored on the project. Each sub-agent's own output
63
70
  // stays on its chapter row, which is where the resume reads it.
64
- storeText(deps, { projectId, stageKind: "research", role: "notes", text: answer.text.trim() });
71
+ storeText(deps, {
72
+ projectId,
73
+ stageKind: "research",
74
+ role: "notes",
75
+ text: answer.value.text.trim(),
76
+ });
65
77
  storeText(deps, {
66
78
  projectId,
67
79
  stageKind: "research",
@@ -80,13 +92,14 @@ async function research(deps, context, providers, choice, brief) {
80
92
  stage: "research",
81
93
  detail: `${String(findings.length)} chapters researched`,
82
94
  });
95
+ return "done";
83
96
  }
84
97
  // The chapter list, planned once and kept. A retry after a failure finds the rows the
85
98
  // first run wrote and does not ask again.
86
99
  async function plan(deps, context, providers, choice, brief, add) {
87
100
  const existing = piecesOf(deps.db, context.stage.id, "chapter");
88
101
  if (existing.length > 0) {
89
- return existing;
102
+ return { ok: true, value: existing };
90
103
  }
91
104
  const answer = await providers.llm({
92
105
  provider: choice.provider,
@@ -97,10 +110,12 @@ async function plan(deps, context, providers, choice, brief, add) {
97
110
  // An empty answer, or one with no chapter in it, is a failed attempt.
98
111
  check: (given) => chaptersFrom(given.text).length === 0 ? "the planner named no chapters" : undefined,
99
112
  });
100
- add(answer.usage);
113
+ if (!answer.ok)
114
+ return answer;
115
+ add(answer.value.usage);
101
116
  // No cap on the count. The list is the prompt's own section guide as often as
102
117
  // not, and capping it would silently drop a section the article asks for.
103
- const planned = chaptersFrom(answer.text).map((title, index) => ({
118
+ const planned = chaptersFrom(answer.value.text).map((title, index) => ({
104
119
  id: deps.ids.next(),
105
120
  stageId: context.stage.id,
106
121
  kind: "chapter",
@@ -113,7 +128,7 @@ async function plan(deps, context, providers, choice, brief, add) {
113
128
  insertPiece(deps.db, piece);
114
129
  }
115
130
  });
116
- return planned;
131
+ return { ok: true, value: planned };
117
132
  }
118
133
  // One sub-agent per chapter, all in parallel. A chapter a previous run finished is not
119
134
  // researched again, and one that fails takes down the whole stage while its siblings finish and
@@ -141,8 +156,12 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
141
156
  webSearch: true,
142
157
  check: (given) => sourcedAnswer(`the researcher on "${kept.title}"`, given.text),
143
158
  });
144
- add(answer.usage);
145
- const notes = answer.text.trim();
159
+ if (!answer.ok) {
160
+ setPiece(deps.db, piece.id, "pending", piece.payload);
161
+ return answer;
162
+ }
163
+ add(answer.value.usage);
164
+ const notes = answer.value.text.trim();
146
165
  setPiece(deps.db, piece.id, "done", JSON.stringify({ title: kept.title, notes }));
147
166
  done += 1;
148
167
  report(deps, context, done, total);
@@ -158,11 +177,13 @@ async function researchChapters(deps, context, providers, choice, brief, chapter
158
177
  const findings = [];
159
178
  for (const outcome of outcomes) {
160
179
  if (!outcome.ok) {
180
+ if ("reason" in outcome)
181
+ return outcome;
161
182
  throw outcome.error;
162
183
  }
163
184
  findings.push(outcome.finding);
164
185
  }
165
- return findings;
186
+ return { ok: true, value: findings };
166
187
  }
167
188
  // K of N chapters researched, written as well as emitted, so a page opened mid-stage reads the
168
189
  // count off the row rather than waiting for the next chapter.
@@ -0,0 +1,31 @@
1
+ import { statSync } from "node:fs";
2
+ import { outputPath } from "../storage/layout.js";
3
+ import { pieceFile } from "../storage/reconcile.js";
4
+ import { insertAsset } from "./repo.js";
5
+ export function adoptAssets(deps, projectId, outputs, pieces) {
6
+ const assets = new Map();
7
+ const capture = (path, bytes) => {
8
+ if (assets.has(path))
9
+ return;
10
+ outputPath(deps.paths, projectId, path);
11
+ const asset = {
12
+ id: deps.ids.next(),
13
+ projectId,
14
+ path,
15
+ bytes,
16
+ createdAt: deps.clock.now().toISOString(),
17
+ };
18
+ insertAsset(deps.db, asset);
19
+ assets.set(path, asset);
20
+ };
21
+ for (const output of outputs)
22
+ capture(output.path, output.bytes);
23
+ for (const piece of pieces) {
24
+ const path = pieceFile(piece.payload);
25
+ if (path === undefined)
26
+ continue;
27
+ const stats = statSync(outputPath(deps.paths, projectId, path), { throwIfNoEntry: false });
28
+ capture(path, stats?.isFile() === true ? stats.size : null);
29
+ }
30
+ return assets;
31
+ }