@gentbajko/slopify 1.5.0 → 2.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 (66) hide show
  1. package/README.md +34 -8
  2. package/dist/adapters/alignment/cache.js +67 -7
  3. package/dist/edge/cli.js +10 -2
  4. package/dist/edge/docker-launch.js +77 -0
  5. package/dist/edge/docker-projects/activation.js +35 -0
  6. package/dist/edge/docker-projects/claims.js +85 -0
  7. package/dist/edge/docker-projects/committed.js +40 -0
  8. package/dist/edge/docker-projects/engine.js +384 -0
  9. package/dist/edge/docker-projects/install.js +246 -0
  10. package/dist/edge/docker-projects/recover.js +100 -0
  11. package/dist/edge/docker-projects/state.js +221 -0
  12. package/dist/edge/docker-projects/tree.js +170 -0
  13. package/dist/edge/docker-projects/volume.js +95 -0
  14. package/dist/edge/docker.js +6 -0
  15. package/dist/edge/http/actions.js +64 -15
  16. package/dist/edge/http/app.js +6 -0
  17. package/dist/edge/http/folder-location-schema.js +11 -0
  18. package/dist/edge/http/folder-location.js +65 -0
  19. package/dist/edge/http/open-folder.js +2 -14
  20. package/dist/edge/http/projects.js +2 -0
  21. package/dist/edge/http/revision-files.js +2 -18
  22. package/dist/kernel/db/migrations/0012-project-recovery.sql +17 -0
  23. package/dist/kernel/lock.js +20 -6
  24. package/dist/main.js +11 -1
  25. package/dist/slices/admission/rules.js +6 -0
  26. package/dist/slices/admission/schema.js +3 -1
  27. package/dist/slices/article/plain.js +16 -9
  28. package/dist/slices/control/revision-control.js +2 -2
  29. package/dist/slices/narration/pronunciation-chunks.js +105 -0
  30. package/dist/slices/narration/pronunciation.js +126 -0
  31. package/dist/slices/narration/steering.js +82 -24
  32. package/dist/slices/play-drafts/convert.js +3 -1
  33. package/dist/slices/play-drafts/schema.js +3 -1
  34. package/dist/slices/rebuild/admission-repo.js +7 -4
  35. package/dist/slices/rebuild/preview-plan.js +1 -1
  36. package/dist/slices/rebuild/preview-retained.js +16 -3
  37. package/dist/slices/rebuild/recipe-audio-parts.js +70 -14
  38. package/dist/slices/rebuild/recipe-audio.js +61 -40
  39. package/dist/slices/rebuild/recipe-model.js +5 -3
  40. package/dist/slices/rebuild/recipe-preparation.js +6 -4
  41. package/dist/slices/rebuild/recipe-text.js +11 -3
  42. package/dist/slices/rebuild/recovery-conflict.js +30 -0
  43. package/dist/slices/rebuild/recovery-model.js +44 -0
  44. package/dist/slices/rebuild/recovery-repo.js +84 -0
  45. package/dist/slices/rebuild/recovery-selection.js +68 -0
  46. package/dist/slices/rebuild/recovery.js +257 -0
  47. package/dist/slices/rebuild/runtime-export-inputs.js +3 -2
  48. package/dist/slices/rebuild/runtime-narration-publication.js +30 -0
  49. package/dist/slices/rebuild/runtime-narration-reuse.js +20 -2
  50. package/dist/slices/rebuild/runtime-plan.js +8 -2
  51. package/dist/slices/rebuild/runtime-publication.js +9 -4
  52. package/dist/slices/rebuild/service.js +44 -22
  53. package/dist/slices/rebuild/transition-repo.js +1 -1
  54. package/dist/slices/revisions/mutation-assets.js +6 -1
  55. package/dist/slices/revisions/mutation-request.js +4 -0
  56. package/dist/slices/revisions/mutations.js +10 -5
  57. package/dist/slices/revisions/rules.js +43 -0
  58. package/dist/slices/revisions/schema.js +12 -0
  59. package/dist/slices/settings/repo.js +4 -2
  60. package/dist/web/assets/index-CZNiHm8P.css +1 -0
  61. package/dist/web/assets/index-DPgGLX5m.js +137 -0
  62. package/dist/web/index.html +2 -2
  63. package/package.json +3 -2
  64. package/scripts/docker-run.sh +1 -74
  65. package/dist/web/assets/index-CbGJtCio.js +0 -137
  66. package/dist/web/assets/index-DX1_chKs.css +0 -1
@@ -0,0 +1,257 @@
1
+ import { transact } from "../../kernel/db/tx.js";
2
+ import { withProjectControl } from "../control/lock.js";
3
+ import { requestHash } from "../revisions/mutation-request.js";
4
+ import { saveRevision } from "../revisions/mutations.js";
5
+ import { currentRevisionId } from "../revisions/repo.js";
6
+ import { getRevisionView } from "../revisions/view.js";
7
+ import { planPreview } from "./preview-plan.js";
8
+ import { retainedPreviewPlan } from "./preview-retained.js";
9
+ import { recipeProviderChoice } from "./recipe-provider-choice.js";
10
+ import { acceptedJobMessage, activeConflict, conflictRefusal } from "./recovery-conflict.js";
11
+ import { recoveryRequestSchema, } from "./recovery-model.js";
12
+ import { readRecovery, recoveryAuthority, recoveryKey, rememberRecovery, reserveRecovery, } from "./recovery-repo.js";
13
+ import { dependentClosure, recoverySelection, regenerationEdit, sectionRoots, } from "./recovery-selection.js";
14
+ import { previewById, storePreview } from "./repo.js";
15
+ import { admitCheckedPreview, wakeRebuild } from "./service.js";
16
+ import { checkReadiness } from "./service-readiness.js";
17
+ function credentialsStamp(deps, plan, view) {
18
+ const providers = new Set(plan.execution.recipes.flatMap((recipe) => {
19
+ if (!plan.execution.submissions.some((row) => row.key === recipe.key && row.submit))
20
+ return [];
21
+ const choice = recipeProviderChoice(recipe, view.revision.config);
22
+ return choice === undefined ? [] : [choice.provider];
23
+ }));
24
+ const metadata = deps.db
25
+ .prepare("SELECT provider,credential_generation FROM provider_keys ORDER BY provider")
26
+ .all()
27
+ .filter((row) => providers.has(String(row.provider)));
28
+ return requestHash("recovery-credentials", view.revision.id, metadata);
29
+ }
30
+ export async function recoverProject(deps, projectId, raw) {
31
+ const parsed = recoveryRequestSchema.safeParse(raw);
32
+ if (!parsed.success)
33
+ return {
34
+ ok: false,
35
+ reason: "invalid-selection",
36
+ fields: parsed.error.issues.map((issue) => ({
37
+ field: issue.path.join("."),
38
+ message: issue.message,
39
+ })),
40
+ };
41
+ const input = parsed.data;
42
+ const prepared = await withProjectControl(deps.db, projectId, async () => {
43
+ const found = readRecovery(deps, projectId, input);
44
+ if (found !== undefined && "ok" in found)
45
+ return found;
46
+ const base = getRevisionView(deps, projectId, input.baseRevisionId);
47
+ if (base === undefined)
48
+ return {
49
+ ok: false,
50
+ reason: currentRevisionId(deps.db, projectId) === undefined ? "no-project" : "conflict",
51
+ };
52
+ let record = found;
53
+ if (record === undefined) {
54
+ if (!base.current)
55
+ return { ok: false, reason: "conflict" };
56
+ const plan = retainedPreviewPlan(deps, base, deps.catalogue.read());
57
+ const edit = input.action.kind === "rerun" ? regenerationEdit(base, plan, input.action.stage) : null;
58
+ if (edit === undefined)
59
+ return {
60
+ ok: false,
61
+ reason: "invalid-selection",
62
+ fields: [
63
+ {
64
+ field: "stage",
65
+ message: "This section has no generated work to rerun. Use Edit project to change its source.",
66
+ },
67
+ ],
68
+ };
69
+ record = reserveRecovery(deps, projectId, input, edit);
70
+ }
71
+ const authority = record.authority;
72
+ let intentRevisionId = record.intentRevisionId;
73
+ const finish = (result) => rememberRecovery(deps, projectId, input, !result.ok && intentRevisionId !== null ? { ...result, intentRevisionId } : result);
74
+ if (recoveryAuthority(deps, projectId) !== authority)
75
+ return finish({ ok: false, reason: "control-changed" });
76
+ let view = base;
77
+ if (record.edit !== null) {
78
+ const prior = retainedPreviewPlan(deps, base, deps.catalogue.read());
79
+ const action = input.action;
80
+ if (action.kind !== "rerun")
81
+ throw new Error("Recovery edit has no rerun action");
82
+ const affected = dependentClosure(prior.recipes, sectionRoots(base, prior, action.stage));
83
+ const savedIntent = deps.db
84
+ .prepare("SELECT result_revision_id FROM revision_mutations WHERE project_id=? AND idempotency_key=?")
85
+ .get(projectId, recoveryKey(input, "save"));
86
+ const conflict = savedIntent === undefined ? activeConflict(deps, projectId, affected) : undefined;
87
+ if (conflict !== undefined)
88
+ return finish(conflictRefusal(conflict));
89
+ const saved = await saveRevision(deps, {
90
+ projectId,
91
+ baseRevisionId: input.baseRevisionId,
92
+ idempotencyKey: recoveryKey(input, "save"),
93
+ edit: record.edit,
94
+ beforeCommit: () => {
95
+ const late = activeConflict(deps, projectId, affected);
96
+ return late === undefined
97
+ ? undefined
98
+ : {
99
+ ok: false,
100
+ reason: "invalid-edit",
101
+ currentRevisionId: currentRevisionId(deps.db, projectId) ?? null,
102
+ fields: [
103
+ {
104
+ field: "stage",
105
+ message: late === "running"
106
+ ? "Wait for this section to finish, or Pause the project before rerunning it."
107
+ : acceptedJobMessage,
108
+ },
109
+ ],
110
+ };
111
+ },
112
+ });
113
+ if (!saved.ok)
114
+ return finish(saved);
115
+ view = saved.view;
116
+ intentRevisionId = view.revision.id;
117
+ deps.db
118
+ .prepare("UPDATE project_recovery_requests SET intent_revision_id=? WHERE project_id=? AND idempotency_key=?")
119
+ .run(view.revision.id, projectId, input.idempotencyKey);
120
+ }
121
+ if (currentRevisionId(deps.db, projectId) !== view.revision.id)
122
+ return finish({ ok: false, reason: "conflict" });
123
+ const plan = retainedPreviewPlan(deps, view, deps.catalogue.read());
124
+ const rerunKeys = input.action.kind === "rerun"
125
+ ? dependentClosure(plan.recipes, sectionRoots(view, plan, input.action.stage))
126
+ : [];
127
+ const selection = input.action.kind === "rerun"
128
+ ? { kind: "selected", workKeys: rerunKeys }
129
+ : recoverySelection(plan, input.action.kind === "retry" ? input.action.stage : undefined);
130
+ if (selection.kind === "selected" && selection.workKeys.length === 0)
131
+ return finish({
132
+ ok: false,
133
+ reason: "invalid-selection",
134
+ fields: [
135
+ {
136
+ field: "stage",
137
+ message: "This section has no enabled work. Use Edit project to change its source.",
138
+ },
139
+ ],
140
+ });
141
+ const preview = planPreview(deps, view, deps.catalogue.read(), selection, deps.ids.next());
142
+ if (!preview.ok)
143
+ return finish(preview);
144
+ if (preview.value.preview.providedReuseRequired.length > 0)
145
+ return finish({
146
+ ok: false,
147
+ reason: "review-required",
148
+ fields: preview.value.preview.providedReuseRequired.map((key) => ({
149
+ field: key,
150
+ message: "Review changed supplied content or manual captions in Edit project or Advanced rebuild review.",
151
+ })),
152
+ });
153
+ const blocked = preview.value.preview.work.filter((row) => row.disposition === "blocked");
154
+ if (blocked.length > 0)
155
+ return finish({
156
+ ok: false,
157
+ reason: "readiness",
158
+ fields: blocked.map((row) => ({ field: row.key, message: row.reason })),
159
+ });
160
+ if (input.action.kind === "rerun" &&
161
+ input.action.stage === "video" &&
162
+ preview.value.preview.work.some((row) => row.kind === "provider" && row.disposition !== "reuse"))
163
+ return finish({
164
+ ok: false,
165
+ reason: "readiness",
166
+ fields: [
167
+ {
168
+ field: "video",
169
+ message: "Source media is incomplete. Use Resume to recover it before rerunning the export.",
170
+ },
171
+ ],
172
+ });
173
+ const conflict = rerunKeys.length > 0 ? activeConflict(deps, projectId, rerunKeys) : undefined;
174
+ if (conflict !== undefined)
175
+ return finish(conflictRefusal(conflict));
176
+ storePreview(deps, preview.value.preview, preview.value.execution);
177
+ const stored = previewById(deps.db, projectId, preview.value.preview.id);
178
+ if (stored === undefined)
179
+ throw new Error("Stored recovery preview is missing.");
180
+ return {
181
+ plan: { preview: stored, execution: preview.value.execution },
182
+ view,
183
+ authority,
184
+ rerunKeys,
185
+ credentials: credentialsStamp(deps, preview.value, view),
186
+ };
187
+ });
188
+ if ("ok" in prepared)
189
+ return prepared;
190
+ let ready;
191
+ try {
192
+ ready = await checkReadiness(deps, prepared.plan.execution, prepared.view);
193
+ }
194
+ catch {
195
+ deps.log.write("warn", "project.recovery", {
196
+ projectId,
197
+ ...(input.action.kind === "resume" ? {} : { stage: input.action.stage }),
198
+ detail: "Provider readiness could not be loaded.",
199
+ });
200
+ ready = {
201
+ fields: [{ field: "provider", message: "Check provider readiness and try Resume." }],
202
+ providers: [],
203
+ };
204
+ }
205
+ let admittedHere = false;
206
+ const result = await withProjectControl(deps.db, projectId, () => transact(deps.db, () => {
207
+ const receipt = readRecovery(deps, projectId, input);
208
+ if (receipt !== undefined && "ok" in receipt)
209
+ return receipt;
210
+ if (receipt === undefined)
211
+ return { ok: false, reason: "no-project" };
212
+ const finish = (value) => rememberRecovery(deps, projectId, input, !value.ok && receipt.intentRevisionId !== null
213
+ ? { ...value, intentRevisionId: receipt.intentRevisionId }
214
+ : value);
215
+ if (recoveryAuthority(deps, projectId) !== prepared.authority)
216
+ return finish({ ok: false, reason: "control-changed" });
217
+ if (currentRevisionId(deps.db, projectId) !== prepared.view.revision.id)
218
+ return finish({ ok: false, reason: "conflict" });
219
+ if (credentialsStamp(deps, prepared.plan, prepared.view) !== prepared.credentials)
220
+ return finish({
221
+ ok: false,
222
+ reason: "readiness",
223
+ fields: [
224
+ { field: "provider", message: "Provider credentials changed. Try Resume again." },
225
+ ],
226
+ });
227
+ if (ready.fields.length > 0)
228
+ return finish({ ok: false, reason: "readiness", fields: [...ready.fields] });
229
+ const conflict = prepared.rerunKeys.length > 0
230
+ ? activeConflict(deps, projectId, prepared.rerunKeys)
231
+ : undefined;
232
+ if (conflict !== undefined)
233
+ return finish(conflictRefusal(conflict));
234
+ const admitted = admitCheckedPreview(deps, prepared.plan.preview, prepared.plan.execution, ready.providers, recoveryKey(input, "admit"), requestHash("direct-recovery", input.baseRevisionId, input.action));
235
+ if (!admitted.ok)
236
+ return finish(admitted);
237
+ admittedHere = true;
238
+ return finish({
239
+ ok: true,
240
+ value: {
241
+ ...admitted.value,
242
+ workIds: [...admitted.value.workIds],
243
+ warnings: [
244
+ ...prepared.plan.preview.warnings,
245
+ ...(prepared.plan.preview.costs.unknown > 0
246
+ ? ["Some generation prices are unknown; actual usage is recorded."]
247
+ : []),
248
+ ],
249
+ },
250
+ });
251
+ }));
252
+ if (result.ok && admittedHere) {
253
+ deps.emit(projectId, { type: "project.updated", projectId });
254
+ wakeRebuild(deps, projectId, result.value);
255
+ }
256
+ return result;
257
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { z } from "zod";
3
- import { usesNarrationPreparation } from "../admission/rules.js";
3
+ import { usesNarrationPreparation, usesPronunciationGlossary } from "../admission/rules.js";
4
4
  import { plainText } from "../article/plain.js";
5
5
  import { splitEndMatter } from "../article/split.js";
6
6
  import { outputPath } from "../storage/layout.js";
@@ -53,7 +53,8 @@ export async function revisionAudio(deps, context, view) {
53
53
  }
54
54
  export function revisionTranscript(deps, snapshot, kind) {
55
55
  const { view, plan } = snapshot;
56
- if (usesNarrationPreparation(view.revision.config))
56
+ if (usesNarrationPreparation(view.revision.config) ||
57
+ usesPronunciationGlossary(view.revision.config))
57
58
  return joinedNarration(narrationTextParts(view, plan, kind));
58
59
  const concat = plan.recipes.find((one) => one.key === (kind === "body" ? "audio:body:concat" : `audio:${kind}`));
59
60
  if (concat !== undefined) {
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ import { projectSelected } from "../revisions/projection.js";
3
+ import { getRevisionView } from "../revisions/view.js";
4
+ import { bindNarrationReuse, narrationOrdinal } from "./runtime-narration-reuse.js";
5
+ import { executionPlan, savedCatalogue } from "./runtime-plan.js";
6
+ export function rebindPublishedNarration(deps, work, piece) {
7
+ if (piece.input.kind !== "tts")
8
+ return;
9
+ const targets = deps.db
10
+ .prepare("SELECT revision_id FROM revision_pieces WHERE project_id=? AND publication_id=? AND piece_key=? AND selected=1 AND revision_id!=?")
11
+ .all(work.projectId, piece.id, piece.key, work.revisionId);
12
+ if (targets.length === 0)
13
+ return;
14
+ const row = deps.db
15
+ .prepare("SELECT recipe_context FROM revision_work WHERE id=?")
16
+ .get(work.workId);
17
+ const catalogue = savedCatalogue(row?.recipe_context);
18
+ for (const target of targets) {
19
+ const view = getRevisionView(deps, work.projectId, z.string().parse(target.revision_id));
20
+ if (view === undefined)
21
+ continue;
22
+ const plan = executionPlan(deps, view, catalogue);
23
+ const recipe = plan.recipes.find((value) => value.key === piece.key && value.fingerprint === piece.fingerprint);
24
+ if (recipe === undefined || recipe.unresolved || recipe.deferred)
25
+ continue;
26
+ bindNarrationReuse(deps, view, recipe, narrationOrdinal(plan.recipes, recipe.key));
27
+ if (view.current)
28
+ projectSelected(deps.db, view.revision);
29
+ }
30
+ }
@@ -83,12 +83,14 @@ export function bindNarrationReuse(deps, view, recipe, ordinal) {
83
83
  }
84
84
  if (recipe.input.kind !== "tts")
85
85
  return;
86
+ const input = recipe.input;
86
87
  if (view.pieces.some((one) => one.selected &&
87
88
  one.available &&
88
89
  one.key === recipe.key &&
89
90
  one.fingerprint === recipe.fingerprint &&
90
91
  one.piece.state === "done" &&
91
- one.piece.idx === ordinal))
92
+ one.piece.idx === ordinal &&
93
+ narrationMetadataMatches(one.piece.payload, input)))
92
94
  return;
93
95
  const history = [...view.pieces, ...retainedNarrationPieces(deps, view.revision.projectId)];
94
96
  const available = new Set(history
@@ -97,7 +99,6 @@ export function bindNarrationReuse(deps, view, recipe, ordinal) {
97
99
  const old = matchingNarrationPiece(recipe, history, available);
98
100
  if (old === undefined)
99
101
  return;
100
- const input = recipe.input;
101
102
  const recordId = deps.ids.next();
102
103
  const payload = JSON.parse(old.piece.payload ?? "{}");
103
104
  insertManifestPiece(deps.db, view.revision, {
@@ -125,6 +126,23 @@ export function bindNarrationReuse(deps, view, recipe, ordinal) {
125
126
  }, recordId);
126
127
  selectPieceRecord(deps.db, view.revision.id, recipe.key, recordId);
127
128
  }
129
+ function narrationMetadataMatches(payload, input) {
130
+ const parsed = z
131
+ .object({
132
+ text: z.string().optional(),
133
+ spokenText: z.string().optional(),
134
+ logicalKey: z.string().optional(),
135
+ logicalText: z.string().optional(),
136
+ segment: z.string().optional(),
137
+ })
138
+ .safeParse(JSON.parse(payload ?? "{}"));
139
+ return (parsed.success &&
140
+ parsed.data.text === input.text &&
141
+ parsed.data.spokenText === input.spokenText &&
142
+ parsed.data.logicalKey === input.logicalKey &&
143
+ parsed.data.logicalText === input.logicalText &&
144
+ parsed.data.segment === input.segment);
145
+ }
128
146
  export function narrationOrdinal(recipes, key) {
129
147
  const entry = /^audio:(intro|outro):([0-9]+)$/.exec(key);
130
148
  if (entry !== null)
@@ -24,14 +24,20 @@ export function savedCatalogue(value) {
24
24
  }
25
25
  export function executionPlan(deps, view, catalogue) {
26
26
  const textOutput = (role) => {
27
- const row = view.outputs.find((output) => output.selected && output.available && output.output.role === role);
27
+ const row = view.outputs.find((output) => output.selected &&
28
+ output.available &&
29
+ output.state === "ready" &&
30
+ output.output.role === role);
28
31
  return row === undefined
29
32
  ? null
30
33
  : readFileSync(outputPath(deps.paths, view.revision.projectId, row.output.path), "utf8");
31
34
  };
32
35
  const payloads = view.pieces.filter((piece) => piece.selected && piece.piece.state === "done" && piece.piece.payload !== null);
33
36
  const resolved = {
34
- articleMarkdown: view.articleMarkdown ?? textOutput("article_md"),
37
+ articleMarkdown: view.revision.content.articleEdited === true ||
38
+ view.revision.config.sources.article === "provide"
39
+ ? view.articleMarkdown
40
+ : textOutput("article_md"),
35
41
  researchNotes: textOutput("notes"),
36
42
  };
37
43
  const research = matchingResearch({
@@ -4,6 +4,7 @@ import { publicationAuthority } from "../revisions/publication-rules.js";
4
4
  import { commitRevisionOutputs } from "../revisions/publish.js";
5
5
  import { insertAsset, insertManifestOutput, revisionById } from "../revisions/repo.js";
6
6
  import { discardPreparedAssets, writeAsset } from "../storage/assets.js";
7
+ import { rebindPublishedNarration } from "./runtime-narration-publication.js";
7
8
  import { executionPlan, executionView, savedCatalogue } from "./runtime-plan.js";
8
9
  export function preparedResult(deps, context, piece, role, asset, durationMs = null, meta = {}) {
9
10
  return {
@@ -65,10 +66,14 @@ export async function publishResult(deps, context, piece, outputs, payload, asse
65
66
  }),
66
67
  },
67
68
  };
68
- const result = await commitRevisionOutputs(deps, { work: context.work, pieceId: piece.id, publicationId: piece.id }, outputs, [record]);
69
- deps.db
70
- .prepare("UPDATE revision_work_pieces SET state='done' WHERE id=? AND work_id=?")
71
- .run(piece.id, context.work.workId);
69
+ const result = transact(deps.db, () => {
70
+ const committed = commitRevisionOutputs(deps, { work: context.work, pieceId: piece.id, publicationId: piece.id }, outputs, [record]);
71
+ rebindPublishedNarration(deps, context.work, piece);
72
+ deps.db
73
+ .prepare("UPDATE revision_work_pieces SET state='done' WHERE id=? AND work_id=?")
74
+ .run(piece.id, context.work.workId);
75
+ return committed;
76
+ });
72
77
  if (result.currentAttached)
73
78
  context.emit({ type: "project.updated", projectId: context.work.projectId });
74
79
  }
@@ -3,7 +3,7 @@ import { requestHash } from "../revisions/mutation-request.js";
3
3
  import { currentRevisionId } from "../revisions/repo.js";
4
4
  import { getRevisionView } from "../revisions/view.js";
5
5
  import { previewRebuildSchema, startRebuildSchema, } from "./model.js";
6
- import { executionSnapshotSchema, planPreview, reviewStillCovers } from "./preview-plan.js";
6
+ import { executionSnapshotSchema, planPreview, reviewStillCovers, } from "./preview-plan.js";
7
7
  import { admissionReceipt, admitPreview, previewById, storePreview } from "./repo.js";
8
8
  import { checkReadiness, localReadiness } from "./service-readiness.js";
9
9
  export function rebuildConfirmations(required, confirmed, unknownCosts, acknowledged) {
@@ -60,7 +60,7 @@ export async function startRebuild(deps, input) {
60
60
  const replay = admissionReceipt(deps.db, projectId, input.idempotencyKey, hash);
61
61
  if (replay !== undefined) {
62
62
  if (replay.ok)
63
- wake(deps, projectId, replay.value);
63
+ wakeRebuild(deps, projectId, replay.value);
64
64
  return replay;
65
65
  }
66
66
  const preview = previewById(deps.db, projectId, input.previewId);
@@ -100,34 +100,56 @@ export async function startRebuild(deps, input) {
100
100
  const again = admissionReceipt(deps.db, projectId, input.idempotencyKey, hash);
101
101
  if (again !== undefined) {
102
102
  if (again.ok)
103
- wake(deps, projectId, again.value);
103
+ wakeRebuild(deps, projectId, again.value);
104
104
  return again;
105
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
- });
106
+ const admitted = admitCheckedPreview(deps, preview, snapshot, ready.providers, input.idempotencyKey, hash);
123
107
  if (admitted.ok) {
124
108
  deps.emit(projectId, { type: "project.updated", projectId });
125
- wake(deps, projectId, admitted.value);
109
+ wakeRebuild(deps, projectId, admitted.value);
126
110
  }
127
111
  return admitted;
128
112
  });
129
113
  }
130
- function wake(deps, projectId, admission) {
114
+ export function admitCheckedPreview(deps, preview, snapshot, providers, idempotencyKey, hash) {
115
+ const replay = admissionReceipt(deps.db, preview.projectId, idempotencyKey, hash);
116
+ if (replay !== undefined)
117
+ return replay;
118
+ if (currentRevisionId(deps.db, preview.projectId) !== preview.baseRevisionId)
119
+ return { ok: false, reason: "conflict" };
120
+ const current = getRevisionView(deps, preview.projectId, preview.baseRevisionId);
121
+ if (current === undefined)
122
+ return { ok: false, reason: "no-project" };
123
+ const catalogue = deps.catalogue.read();
124
+ const fresh = planPreview(deps, current, catalogue, preview.selection, preview.id);
125
+ if (!fresh.ok || !reviewStillCovers(preview, snapshot, fresh.value))
126
+ return { ok: false, reason: "stale-preview" };
127
+ const fields = localReadiness(deps, snapshot, current, providers, catalogue);
128
+ if (fields.length > 0)
129
+ return { ok: false, reason: "readiness", fields };
130
+ for (const recipe of snapshot.recipes) {
131
+ const running = deps.db
132
+ .prepare("SELECT r.fingerprint FROM revision_work_reservations r " +
133
+ "JOIN revision_work w ON w.id=r.work_id WHERE r.revision_id=? " +
134
+ "AND r.work_key=? AND w.state='running'")
135
+ .get(preview.baseRevisionId, recipe.key);
136
+ if (running !== undefined && running.fingerprint !== recipe.fingerprint)
137
+ return {
138
+ ok: false,
139
+ reason: "conflict",
140
+ fields: [
141
+ { field: recipe.key, message: "Wait for this work to finish, or Pause before retrying." },
142
+ ],
143
+ };
144
+ }
145
+ return admitPreview(deps, {
146
+ preview,
147
+ planningCatalogue: catalogue,
148
+ idempotencyKey,
149
+ requestHash: hash,
150
+ });
151
+ }
152
+ export function wakeRebuild(deps, projectId, admission) {
131
153
  const owned = deps.db
132
154
  .prepare("SELECT 1 FROM revision_work WHERE project_id=? AND admission_id=? AND state!='done' AND dispatch_state='allowed' LIMIT 1")
133
155
  .get(projectId, admission.admissionId) !== undefined;
@@ -96,7 +96,7 @@ function stageForKey(key) {
96
96
  return "research";
97
97
  if (prefix === "article" || prefix === "entry")
98
98
  return "article";
99
- if (prefix === "audio")
99
+ if (prefix === "audio" || prefix === "narration")
100
100
  return "audio";
101
101
  if (prefix === "image")
102
102
  return "images";
@@ -89,7 +89,12 @@ export function validateAssetReferences(deps, projectId, content, prepared) {
89
89
  (!allowPiece ||
90
90
  deps.db
91
91
  .prepare("SELECT 1 FROM revision_pieces WHERE project_id=? AND asset_id=? AND stage_kind=? AND json_extract(descriptor,'$.kind') IN (SELECT value FROM json_each(?))")
92
- .get(projectId, id, kind, JSON.stringify(kind === "audio" ? ["chunk", "segment"] : ["image"])) === undefined))
92
+ .get(projectId, id, kind, JSON.stringify(kind === "audio" ? ["chunk", "segment"] : ["image"])) === undefined) &&
93
+ !(kind === "audio" &&
94
+ allowPiece &&
95
+ deps.db
96
+ .prepare("SELECT 1 FROM project_revisions r, json_each(r.content, '$.narrationOverrides') o WHERE r.project_id=? AND json_extract(o.value,'$.kind')='asset' AND json_extract(o.value,'$.assetId')=? LIMIT 1")
97
+ .get(projectId, id) !== undefined))
93
98
  fields.push({ field, message: "Choose an asset for this content stage." });
94
99
  }
95
100
  return fields;
@@ -39,6 +39,10 @@ export function checkMutation(deps, input) {
39
39
  .prepare("SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=?")
40
40
  .get(input.projectId, input.idempotencyKey) !== undefined)
41
41
  return refusal(deps, input.projectId, "idempotency-conflict");
42
+ if (db
43
+ .prepare("SELECT 1 FROM project_recovery_requests WHERE project_id=? AND idempotency_key=?")
44
+ .get(input.projectId, input.idempotencyKey) !== undefined)
45
+ return refusal(deps, input.projectId, "idempotency-conflict");
42
46
  if (db.prepare("SELECT 1 FROM projects WHERE id=?").get(input.projectId) === undefined)
43
47
  return refusal(deps, input.projectId, "no-project");
44
48
  if (currentRevisionId(db, input.projectId) !== input.baseRevisionId)
@@ -16,7 +16,7 @@ import { checkMutation, insertReceipt, requestHash, requiredView } from "./mutat
16
16
  import { preserveDeferredIntent } from "./mutation-work.js";
17
17
  import { cloneManifest, ensureRevisionStages, projectSelected } from "./projection.js";
18
18
  import { insertAsset, insertRevision } from "./repo.js";
19
- import { validateNarrationIntent, validateTemplateIntent } from "./rules.js";
19
+ import { bindNarrationSources, validateNarrationIntent, validateTemplateIntent } from "./rules.js";
20
20
  import { revisionEditSchema } from "./schema.js";
21
21
  export { bindUpload } from "./mutation-assets.js";
22
22
  export { restoreRevision } from "./restore.js";
@@ -32,16 +32,18 @@ export async function saveRevision(deps, input) {
32
32
  message: row.message,
33
33
  })),
34
34
  };
35
- const edit = parsed.data;
35
+ const { narrationSources: _submitted, ...content } = parsed.data.content;
36
+ const submitted = { ...parsed.data, content };
36
37
  const identity = {
37
38
  ...input,
38
39
  operation: "save",
39
- hash: requestHash("save", input.baseRevisionId, edit),
40
+ hash: requestHash("save", input.baseRevisionId, submitted),
40
41
  };
41
42
  const checked = checkMutation(deps, identity);
42
43
  if (checked !== undefined)
43
44
  return checked;
44
45
  const base = requiredView(deps, input.projectId, input.baseRevisionId);
46
+ const edit = bindNarrationSources(base, submitted);
45
47
  const fields = [
46
48
  ...validateUploads(deps, edit),
47
49
  ...validateTemplateIntent(base, edit),
@@ -65,14 +67,17 @@ export async function saveRevision(deps, input) {
65
67
  const checked = checkMutation(deps, identity);
66
68
  if (checked !== undefined)
67
69
  return checked;
70
+ const blocked = input.beforeCommit?.();
71
+ if (blocked !== undefined)
72
+ return blocked;
68
73
  const fresh = requiredView(deps, input.projectId, input.baseRevisionId);
69
74
  const tokens = { ...fresh.revision.content.regenerationTokens };
70
75
  for (const key of new Set((edit.regenerate ?? []).map(narrationRegenerationKey)))
71
76
  tokens[key] = deps.ids.next();
72
- const finalEdit = {
77
+ const finalEdit = bindNarrationSources(fresh, {
73
78
  ...supplied,
74
79
  content: { ...supplied.content, regenerationTokens: tokens },
75
- };
80
+ });
76
81
  const outputAssets = prepared.filter((row) => row.upload?.destination.kind !== "narration");
77
82
  const inspected = measuredOutputs(deps, fresh, durations);
78
83
  const preparedManifest = {
@@ -1,6 +1,49 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
+ import { narrationRegenerationKey } from "../narration/plan.js";
3
+ import { bodyNarrationGroups } from "../rebuild/recipe-audio.js";
2
4
  import { buildRecipes } from "../rebuild/recipe-build.js";
3
5
  import { normalizeArticleIntent, validateRevisionEdit } from "../rebuild/recipe-save.js";
6
+ import { textRecipes } from "../rebuild/recipe-text.js";
7
+ export function bindNarrationSources(base, edit) {
8
+ const { narrationSources: _submitted, ...content } = normalizeArticleIntent(base, edit);
9
+ const keys = new Set([
10
+ ...Object.keys(content.narrationOverrides),
11
+ ...Object.keys(base.revision.content.regenerationTokens),
12
+ ...(edit.regenerate ?? []).map(narrationRegenerationKey),
13
+ ...(edit.uploads ?? []).flatMap((upload) => upload.destination.kind === "narration" ? [upload.destination.key] : []),
14
+ ]);
15
+ if (keys.size === 0)
16
+ return { ...edit, content };
17
+ const previous = {
18
+ config: base.revision.config,
19
+ content: base.revision.content,
20
+ manifest: base,
21
+ resolved: {
22
+ articleMarkdown: base.articleMarkdown,
23
+ researchNotes: base.revision.config.provided.research ?? null,
24
+ },
25
+ };
26
+ const sources = {
27
+ ...base.revision.content.narrationSources,
28
+ ...Object.fromEntries(bodyNarrationGroups(previous, textRecipes(previous)).flatMap((group) => group.source === undefined ? [] : [[group.key, group.source]])),
29
+ };
30
+ const proposed = {
31
+ ...previous,
32
+ config: edit.config,
33
+ content: {
34
+ ...content,
35
+ narrationSources: Object.fromEntries(Object.entries(sources).filter(([key]) => keys.has(key))),
36
+ },
37
+ };
38
+ const bindings = Object.fromEntries(bodyNarrationGroups(proposed, textRecipes(proposed)).flatMap((group) => group.source !== undefined && keys.has(group.key) ? [[group.key, group.source]] : []));
39
+ return {
40
+ ...edit,
41
+ content: {
42
+ ...content,
43
+ ...(Object.keys(bindings).length > 0 ? { narrationSources: bindings } : {}),
44
+ },
45
+ };
46
+ }
4
47
  export function validateCues(cues, duration) {
5
48
  const fields = [];
6
49
  const seen = new Set();
@@ -47,6 +47,18 @@ export const revisionContentSchema = z
47
47
  z.object({ kind: z.literal("asset"), assetId: id }).strict(),
48
48
  z.object({ kind: z.literal("text"), text: z.string().trim().min(1).max(500000) }).strict(),
49
49
  ])),
50
+ narrationSources: z
51
+ .record(workKey, z
52
+ .object({
53
+ text: z.string().min(1).max(500000),
54
+ start: z.number().int().nonnegative().max(500000),
55
+ bodyFingerprint: z.string().regex(/^[0-9a-f]{64}$/u),
56
+ chunkingFingerprint: z.string().regex(/^[0-9a-f]{64}$/u),
57
+ })
58
+ .strict())
59
+ .refine((rows) => Object.keys(rows).length <= 10000 &&
60
+ Object.values(rows).reduce((length, row) => length + row.text.length, 0) <= 500000, "Narration source bindings exceed the article limit.")
61
+ .optional(),
50
62
  subtitleCues: z
51
63
  .object({
52
64
  audioFingerprint: z.string().min(1),