@gentbajko/slopify 1.5.1 → 2.0.1
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.
- package/README.md +34 -8
- package/dist/adapters/alignment/cache.js +67 -7
- package/dist/edge/cli.js +10 -2
- package/dist/edge/docker-launch.js +77 -0
- package/dist/edge/docker-projects/activation.js +35 -0
- package/dist/edge/docker-projects/claims.js +85 -0
- package/dist/edge/docker-projects/committed.js +40 -0
- package/dist/edge/docker-projects/engine.js +384 -0
- package/dist/edge/docker-projects/install.js +246 -0
- package/dist/edge/docker-projects/recover.js +100 -0
- package/dist/edge/docker-projects/state.js +221 -0
- package/dist/edge/docker-projects/tree.js +170 -0
- package/dist/edge/docker-projects/volume.js +95 -0
- package/dist/edge/docker.js +6 -0
- package/dist/edge/http/actions.js +64 -15
- package/dist/edge/http/app.js +6 -0
- package/dist/edge/http/folder-location-schema.js +11 -0
- package/dist/edge/http/folder-location.js +65 -0
- package/dist/edge/http/open-folder.js +2 -14
- package/dist/edge/http/projects.js +2 -0
- package/dist/edge/http/revision-files.js +2 -18
- package/dist/kernel/db/migrations/0012-project-recovery.sql +17 -0
- package/dist/kernel/lock.js +20 -6
- package/dist/main.js +11 -1
- package/dist/slices/admission/rules.js +6 -0
- package/dist/slices/admission/schema.js +3 -1
- package/dist/slices/article/plain.js +16 -9
- package/dist/slices/control/revision-control.js +2 -2
- package/dist/slices/narration/pronunciation-chunks.js +105 -0
- package/dist/slices/narration/pronunciation.js +126 -0
- package/dist/slices/narration/steering.js +82 -24
- package/dist/slices/play-drafts/convert.js +3 -1
- package/dist/slices/play-drafts/schema.js +3 -1
- package/dist/slices/rebuild/admission-repo.js +7 -4
- package/dist/slices/rebuild/recipe-audio-parts.js +70 -14
- package/dist/slices/rebuild/recipe-audio.js +61 -40
- package/dist/slices/rebuild/recipe-model.js +5 -3
- package/dist/slices/rebuild/recipe-preparation.js +6 -4
- package/dist/slices/rebuild/recipe-text.js +11 -3
- package/dist/slices/rebuild/recovery-conflict.js +30 -0
- package/dist/slices/rebuild/recovery-model.js +44 -0
- package/dist/slices/rebuild/recovery-repo.js +84 -0
- package/dist/slices/rebuild/recovery-selection.js +68 -0
- package/dist/slices/rebuild/recovery.js +257 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +3 -2
- package/dist/slices/rebuild/runtime-narration-publication.js +30 -0
- package/dist/slices/rebuild/runtime-narration-reuse.js +20 -2
- package/dist/slices/rebuild/runtime-plan.js +8 -2
- package/dist/slices/rebuild/runtime-publication.js +9 -4
- package/dist/slices/rebuild/service.js +44 -22
- package/dist/slices/rebuild/transition-repo.js +1 -1
- package/dist/slices/revisions/mutation-assets.js +6 -1
- package/dist/slices/revisions/mutation-request.js +4 -0
- package/dist/slices/revisions/mutations.js +10 -5
- package/dist/slices/revisions/projection.js +9 -1
- package/dist/slices/revisions/rules.js +43 -0
- package/dist/slices/revisions/schema.js +12 -0
- package/dist/slices/settings/repo.js +4 -2
- package/dist/web/assets/index-CZNiHm8P.css +1 -0
- package/dist/web/assets/index-DPgGLX5m.js +137 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -2
- package/scripts/docker-run.sh +1 -74
- package/dist/web/assets/index-CbGJtCio.js +0 -137
- package/dist/web/assets/index-DX1_chKs.css +0 -1
|
@@ -36,11 +36,13 @@ export function recipe(context, key, stage, input, dependsOn = [], options = {})
|
|
|
36
36
|
wholeText: input.wholeRequest === true ? input.logicalText : null,
|
|
37
37
|
})
|
|
38
38
|
: fingerprint(JSON.parse(JSON.stringify(input, (_key, value) => typeof value === "number" ? z.number().finite().parse(value) : value)));
|
|
39
|
+
const token = input.kind === "tts"
|
|
40
|
+
? narrationRegenerationToken(context.content.regenerationTokens, input.logicalKey, input.segment)
|
|
41
|
+
: (context.content.regenerationTokens[options.tokenKey ?? key] ?? null);
|
|
42
|
+
const researchToken = stage === "research" ? context.content.regenerationTokens["research:all"] : undefined;
|
|
39
43
|
const workFingerprint = fingerprint([
|
|
40
44
|
requestFingerprint,
|
|
41
|
-
|
|
42
|
-
? narrationRegenerationToken(context.content.regenerationTokens, input.logicalKey, input.segment)
|
|
43
|
-
: (context.content.regenerationTokens[options.tokenKey ?? key] ?? null),
|
|
45
|
+
researchToken === undefined ? token : fingerprint([researchToken, token]),
|
|
44
46
|
]);
|
|
45
47
|
return {
|
|
46
48
|
key,
|
|
@@ -12,7 +12,7 @@ export function preparationTemplate(context) {
|
|
|
12
12
|
JSON.parse(JSON.stringify(context.content.narrationOverrides)),
|
|
13
13
|
];
|
|
14
14
|
}
|
|
15
|
-
export function preparationFuture(context, segment, dependency) {
|
|
15
|
+
export function preparationFuture(context, segment, dependency, additionalDependencies = []) {
|
|
16
16
|
return recipe(context, `narration:prepare:${segment}:future`, "audio", {
|
|
17
17
|
kind: "deferred",
|
|
18
18
|
version: 1,
|
|
@@ -21,10 +21,11 @@ export function preparationFuture(context, segment, dependency) {
|
|
|
21
21
|
dependency.fingerprint,
|
|
22
22
|
preparationTemplate(context),
|
|
23
23
|
JSON.parse(JSON.stringify(context.config.chunking ?? null)),
|
|
24
|
+
...additionalDependencies.map((row) => row.fingerprint),
|
|
24
25
|
],
|
|
25
|
-
}, [dependency.key]);
|
|
26
|
+
}, [...new Set([dependency.key, ...additionalDependencies.map((row) => row.key)])]);
|
|
26
27
|
}
|
|
27
|
-
export function preparationForGroup(context, logicalKey, source, segment, dependsOn, maxCharacters) {
|
|
28
|
+
export function preparationForGroup(context, logicalKey, source, segment, dependsOn, maxCharacters, spans = []) {
|
|
28
29
|
const preparation = recipe(context, `narration:prepare:${segment}:${logicalKey}`, "audio", {
|
|
29
30
|
...llmInput(context, preparationMessages(renderedPrompt(context, "narration"), source)),
|
|
30
31
|
preparation: { format: "inworld-tts-2", version: 1, source, logicalKey, segment },
|
|
@@ -58,8 +59,9 @@ export function preparationForGroup(context, logicalKey, source, segment, depend
|
|
|
58
59
|
return { preparation, requests: null, refusal: validated.reason };
|
|
59
60
|
const prepared = prepareRequests(source, validated.cues, context.catalogue === undefined
|
|
60
61
|
? Math.max(2, source.length +
|
|
62
|
+
spans.reduce((sum, span) => sum + Math.max(0, span.text.length - (span.end - span.start)), 0) +
|
|
61
63
|
validated.cues.reduce((n, cue) => n + (cue.kind === "instruction" ? cue.text.length : 20) + 3, 0))
|
|
62
|
-
: maxCharacters);
|
|
64
|
+
: maxCharacters, spans);
|
|
63
65
|
return prepared.ok
|
|
64
66
|
? { preparation, requests: prepared.requests, refusal: null }
|
|
65
67
|
: { preparation, requests: null, refusal: prepared.reason };
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { documentIndex } from "../../kernel/ports/llm-documents.js";
|
|
3
3
|
import { fingerprint } from "../../kernel/runner/work.js";
|
|
4
|
+
import { usesPronunciationGlossary } from "../admission/rules.js";
|
|
4
5
|
import { render } from "../admission/substitute.js";
|
|
5
6
|
import { articleMessages, continuationMessages } from "../article/continuation.js";
|
|
6
7
|
import { plainText } from "../article/plain.js";
|
|
7
8
|
import { segmentMessages } from "../article/segments.js";
|
|
8
9
|
import { splitEndMatter } from "../article/split.js";
|
|
10
|
+
import { parsePronunciationGlossary } from "../narration/pronunciation.js";
|
|
9
11
|
import { researchDocuments } from "../research/documents.js";
|
|
10
12
|
import { plannerMessages, subAgentMessages } from "../research/planner.js";
|
|
11
13
|
import { synthesisMessages } from "../research/synthesis.js";
|
|
@@ -55,7 +57,7 @@ export function textRecipes(context) {
|
|
|
55
57
|
if (config.sources.research === "generate") {
|
|
56
58
|
const planner = recipe(context, "research:planner", "research", llmInput(context, plannerMessages(brief)));
|
|
57
59
|
recipes.push(planner);
|
|
58
|
-
const chapters = resolved.research?.outline.map((title, index) => recipe(context, `research:chapter:${index + 1}`, "research", llmInput(context, subAgentMessages(brief, title, resolved.research?.outline ?? []), true), [planner.key]))
|
|
60
|
+
const chapters = (resolved.research?.outline ?? []).map((title, index) => recipe(context, `research:chapter:${index + 1}`, "research", llmInput(context, subAgentMessages(brief, title, resolved.research?.outline ?? []), true), [planner.key]));
|
|
59
61
|
recipes.push(...chapters);
|
|
60
62
|
const findings = resolved.research?.findings;
|
|
61
63
|
research =
|
|
@@ -116,7 +118,13 @@ export function textRecipes(context) {
|
|
|
116
118
|
config.sources.article === "generate" &&
|
|
117
119
|
!content.articleEdited)
|
|
118
120
|
recipes.push(recipe(context, "article:continuation", "article", llmInput(context, continuationMessages(messages, resolved.articleContinuation), false, documents), [article.key]));
|
|
119
|
-
const
|
|
121
|
+
const endMatter = articleMarkdown === null ? null : splitEndMatter(articleMarkdown);
|
|
122
|
+
const articleText = endMatter === null ? null : plainText(endMatter.body);
|
|
123
|
+
const glossary = !usesPronunciationGlossary(config)
|
|
124
|
+
? { ok: true, entries: [] }
|
|
125
|
+
: endMatter === null
|
|
126
|
+
? null
|
|
127
|
+
: parsePronunciationGlossary(endMatter.glossary);
|
|
120
128
|
const entries = {};
|
|
121
129
|
for (const category of ["intro", "outro"]) {
|
|
122
130
|
const choice = config[category];
|
|
@@ -162,7 +170,7 @@ export function textRecipes(context) {
|
|
|
162
170
|
}
|
|
163
171
|
: llmInput(context, messages), [article.key]));
|
|
164
172
|
}
|
|
165
|
-
return { recipes, articleText, article, entries };
|
|
173
|
+
return { recipes, articleText, glossary, article, entries };
|
|
166
174
|
}
|
|
167
175
|
function llmInputFingerprint(context, messages) {
|
|
168
176
|
return fingerprint(JSON.parse(JSON.stringify(llmInput(context, messages))));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { projectPaused } from "../admission/repo.js";
|
|
2
|
+
export function activeConflict(deps, projectId, keys) {
|
|
3
|
+
const rows = deps.db
|
|
4
|
+
.prepare("SELECT work_key,logical_key,live FROM (SELECT p.work_key,r.logical_key," +
|
|
5
|
+
"(w.state='running' OR (? AND w.state='pending' AND w.dispatch_state='allowed' AND NOT EXISTS(" +
|
|
6
|
+
"SELECT 1 FROM revision_work f WHERE f.admission_id=w.admission_id " +
|
|
7
|
+
"AND f.state IN ('failed','canceled')))) AS live," +
|
|
8
|
+
"(w.state='pending' AND p.state!='done' AND p.continuation IS NOT NULL AND " +
|
|
9
|
+
"r.revision_id=(SELECT revision_id FROM project_heads WHERE project_id=w.project_id)) AS accepted " +
|
|
10
|
+
"FROM revision_work w JOIN revision_work_pieces p ON p.work_id=w.id " +
|
|
11
|
+
"LEFT JOIN revision_work_reservations r ON r.work_id=w.id AND r.piece_id=p.id " +
|
|
12
|
+
"WHERE w.project_id=?) WHERE live OR accepted")
|
|
13
|
+
.all(projectPaused(deps.db, projectId) ? 0 : 1, projectId)
|
|
14
|
+
.filter((row) => keys.some((key) => row.work_key === key ||
|
|
15
|
+
row.logical_key === key ||
|
|
16
|
+
(key.endsWith(":future") && String(row.work_key).startsWith(key.slice(0, -6)))));
|
|
17
|
+
if (rows.length === 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
return rows.some((row) => Number(row.live) === 1) ? "running" : "accepted-job";
|
|
20
|
+
}
|
|
21
|
+
export const acceptedJobMessage = "An accepted provider job for this section is waiting to be collected. Use Retry stage or Resume, then rerun.";
|
|
22
|
+
export function conflictRefusal(conflict) {
|
|
23
|
+
return conflict === "running"
|
|
24
|
+
? { ok: false, reason: "running" }
|
|
25
|
+
: {
|
|
26
|
+
ok: false,
|
|
27
|
+
reason: "accepted-job",
|
|
28
|
+
fields: [{ field: "stage", message: acceptedJobMessage }],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { stageKinds } from "../../kernel/pipeline.js";
|
|
3
|
+
import { revisionControlSchema } from "../control/revision-control-schema.js";
|
|
4
|
+
import { rebuildAdmissionSchema } from "./model.js";
|
|
5
|
+
export const recoveryActionSchema = z.discriminatedUnion("kind", [
|
|
6
|
+
z.object({ kind: z.literal("resume") }).strict(),
|
|
7
|
+
z.object({ kind: z.literal("retry"), stage: z.enum(stageKinds) }).strict(),
|
|
8
|
+
z.object({ kind: z.literal("rerun"), stage: z.enum(stageKinds) }).strict(),
|
|
9
|
+
]);
|
|
10
|
+
export const recoveryRequestSchema = revisionControlSchema
|
|
11
|
+
.extend({
|
|
12
|
+
action: recoveryActionSchema,
|
|
13
|
+
})
|
|
14
|
+
.strict();
|
|
15
|
+
export const recoveryResultSchema = z.discriminatedUnion("ok", [
|
|
16
|
+
z.object({
|
|
17
|
+
ok: z.literal(true),
|
|
18
|
+
value: rebuildAdmissionSchema.and(z.object({ warnings: z.array(z.string()).readonly() })),
|
|
19
|
+
}),
|
|
20
|
+
z.object({
|
|
21
|
+
ok: z.literal(false),
|
|
22
|
+
reason: z.enum([
|
|
23
|
+
"no-project",
|
|
24
|
+
"no-revision",
|
|
25
|
+
"conflict",
|
|
26
|
+
"idempotency-conflict",
|
|
27
|
+
"invalid-edit",
|
|
28
|
+
"stale-preview",
|
|
29
|
+
"invalid-selection",
|
|
30
|
+
"review-required",
|
|
31
|
+
"cost-ack-required",
|
|
32
|
+
"readiness",
|
|
33
|
+
"running",
|
|
34
|
+
"accepted-job",
|
|
35
|
+
"control-changed",
|
|
36
|
+
]),
|
|
37
|
+
fields: z
|
|
38
|
+
.array(z.object({ field: z.string(), message: z.string() }))
|
|
39
|
+
.readonly()
|
|
40
|
+
.optional(),
|
|
41
|
+
intentRevisionId: z.string().optional(),
|
|
42
|
+
currentRevisionId: z.string().nullable().optional(),
|
|
43
|
+
}),
|
|
44
|
+
]);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { requestHash } from "../revisions/mutation-request.js";
|
|
3
|
+
import { revisionEditSchema } from "../revisions/schema.js";
|
|
4
|
+
import { recoveryResultSchema, } from "./recovery-model.js";
|
|
5
|
+
const rowSchema = z.object({
|
|
6
|
+
request_hash: z.string(),
|
|
7
|
+
base_revision_id: z.string(),
|
|
8
|
+
authority_stamp: z.string(),
|
|
9
|
+
edit_json: z.string().nullable(),
|
|
10
|
+
intent_revision_id: z.string().nullable(),
|
|
11
|
+
response_json: z.string().nullable(),
|
|
12
|
+
});
|
|
13
|
+
export function recoveryKey(input, phase) {
|
|
14
|
+
return `recovery:${input.idempotencyKey}:${phase}`;
|
|
15
|
+
}
|
|
16
|
+
export function recoveryAuthority(deps, projectId) {
|
|
17
|
+
const controls = deps.db
|
|
18
|
+
.prepare("SELECT idempotency_key FROM project_control_receipts WHERE project_id=? ORDER BY rowid")
|
|
19
|
+
.all(projectId);
|
|
20
|
+
const admissions = deps.db
|
|
21
|
+
.prepare("SELECT id FROM rebuild_admissions WHERE project_id=? ORDER BY rowid")
|
|
22
|
+
.all(projectId);
|
|
23
|
+
return requestHash("recovery-authority", projectId, { controls, admissions });
|
|
24
|
+
}
|
|
25
|
+
export function readRecovery(deps, projectId, input) {
|
|
26
|
+
const raw = deps.db
|
|
27
|
+
.prepare("SELECT * FROM project_recovery_requests WHERE project_id=? AND idempotency_key=?")
|
|
28
|
+
.get(projectId, input.idempotencyKey);
|
|
29
|
+
if (raw === undefined) {
|
|
30
|
+
const used = deps.db
|
|
31
|
+
.prepare("SELECT 1 FROM revision_mutations WHERE project_id=? AND idempotency_key=? " +
|
|
32
|
+
"UNION ALL SELECT 1 FROM rebuild_admissions WHERE project_id=? AND idempotency_key=? " +
|
|
33
|
+
"UNION ALL SELECT 1 FROM project_control_receipts WHERE project_id=? AND idempotency_key=?")
|
|
34
|
+
.get(projectId, input.idempotencyKey, projectId, input.idempotencyKey, projectId, input.idempotencyKey);
|
|
35
|
+
return used === undefined ? undefined : { ok: false, reason: "idempotency-conflict" };
|
|
36
|
+
}
|
|
37
|
+
const row = rowSchema.parse(raw);
|
|
38
|
+
if (row.request_hash !== requestHash("direct-recovery", input.baseRevisionId, input.action))
|
|
39
|
+
return { ok: false, reason: "idempotency-conflict" };
|
|
40
|
+
if (row.response_json !== null)
|
|
41
|
+
return recoveryResultSchema.parse(JSON.parse(row.response_json));
|
|
42
|
+
return {
|
|
43
|
+
pending: true,
|
|
44
|
+
authority: row.authority_stamp,
|
|
45
|
+
intentRevisionId: row.intent_revision_id,
|
|
46
|
+
edit: row.edit_json === null ? null : revisionEditSchema.parse(JSON.parse(row.edit_json)),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function reserveRecovery(deps, projectId, input, edit) {
|
|
50
|
+
const authority = recoveryAuthority(deps, projectId);
|
|
51
|
+
deps.db
|
|
52
|
+
.prepare("INSERT INTO project_recovery_requests " +
|
|
53
|
+
"(project_id,idempotency_key,request_hash,base_revision_id,authority_stamp,edit_json) " +
|
|
54
|
+
"VALUES (?,?,?,?,?,?)")
|
|
55
|
+
.run(projectId, input.idempotencyKey, requestHash("direct-recovery", input.baseRevisionId, input.action), input.baseRevisionId, authority, edit === null ? null : JSON.stringify(edit));
|
|
56
|
+
return { pending: true, authority, edit, intentRevisionId: null };
|
|
57
|
+
}
|
|
58
|
+
export function rememberRecovery(deps, projectId, input, result) {
|
|
59
|
+
const response = recoveryResultSchema.parse(result);
|
|
60
|
+
deps.db
|
|
61
|
+
.prepare("UPDATE project_recovery_requests SET response_json=? WHERE project_id=? AND idempotency_key=?")
|
|
62
|
+
.run(JSON.stringify(response), projectId, input.idempotencyKey);
|
|
63
|
+
return response;
|
|
64
|
+
}
|
|
65
|
+
// The head has unfinished stages but nothing that will move them on its own: no call in
|
|
66
|
+
// flight and no admitted, dispatchable work. A saved rerun or edit is admitted by no one
|
|
67
|
+
// until Resume, and work held by a pause, a restart or a failed sibling of its admission
|
|
68
|
+
// never starts, yet none of these shows as paused or failed. A queued batch item waits for
|
|
69
|
+
// its turn instead.
|
|
70
|
+
export function resumable(db, projectId) {
|
|
71
|
+
return (db
|
|
72
|
+
.prepare("SELECT 1 FROM project_heads h WHERE h.project_id=? " +
|
|
73
|
+
"AND EXISTS(SELECT 1 FROM stages s WHERE s.project_id=h.project_id " +
|
|
74
|
+
"AND s.state NOT IN ('done','provided','skipped')) " +
|
|
75
|
+
"AND NOT EXISTS(SELECT 1 FROM stages s WHERE s.project_id=h.project_id AND s.state='running') " +
|
|
76
|
+
"AND NOT EXISTS(SELECT 1 FROM project_queue q WHERE q.project_id=h.project_id AND q.state='queued') " +
|
|
77
|
+
"AND NOT EXISTS(SELECT 1 FROM revision_work w WHERE w.project_id=h.project_id AND w.state='running') " +
|
|
78
|
+
"AND NOT EXISTS(SELECT 1 FROM revision_work w " +
|
|
79
|
+
"JOIN revision_work_reservations r ON r.work_id=w.id AND r.revision_id=h.revision_id " +
|
|
80
|
+
"WHERE w.state='pending' AND w.dispatch_state='allowed' AND NOT EXISTS(" +
|
|
81
|
+
"SELECT 1 FROM revision_work f WHERE f.admission_id=w.admission_id " +
|
|
82
|
+
"AND f.state IN ('failed','canceled')))")
|
|
83
|
+
.get(projectId) !== undefined);
|
|
84
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { narrationRegenerationKey } from "../narration/plan.js";
|
|
2
|
+
export function sectionRoots(view, plan, stage) {
|
|
3
|
+
if (stage === "article" &&
|
|
4
|
+
(view.revision.config.sources.article !== "generate" || view.revision.content.articleEdited))
|
|
5
|
+
return [];
|
|
6
|
+
return plan.recipes
|
|
7
|
+
.filter((row) => {
|
|
8
|
+
if (row.stage !== stage || row.kind === "provided")
|
|
9
|
+
return false;
|
|
10
|
+
if (stage === "video")
|
|
11
|
+
return (row.input.kind === "local" &&
|
|
12
|
+
(row.key.startsWith("export:") || row.key === "subtitles:files"));
|
|
13
|
+
if (stage === "audio")
|
|
14
|
+
return (row.input.kind === "tts" ||
|
|
15
|
+
(row.input.kind === "deferred" &&
|
|
16
|
+
["body-narration", "intro-narration", "outro-narration"].includes(row.input.operation)));
|
|
17
|
+
if (stage === "article")
|
|
18
|
+
return row.key === "article:body" && row.kind === "provider";
|
|
19
|
+
return row.kind === "provider";
|
|
20
|
+
})
|
|
21
|
+
.map((row) => row.key);
|
|
22
|
+
}
|
|
23
|
+
export function dependentClosure(recipes, roots) {
|
|
24
|
+
const selected = new Set(roots);
|
|
25
|
+
let changed = true;
|
|
26
|
+
while (changed) {
|
|
27
|
+
changed = false;
|
|
28
|
+
for (const row of recipes)
|
|
29
|
+
if (!selected.has(row.key) && row.dependsOn.some((key) => selected.has(key))) {
|
|
30
|
+
selected.add(row.key);
|
|
31
|
+
changed = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return recipes.filter((row) => selected.has(row.key)).map((row) => row.key);
|
|
35
|
+
}
|
|
36
|
+
export function recoverySelection(plan, stage) {
|
|
37
|
+
if (stage === undefined)
|
|
38
|
+
return { kind: "allAffected" };
|
|
39
|
+
const roots = plan.work
|
|
40
|
+
.filter((row) => row.stage === stage && row.disposition !== "reuse")
|
|
41
|
+
.map((row) => row.key);
|
|
42
|
+
const unfinished = roots.length === 0
|
|
43
|
+
? plan.work.filter((row) => row.stage === stage).map((row) => row.key)
|
|
44
|
+
: roots;
|
|
45
|
+
return { kind: "selected", workKeys: unfinished };
|
|
46
|
+
}
|
|
47
|
+
export function regenerationEdit(view, plan, stage) {
|
|
48
|
+
const roots = sectionRoots(view, plan, stage);
|
|
49
|
+
if (roots.length === 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
const regenerate = stage === "research"
|
|
52
|
+
? ["research:all"]
|
|
53
|
+
: [
|
|
54
|
+
...new Set(roots.map((key) => {
|
|
55
|
+
const row = plan.recipes.find((recipe) => recipe.key === key);
|
|
56
|
+
return row?.input.kind === "tts"
|
|
57
|
+
? row.input.logicalKey
|
|
58
|
+
: narrationRegenerationKey(key);
|
|
59
|
+
})),
|
|
60
|
+
];
|
|
61
|
+
for (const root of roots.filter((key) => key.startsWith("audio:") && key.endsWith(":future"))) {
|
|
62
|
+
const segment = root.slice(0, -7);
|
|
63
|
+
for (const key of Object.keys(view.revision.content.regenerationTokens))
|
|
64
|
+
if ((key === segment || key.startsWith(`${segment}:`)) && !regenerate.includes(key))
|
|
65
|
+
regenerate.push(key);
|
|
66
|
+
}
|
|
67
|
+
return { config: view.revision.config, content: view.revision.content, regenerate };
|
|
68
|
+
}
|
|
@@ -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
|
+
}
|