@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,94 @@
1
+ import { z } from "zod";
2
+ import { stageKinds } from "../../kernel/pipeline.js";
3
+ export const rebuildSelectionSchema = z.discriminatedUnion("kind", [
4
+ z.object({ kind: z.literal("allAffected") }).strict(),
5
+ z
6
+ .object({
7
+ kind: z.literal("selected"),
8
+ workKeys: z.array(z.string().min(1).max(200)).min(1).max(10000),
9
+ })
10
+ .strict(),
11
+ ]);
12
+ const revisionId = z.string().min(1).max(64);
13
+ export const previewRebuildSchema = z
14
+ .object({
15
+ baseRevisionId: revisionId,
16
+ request: rebuildSelectionSchema,
17
+ })
18
+ .strict();
19
+ export const startRebuildSchema = z
20
+ .object({
21
+ baseRevisionId: revisionId,
22
+ idempotencyKey: z.uuid(),
23
+ previewId: z.string().min(1).max(64),
24
+ acknowledgeUnknownCosts: z.boolean(),
25
+ confirmedProvidedWorkKeys: z.array(z.string().min(1).max(200)).max(10000),
26
+ })
27
+ .strict();
28
+ const costRowSchema = z.object({
29
+ stage: z.string(),
30
+ low: z.number().finite().nonnegative().nullable(),
31
+ high: z.number().finite().nonnegative().nullable(),
32
+ detail: z.string(),
33
+ });
34
+ const costEstimateSchema = z.object({
35
+ currency: z.literal("USD"),
36
+ rows: z.array(costRowSchema),
37
+ low: z.number().finite().nonnegative(),
38
+ high: z.number().finite().nonnegative(),
39
+ unknown: z.number().int().nonnegative(),
40
+ expectedWords: z.number().finite().nonnegative(),
41
+ catalogueDate: z.string().nullable(),
42
+ assumptions: z.array(z.string()),
43
+ });
44
+ export const rebuildAdmissionSchema = z.object({
45
+ revisionId,
46
+ admissionId: z.string().min(1).max(64),
47
+ workIds: z.array(z.string().min(1).max(64)),
48
+ replayed: z.boolean(),
49
+ });
50
+ export const rebuildPreviewSchema = z.object({
51
+ id: z.string().min(1).max(64),
52
+ projectId: z.string().min(1).max(64),
53
+ baseRevisionId: revisionId,
54
+ planFingerprint: z.string().min(1),
55
+ selection: rebuildSelectionSchema,
56
+ changedInputs: z.array(z.object({ path: z.string(), before: z.string().nullable(), after: z.string().nullable() })),
57
+ work: z.array(z.object({
58
+ key: z.string(),
59
+ stage: z.enum(stageKinds),
60
+ kind: z.enum(["provider", "local", "provided"]),
61
+ disposition: z.enum(["reuse", "generate", "local", "review", "blocked"]),
62
+ requestFingerprint: z.string(),
63
+ fingerprint: z.string(),
64
+ dependsOn: z.array(z.string()),
65
+ reason: z.string(),
66
+ inflight: z.boolean(),
67
+ pieceIds: z.array(z.string()),
68
+ })),
69
+ retained: z.array(z.object({
70
+ slot: z.string(),
71
+ outputId: z.string(),
72
+ assetId: z.string(),
73
+ state: z.enum(["ready", "outdated", "review"]),
74
+ })),
75
+ providedReuseRequired: z.array(z.string()),
76
+ costs: costEstimateSchema,
77
+ wholeRequestNotice: z.string().nullable(),
78
+ warnings: z.array(z.string()),
79
+ review: z
80
+ .object({
81
+ inputChanges: z.array(z.object({
82
+ label: z.string(),
83
+ before: z.string().nullable(),
84
+ after: z.string().nullable(),
85
+ })),
86
+ requests: z.array(z.object({
87
+ key: z.string(),
88
+ label: z.string(),
89
+ text: z.string().nullable(),
90
+ settings: z.string().nullable(),
91
+ })),
92
+ })
93
+ .optional(),
94
+ });
@@ -0,0 +1,43 @@
1
+ import { statSync } from "node:fs";
2
+ import { z } from "zod";
3
+ import { stagePieceSchema } from "../revisions/schema.js";
4
+ import { outputPath } from "../storage/layout.js";
5
+ export function retainedNarrationPieces(deps, projectId) {
6
+ return deps.db
7
+ .prepare(`SELECT p.piece_key,p.asset_id,p.fingerprint,p.descriptor,a.path FROM revision_pieces p
8
+ JOIN project_assets a ON a.id=p.asset_id AND a.project_id=p.project_id
9
+ WHERE p.project_id=? AND p.stage_kind='audio' ORDER BY p.created_at,p.id`)
10
+ .all(projectId)
11
+ .map((value) => {
12
+ const row = z
13
+ .object({
14
+ piece_key: z.string(),
15
+ asset_id: z.string(),
16
+ fingerprint: z.string(),
17
+ descriptor: z.string(),
18
+ path: z.string(),
19
+ })
20
+ .parse(value);
21
+ return {
22
+ key: row.piece_key,
23
+ stageKind: "audio",
24
+ assetId: row.asset_id,
25
+ fingerprint: row.fingerprint,
26
+ piece: stagePieceSchema.parse(JSON.parse(row.descriptor)),
27
+ available: statSync(outputPath(deps.paths, projectId, row.path), {
28
+ throwIfNoEntry: false,
29
+ })?.isFile() === true,
30
+ };
31
+ });
32
+ }
33
+ export function referencedAssetsAvailable(deps, projectId, ids) {
34
+ return ids.filter((id) => {
35
+ const row = deps.db
36
+ .prepare("SELECT path FROM project_assets WHERE project_id=? AND id=?")
37
+ .get(projectId, id);
38
+ return (row !== undefined &&
39
+ statSync(outputPath(deps.paths, projectId, z.string().parse(row.path)), {
40
+ throwIfNoEntry: false,
41
+ })?.isFile() === true);
42
+ });
43
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import { fingerprint } from "../../kernel/runner/work.js";
3
+ export function matchingNarrationPiece(recipe, pieces, available) {
4
+ if (recipe.input.kind !== "tts")
5
+ return undefined;
6
+ const noRegeneration = recipe.fingerprint === fingerprint([recipe.requestFingerprint, null]);
7
+ return pieces.find((piece) => {
8
+ if (piece.stageKind !== "audio" ||
9
+ piece.piece.state !== "done" ||
10
+ piece.assetId === null ||
11
+ !available.has(piece.assetId))
12
+ return false;
13
+ const parsed = z
14
+ .object({ requestFingerprint: z.string() })
15
+ .safeParse(JSON.parse(piece.piece.payload ?? "null"));
16
+ return (parsed.success &&
17
+ parsed.data.requestFingerprint === recipe.requestFingerprint &&
18
+ (noRegeneration || piece.fingerprint === recipe.fingerprint));
19
+ });
20
+ }
@@ -0,0 +1,144 @@
1
+ import { stageKinds } from "../../kernel/pipeline.js";
2
+ import { getRevisionView } from "../revisions/view.js";
3
+ export function previewDetails(deps, view, allRecipes, selected) {
4
+ const parent = view.revision.parentId === null
5
+ ? undefined
6
+ : getRevisionView(deps, view.revision.projectId, view.revision.parentId);
7
+ const requests = allRecipes.flatMap((row) => {
8
+ if (!selected.has(row.key))
9
+ return [];
10
+ const input = row.input;
11
+ const narration = narrationIdentity(row);
12
+ if (narration !== undefined) {
13
+ const groups = [
14
+ ...new Set(allRecipes.flatMap((one) => {
15
+ const identity = narrationIdentity(one);
16
+ return identity?.segment === narration.segment ? [identity.logicalKey] : [];
17
+ })),
18
+ ];
19
+ const siblings = allRecipes.filter((one) => narrationIdentity(one)?.logicalKey === narration.logicalKey);
20
+ const segment = narration.segment === "body" ? "Body" : narration.segment === "intro" ? "Intro" : "Outro";
21
+ const text = input.kind === "tts"
22
+ ? input.text
23
+ : input.kind === "provided" &&
24
+ Array.isArray(input.semantic) &&
25
+ typeof input.semantic[0] === "string"
26
+ ? input.semantic[0]
27
+ : null;
28
+ return [
29
+ {
30
+ key: row.key,
31
+ label: `${segment} narration chunk ${groups.indexOf(narration.logicalKey) + 1} · request ${siblings.findIndex((one) => one.key === row.key) + 1}`,
32
+ text,
33
+ settings: input.kind === "tts"
34
+ ? [input.provider, input.model, input.voice].join(" · ")
35
+ : "Provided audio",
36
+ },
37
+ ];
38
+ }
39
+ if (input.kind === "image") {
40
+ const index = view.revision.content.imageOrder.indexOf(row.key.slice("image:".length));
41
+ return [
42
+ {
43
+ key: row.key,
44
+ label: index < 0 ? "Thumbnail image" : `Image ${index + 1}`,
45
+ text: input.prompt,
46
+ settings: [input.provider, input.model, input.aspect].join(" · "),
47
+ },
48
+ ];
49
+ }
50
+ if (input.kind === "llm") {
51
+ const peers = allRecipes.filter((one) => one.stage === row.stage && one.input.kind === "llm");
52
+ const category = row.key.startsWith("entry:intro")
53
+ ? "Intro text"
54
+ : row.key.startsWith("entry:outro")
55
+ ? "Outro text"
56
+ : row.stage === "article"
57
+ ? "Article"
58
+ : row.stage === "research"
59
+ ? "Research"
60
+ : "Thumbnail prompt";
61
+ return [
62
+ {
63
+ key: row.key,
64
+ label: `${category} request ${peers.findIndex((one) => one.key === row.key) + 1}`,
65
+ text: input.messages
66
+ .map((message) => `${message.role}:\n${message.content}`)
67
+ .join("\n\n"),
68
+ settings: [input.provider, input.model, input.thinking]
69
+ .filter((value) => value !== null)
70
+ .join(" · "),
71
+ },
72
+ ];
73
+ }
74
+ return [];
75
+ });
76
+ return { inputChanges: parent === undefined ? [] : inputChanges(parent, view), requests };
77
+ }
78
+ function narrationIdentity(row) {
79
+ if (row.input.kind === "tts")
80
+ return { logicalKey: row.input.logicalKey, segment: row.input.segment };
81
+ if (row.input.kind !== "provided")
82
+ return undefined;
83
+ const match = /^audio:(body:.+|intro|outro):[0-9]+$/.exec(row.key);
84
+ if (match === null)
85
+ return undefined;
86
+ return {
87
+ logicalKey: row.key.slice(0, row.key.lastIndexOf(":")),
88
+ segment: row.key.startsWith("audio:body:")
89
+ ? "body"
90
+ : row.key.startsWith("audio:intro:")
91
+ ? "intro"
92
+ : "outro",
93
+ };
94
+ }
95
+ function inputChanges(parent, view) {
96
+ const before = parent.revision.config;
97
+ const after = view.revision.config;
98
+ const old = parent.revision.content;
99
+ const next = view.revision.content;
100
+ const changes = [];
101
+ const add = (label, previous, current) => {
102
+ const text = (value) => value == null ? null : typeof value === "string" ? value : JSON.stringify(value, null, 2);
103
+ const a = text(previous);
104
+ const b = text(current);
105
+ if (a !== b)
106
+ changes.push({ label, before: a, after: b });
107
+ };
108
+ add("Project title", before.title, after.title);
109
+ add("Aspect ratio", before.format, after.format);
110
+ for (const kind of stageKinds)
111
+ add(`${kind[0]?.toUpperCase()}${kind.slice(1)} source`, before.sources[kind], after.sources[kind]);
112
+ for (const [name, a, b] of [
113
+ ["Text", before.llm, after.llm],
114
+ ["Narration", before.audio, after.audio],
115
+ ["Images", before.images, after.images],
116
+ ]) {
117
+ add(`${name} provider`, a?.provider, b?.provider);
118
+ add(`${name} model`, a?.model, b?.model);
119
+ add(`${name} thinking`, a?.thinking, b?.thinking);
120
+ }
121
+ add("Narration voice", before.audio?.voice, after.audio?.voice);
122
+ add("Narration chunking", before.chunking, after.chunking);
123
+ add("Silence gap (seconds)", before.silenceGapSeconds, after.silenceGapSeconds);
124
+ add("Intro", before.intro, after.intro);
125
+ add("Outro", before.outro, after.outro);
126
+ add("Subtitle settings", before.subtitles, after.subtitles);
127
+ add("Article text", parent.articleMarkdown, view.articleMarkdown);
128
+ add("Provided research", before.provided.research, after.provided.research);
129
+ for (const key of new Set([...Object.keys(before.values), ...Object.keys(after.values)]))
130
+ add(`Keyword: ${key}`, before.values[key], after.values[key]);
131
+ for (const key of new Set([...Object.keys(before.rendered), ...Object.keys(after.rendered)]))
132
+ add(`Prompt: ${key}`, before.rendered[key], after.rendered[key]);
133
+ if (old.imageOrder.join("\0") !== next.imageOrder.join("\0")) {
134
+ const label = (key) => old.imageOrder.includes(key) ? `Image ${old.imageOrder.indexOf(key) + 1}` : "New image";
135
+ add("Image order", old.imageOrder.map(label).join(", "), next.imageOrder.map(label).join(", "));
136
+ }
137
+ for (const [index, key] of next.imageOrder.entries()) {
138
+ add(`Image ${index + 1} prompt`, old.imageDefinitions[key]?.prompt, next.imageDefinitions[key]?.prompt);
139
+ if (old.imageDefinitions[key]?.assetId !== next.imageDefinitions[key]?.assetId)
140
+ add(`Image ${index + 1} file`, old.imageDefinitions[key]?.assetId == null ? null : "Previous file", next.imageDefinitions[key]?.assetId == null ? null : "Replacement file");
141
+ }
142
+ add("Manual captions", old.subtitleCues?.cues, next.subtitleCues?.cues);
143
+ return changes;
144
+ }
@@ -0,0 +1,223 @@
1
+ import { z } from "zod";
2
+ import { catalogueSchema } from "../../catalog/schema.js";
3
+ import { stageKinds } from "../../kernel/pipeline.js";
4
+ import { estimateRequests } from "../estimate/index.js";
5
+ import { requestHash } from "../revisions/mutation-request.js";
6
+ import { previewDetails } from "./preview-details.js";
7
+ import { hasSubmittedRequest, requiresNewSubmission, retainedPreviewPlan, } from "./preview-retained.js";
8
+ import { providedDependencyFingerprint } from "./provided-review.js";
9
+ import { recipeInputSchema } from "./recipe-input-schema.js";
10
+ import { recipeProviderChoice } from "./recipe-provider-choice.js";
11
+ import { priceRecipe } from "./recipe-work.js";
12
+ import { narrationOrdinal } from "./runtime-narration-reuse.js";
13
+ import { executionCatalogue } from "./runtime-plan.js";
14
+ export const executionSnapshotSchema = z.object({
15
+ version: z.literal(1),
16
+ submissions: z
17
+ .array(z.object({ key: z.string(), submit: z.boolean(), uncertain: z.boolean() }))
18
+ .default([]),
19
+ catalogue: catalogueSchema,
20
+ recipes: z.array(z.object({
21
+ key: z.string(),
22
+ stage: z.enum(stageKinds),
23
+ kind: z.enum(["provider", "local", "provided"]),
24
+ requestFingerprint: z.string(),
25
+ fingerprint: z.string(),
26
+ logicalFingerprint: z.string(),
27
+ dependsOn: z.array(z.string()),
28
+ unresolved: z.boolean(),
29
+ deferred: z.boolean(),
30
+ input: recipeInputSchema,
31
+ })),
32
+ dispositions: z.array(z.object({ key: z.string(), disposition: z.string() })),
33
+ assets: z.array(z.object({ id: z.string(), path: z.string(), available: z.boolean() })),
34
+ reviews: z.array(z.object({ key: z.string(), fingerprint: z.string() })),
35
+ anchors: z.record(z.string(), z.string()),
36
+ ordinals: z.record(z.string(), z.number().int()),
37
+ });
38
+ export function planPreview(deps, view, catalogue, selection, id) {
39
+ const plan = retainedPreviewPlan(deps, view, catalogue);
40
+ const requested = selection.kind === "allAffected"
41
+ ? plan.work.filter((row) => row.disposition !== "reuse").map((row) => row.key)
42
+ : selection.workKeys;
43
+ if (new Set(requested).size !== requested.length)
44
+ return invalid("Choose each work item only once.");
45
+ const selected = new Set();
46
+ const visiting = new Set();
47
+ const visit = (key) => {
48
+ if (selected.has(key))
49
+ return true;
50
+ if (visiting.has(key))
51
+ return false;
52
+ const work = plan.work.find((row) => row.key === key);
53
+ const recipe = plan.recipes.find((row) => row.key === key);
54
+ if (work === undefined || recipe === undefined)
55
+ return false;
56
+ if (recipe.input.kind === "provided" && recipe.unresolved)
57
+ return false;
58
+ if (recipe.unresolved && recipe.dependsOn.length === 0 && !recipe.deferred)
59
+ return false;
60
+ visiting.add(key);
61
+ if (work.disposition !== "reuse")
62
+ for (const dependency of recipe.dependsOn)
63
+ if (!visit(dependency))
64
+ return false;
65
+ visiting.delete(key);
66
+ selected.add(key);
67
+ return true;
68
+ };
69
+ for (const key of requested)
70
+ if (!visit(key))
71
+ return invalid(`Work ${key} is disabled, unknown, or has an unavailable prerequisite.`);
72
+ const recipes = plan.recipes.filter((row) => selected.has(row.key));
73
+ const reservations = deps.db
74
+ .prepare(`SELECT r.work_key,r.fingerprint,w.state,w.recipe_context,r.piece_id FROM revision_work_reservations r JOIN revision_work w ON w.id=r.work_id WHERE r.revision_id=?`)
75
+ .all(view.revision.id);
76
+ const work = plan.work
77
+ .filter((row) => selected.has(row.key))
78
+ .map((row) => {
79
+ const old = reservations.find((one) => one.work_key === row.key &&
80
+ one.fingerprint === row.fingerprint &&
81
+ one.state !== "done" &&
82
+ typeof one.recipe_context === "string");
83
+ return old === undefined
84
+ ? row
85
+ : {
86
+ ...row,
87
+ inflight: old.state === "running",
88
+ pieceIds: typeof old.piece_id === "string" ? [old.piece_id] : [],
89
+ };
90
+ });
91
+ const relevantAssets = new Map();
92
+ for (const row of view.outputs)
93
+ if (row.selected && selected.has(row.workKey))
94
+ relevantAssets.set(row.assetId, {
95
+ id: row.assetId,
96
+ path: row.output.path,
97
+ available: row.available,
98
+ });
99
+ for (const row of view.pieces)
100
+ if (row.selected && selected.has(row.key) && row.assetId !== null) {
101
+ const asset = deps.db
102
+ .prepare("SELECT path FROM project_assets WHERE id=? AND project_id=?")
103
+ .get(row.assetId, view.revision.projectId);
104
+ relevantAssets.set(row.assetId, {
105
+ id: row.assetId,
106
+ path: String(asset?.path ?? ""),
107
+ available: row.available,
108
+ });
109
+ }
110
+ for (const recipe of recipes)
111
+ if (recipe.input.kind === "provided" && recipe.input.assetId !== null) {
112
+ const asset = deps.db
113
+ .prepare("SELECT path FROM project_assets WHERE id=? AND project_id=?")
114
+ .get(recipe.input.assetId, view.revision.projectId);
115
+ relevantAssets.set(recipe.input.assetId, {
116
+ id: recipe.input.assetId,
117
+ path: String(asset?.path ?? ""),
118
+ available: !recipe.unresolved,
119
+ });
120
+ }
121
+ const snapshot = selectedCatalogue(executionCatalogue(catalogue, view.revision.config), recipes, view);
122
+ const submissions = work
123
+ .filter((row) => row.kind === "provider")
124
+ .map((row) => {
125
+ const submit = row.disposition !== "reuse" &&
126
+ !row.inflight &&
127
+ requiresNewSubmission(deps, view.revision.id, row.key, row.fingerprint);
128
+ return {
129
+ key: row.key,
130
+ submit,
131
+ uncertain: submit && hasSubmittedRequest(deps, view.revision.id, row.key, row.fingerprint),
132
+ };
133
+ });
134
+ const execution = {
135
+ version: 1,
136
+ submissions,
137
+ catalogue: snapshot,
138
+ recipes: recipes.map((row) => ({ ...row, dependsOn: [...row.dependsOn] })),
139
+ dispositions: work.map((row) => ({ key: row.key, disposition: row.disposition })),
140
+ assets: [...relevantAssets.values()].sort((a, b) => a.id.localeCompare(b.id)),
141
+ reviews: plan.providedReuseRequired
142
+ .filter((key) => selected.has(key))
143
+ .map((key) => ({ key, fingerprint: providedDependencyFingerprint(plan, key) })),
144
+ ordinals: Object.fromEntries(recipes.map((row) => [row.key, narrationOrdinal(plan.recipes, row.key)])),
145
+ anchors: Object.fromEntries(recipes.map((recipe) => {
146
+ const key = recipe.input.kind === "tts" ? `${recipe.input.logicalKey}:1` : recipe.key;
147
+ return [key, view.revision.fingerprints[key] ?? recipe.logicalFingerprint];
148
+ })),
149
+ };
150
+ const preview = {
151
+ id,
152
+ projectId: view.revision.projectId,
153
+ baseRevisionId: view.revision.id,
154
+ selection,
155
+ planFingerprint: executionFingerprint(view.revision.id, execution),
156
+ work,
157
+ review: previewDetails(deps, view, plan.recipes, selected),
158
+ changedInputs: plan.changedInputs.filter((row) => selected.has(row.path)),
159
+ retained: view.outputs
160
+ .filter((row) => row.selected)
161
+ .map((row) => ({
162
+ slot: row.slot,
163
+ outputId: row.output.id,
164
+ assetId: row.assetId,
165
+ state: row.state,
166
+ })),
167
+ providedReuseRequired: execution.reviews.map((row) => row.key),
168
+ costs: estimateRequests(work.map((row) => priceRecipe(row.inflight || !requiresNewSubmission(deps, view.revision.id, row.key, row.fingerprint)
169
+ ? { ...row, disposition: "reuse" }
170
+ : row, recipes.find((recipe) => recipe.key === row.key))), snapshot),
171
+ wholeRequestNotice: plan.wholeRequestNotice,
172
+ warnings: submissions.some((row) => row.uncertain)
173
+ ? [
174
+ "A previous request was submitted without a saved result or resumable job. Retrying may charge you again.",
175
+ ]
176
+ : [],
177
+ };
178
+ return { ok: true, value: { preview, execution } };
179
+ }
180
+ function invalid(message) {
181
+ return {
182
+ ok: false,
183
+ reason: "invalid-selection",
184
+ fields: [{ field: "request.workKeys", message }],
185
+ };
186
+ }
187
+ function selectedCatalogue(catalogue, recipes, view) {
188
+ const uses = new Set(recipes.flatMap((row) => {
189
+ const choice = recipeProviderChoice(row, view.revision.config);
190
+ return choice === undefined ? [] : [`${choice.family}:${choice.provider}:${choice.model}`];
191
+ }));
192
+ const selected = {
193
+ llm: catalogue.llm.filter((row) => uses.has(`llm:${row.provider}:${row.id}`)),
194
+ tts: catalogue.tts.filter((row) => uses.has(`tts:${row.provider}:${row.id}`)),
195
+ image: catalogue.image.filter((row) => uses.has(`image:${row.provider}:${row.id}`)),
196
+ };
197
+ const providers = new Set([...selected.llm, ...selected.tts, ...selected.image].map((row) => row.provider));
198
+ return {
199
+ ...catalogue,
200
+ ...selected,
201
+ providers: Object.fromEntries(Object.entries(catalogue.providers).filter(([id]) => providers.has(id))),
202
+ };
203
+ }
204
+ function executionFingerprint(revisionId, execution) {
205
+ return requestHash("rebuild-plan-v1", revisionId, {
206
+ ...execution,
207
+ catalogue: { ...execution.catalogue, updatedAt: "1970-01-01" },
208
+ });
209
+ }
210
+ export function reviewStillCovers(reviewed, snapshot, fresh) {
211
+ if (reviewed.planFingerprint === fresh.preview.planFingerprint)
212
+ return true;
213
+ const escalated = fresh.execution.submissions.some((current) => {
214
+ const old = snapshot.submissions.find((row) => row.key === current.key);
215
+ return current.submit && (old?.submit !== true || (current.uncertain && !old.uncertain));
216
+ });
217
+ // A paid request may become a free join while readiness loads, never the reverse.
218
+ return (!escalated &&
219
+ executionFingerprint(reviewed.baseRevisionId, {
220
+ ...fresh.execution,
221
+ submissions: snapshot.submissions,
222
+ }) === reviewed.planFingerprint);
223
+ }
@@ -0,0 +1,128 @@
1
+ import { continuationLimit } from "../article/continuation.js";
2
+ import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
3
+ import { workPieces } from "./work-records.js";
4
+ export function retainedPreviewPlan(deps, view, catalogue) {
5
+ const current = executionPlan(deps, view, catalogue);
6
+ const replacements = new Map();
7
+ const groups = new Set();
8
+ const reservations = deps.db
9
+ .prepare(`SELECT r.*,w.revision_id AS origin_revision,w.recipe_context,w.state FROM revision_work_reservations r JOIN revision_work w ON w.id=r.work_id WHERE r.revision_id=? AND w.recipe_context IS NOT NULL AND w.state!='done'`)
10
+ .all(view.revision.id);
11
+ const origins = new Map();
12
+ for (const row of reservations) {
13
+ const anchor = String(row.logical_key ?? row.work_key);
14
+ if (view.revision.fingerprints[anchor] !== row.desired_fingerprint)
15
+ continue;
16
+ const piece = workPieces(deps.db, String(row.work_id)).find((one) => one.id === row.piece_id);
17
+ if (piece === undefined)
18
+ continue;
19
+ const originKey = `${row.origin_revision}:${row.recipe_context}`;
20
+ let original = origins.get(originKey);
21
+ if (original === undefined) {
22
+ const origin = executionView(deps, view.revision.projectId, String(row.origin_revision));
23
+ if (origin === undefined)
24
+ continue;
25
+ original = executionPlan(deps, origin, savedCatalogue(row.recipe_context));
26
+ origins.set(originKey, original);
27
+ }
28
+ const recipe = original.recipes.find((one) => one.key === piece.key);
29
+ if (recipe === undefined)
30
+ continue;
31
+ replacements.set(piece.key, {
32
+ ...recipe,
33
+ input: piece.input,
34
+ fingerprint: piece.fingerprint,
35
+ requestFingerprint: piece.requestFingerprint,
36
+ logicalFingerprint: piece.logicalFingerprint ?? recipe.logicalFingerprint,
37
+ });
38
+ if (piece.input.kind === "tts") {
39
+ const logicalKey = piece.input.logicalKey;
40
+ groups.add(logicalKey);
41
+ for (const sibling of original.recipes)
42
+ if (sibling.input.kind === "tts" &&
43
+ sibling.input.logicalKey === logicalKey &&
44
+ !replacements.has(sibling.key))
45
+ replacements.set(sibling.key, sibling);
46
+ }
47
+ }
48
+ const recipes = current.recipes.flatMap((row) => {
49
+ const replacement = replacements.get(row.key);
50
+ if (replacement !== undefined)
51
+ return [replacement];
52
+ if (row.input.kind === "tts" && groups.has(row.input.logicalKey))
53
+ return [];
54
+ return [row];
55
+ });
56
+ for (const row of replacements.values())
57
+ if (!recipes.some((one) => one.key === row.key))
58
+ recipes.push(row);
59
+ const work = recipes.flatMap((recipe) => {
60
+ const row = current.work.find((one) => one.key === recipe.key);
61
+ if (row !== undefined)
62
+ return [
63
+ {
64
+ ...row,
65
+ requestFingerprint: recipe.requestFingerprint,
66
+ fingerprint: recipe.fingerprint,
67
+ dependsOn: recipe.dependsOn,
68
+ kind: recipe.kind,
69
+ stage: recipe.stage,
70
+ },
71
+ ];
72
+ if (!replacements.has(recipe.key))
73
+ return [];
74
+ return [
75
+ {
76
+ key: recipe.key,
77
+ stage: recipe.stage,
78
+ kind: recipe.kind,
79
+ requestFingerprint: recipe.requestFingerprint,
80
+ fingerprint: recipe.fingerprint,
81
+ dependsOn: recipe.dependsOn,
82
+ disposition: recipe.kind === "local" ? "local" : "generate",
83
+ reason: "Continue the exact admitted request.",
84
+ inflight: false,
85
+ pieceIds: [],
86
+ },
87
+ ];
88
+ });
89
+ return { ...current, recipes, work };
90
+ }
91
+ export function requiresNewSubmission(deps, revisionId, key, fingerprint) {
92
+ const row = deps.db
93
+ .prepare(`SELECT w.id,w.state,w.dispatch_state,p.continuation FROM revision_work_reservations r JOIN revision_work w ON w.id=r.work_id JOIN revision_work_pieces p ON p.id=r.piece_id WHERE r.revision_id=? AND r.work_key=? AND r.fingerprint=? AND w.recipe_context IS NOT NULL`)
94
+ .get(revisionId, key, fingerprint);
95
+ return (row === undefined ||
96
+ (row.state !== "running" &&
97
+ !(row.state === "pending" && row.dispatch_state === "allowed") &&
98
+ row.continuation === null &&
99
+ !(key === "article:body" && cachedArticleComplete(deps, revisionId, String(row.id)))));
100
+ }
101
+ function cachedArticleComplete(deps, revisionId, workId) {
102
+ const answers = deps.db
103
+ .prepare(`SELECT p.work_key,json_extract(p.result_json,'$.finishReason') AS finish_reason
104
+ FROM revision_work_pieces p
105
+ JOIN revision_work_reservations r ON r.piece_id=p.id AND r.work_id=p.work_id
106
+ AND r.work_key=p.work_key AND r.fingerprint=p.fingerprint
107
+ JOIN project_revisions v ON v.id=r.revision_id
108
+ JOIN json_each(v.fingerprints) f ON f.key=COALESCE(r.logical_key,r.work_key)
109
+ AND f.value=COALESCE(r.desired_fingerprint,r.fingerprint)
110
+ WHERE r.revision_id=? AND p.work_id=? AND p.result_json IS NOT NULL
111
+ AND json_extract(p.input_json,'$.kind')='llm'
112
+ AND COALESCE(r.logical_key,r.work_key)='article:body'`)
113
+ .all(revisionId, workId);
114
+ for (let part = 0; part <= continuationLimit; part += 1) {
115
+ const key = part === 0 ? "article:body" : `article:continuation:${part}`;
116
+ const answer = answers.find((row) => row.work_key === key);
117
+ if (answer === undefined)
118
+ return false;
119
+ if (answer.finish_reason !== "length")
120
+ return true;
121
+ }
122
+ return false;
123
+ }
124
+ export function hasSubmittedRequest(deps, revisionId, key, fingerprint) {
125
+ return (deps.db
126
+ .prepare(`SELECT 1 FROM revision_work_reservations r JOIN revision_work_pieces p ON p.id=r.piece_id WHERE r.revision_id=? AND r.work_key=? AND r.fingerprint=? AND p.submitted_at IS NOT NULL AND p.continuation IS NULL AND p.state!='done'`)
127
+ .get(revisionId, key, fingerprint) !== undefined);
128
+ }