@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.
- 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/preview-plan.js +1 -1
- package/dist/slices/rebuild/preview-retained.js +16 -3
- 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/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
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import { fingerprint } from "../../kernel/runner/work.js";
|
|
2
|
-
import { usesNarrationPreparation } from "../admission/rules.js";
|
|
3
|
-
import {
|
|
2
|
+
import { usesNarrationPreparation, usesPronunciationGlossary } from "../admission/rules.js";
|
|
3
|
+
import { defaultChunking } from "../narration/chunk.js";
|
|
4
4
|
import { concatArgs } from "../narration/concat.js";
|
|
5
5
|
import { normalizeNarrationText } from "../narration/plan.js";
|
|
6
|
-
import {
|
|
6
|
+
import { pronunciationChunks } from "../narration/pronunciation-chunks.js";
|
|
7
|
+
import { narrationParts, pronunciationFutureValues, voiceValues } from "./recipe-audio-parts.js";
|
|
7
8
|
import { recipe, resourceIdentity, selectedReference, } from "./recipe-model.js";
|
|
8
9
|
import { narrationFileRecipe } from "./recipe-narration-text.js";
|
|
9
10
|
import { preparationFuture, preparationTemplate } from "./recipe-preparation.js";
|
|
11
|
+
export function bodyNarrationGroups(context, text) {
|
|
12
|
+
return text.articleText === null
|
|
13
|
+
? []
|
|
14
|
+
: pronunciationChunks(normalizeNarrationText(text.articleText), context.config.chunking ?? defaultChunking, usesPronunciationGlossary(context.config) && text.glossary?.ok ? text.glossary.entries : [], new Set(Object.keys(context.content.narrationOverrides)), context.content.narrationSources);
|
|
15
|
+
}
|
|
10
16
|
export function audioRecipes(context, text) {
|
|
11
17
|
const { config, content } = context;
|
|
12
18
|
const recipes = [];
|
|
13
19
|
if (config.sources.audio === "off")
|
|
14
20
|
return { recipes, mediaFingerprint: null, timeline: [], keys: [] };
|
|
21
|
+
const prepare = usesNarrationPreparation(config);
|
|
22
|
+
const pronounce = usesPronunciationGlossary(config);
|
|
23
|
+
const narrationFiles = prepare || pronounce;
|
|
15
24
|
let body;
|
|
25
|
+
let bodyTranscript = text.articleText ?? text.article.fingerprint;
|
|
16
26
|
if (config.sources.audio === "provide") {
|
|
17
27
|
body = recipe(context, "audio:provided", "audio", {
|
|
18
28
|
kind: "provided",
|
|
@@ -23,26 +33,30 @@ export function audioRecipes(context, text) {
|
|
|
23
33
|
recipes.push(body);
|
|
24
34
|
}
|
|
25
35
|
else {
|
|
26
|
-
const groups = text
|
|
27
|
-
? []
|
|
28
|
-
: chunkNarration(normalizeNarrationText(text.articleText), config.chunking ?? defaultChunking);
|
|
29
|
-
const occurrences = new Map();
|
|
36
|
+
const groups = bodyNarrationGroups(context, text);
|
|
30
37
|
const parts = [];
|
|
38
|
+
const transcripts = [];
|
|
39
|
+
const futurePronunciation = [];
|
|
31
40
|
let pending = text.articleText === null;
|
|
32
|
-
for (const logicalText of groups) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
for (const { key: logicalKey, text: logicalText } of groups) {
|
|
42
|
+
transcripts.push({
|
|
43
|
+
original: logicalText,
|
|
44
|
+
effective: effectiveText(context, logicalKey, logicalText),
|
|
45
|
+
});
|
|
46
|
+
futurePronunciation.push(...pronunciationFutureValues(context, text.glossary, text.article, logicalKey, logicalText));
|
|
47
|
+
const group = narrationParts(context, logicalKey, logicalText, "body", [text.article.key], recipes, text.glossary);
|
|
38
48
|
if (group.length === 0)
|
|
39
49
|
pending = true;
|
|
40
50
|
parts.push(...group);
|
|
41
51
|
}
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
if (transcripts.some((row) => row.effective !== row.original))
|
|
53
|
+
bodyTranscript = ["narration-transcript-v1", transcripts.map((row) => row.effective)];
|
|
54
|
+
if (text.articleText === null) {
|
|
55
|
+
futurePronunciation.push(...pronunciationFutureValues(context, text.glossary, text.article, "audio:body:future", null));
|
|
56
|
+
if (prepare)
|
|
57
|
+
recipes.push(preparationFuture(context, "body", text.article));
|
|
58
|
+
}
|
|
59
|
+
if (pending && prepare)
|
|
46
60
|
parts.length = 0;
|
|
47
61
|
if (pending)
|
|
48
62
|
parts.push(recipe(context, "audio:body:future", "audio", {
|
|
@@ -53,11 +67,12 @@ export function audioRecipes(context, text) {
|
|
|
53
67
|
text.article.fingerprint,
|
|
54
68
|
voiceValues(context),
|
|
55
69
|
chunkingValues(context),
|
|
56
|
-
...(
|
|
70
|
+
...(prepare ? [preparationTemplate(context)] : []),
|
|
71
|
+
...futurePronunciation,
|
|
57
72
|
],
|
|
58
73
|
}, [text.article.key, ...preparationKeys(recipes, "body")]));
|
|
59
74
|
recipes.push(...parts);
|
|
60
|
-
if (
|
|
75
|
+
if (narrationFiles)
|
|
61
76
|
recipes.push(narrationFileRecipe(context, "body", parts));
|
|
62
77
|
body = recipe(context, "audio:body:concat", "audio", {
|
|
63
78
|
kind: "local",
|
|
@@ -73,29 +88,25 @@ export function audioRecipes(context, text) {
|
|
|
73
88
|
const ordered = [];
|
|
74
89
|
for (const category of ["intro", "body", "outro"]) {
|
|
75
90
|
if (category === "body") {
|
|
76
|
-
ordered.push({ value: body, transcript:
|
|
91
|
+
ordered.push({ value: body, transcript: bodyTranscript });
|
|
77
92
|
continue;
|
|
78
93
|
}
|
|
79
94
|
const entry = text.entries[category];
|
|
80
95
|
if (entry === undefined)
|
|
81
96
|
continue;
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
const logicalKey = `audio:${category}`;
|
|
98
|
+
const supplied = content.narrationOverrides[logicalKey]?.kind === "asset";
|
|
99
|
+
const dependencies = [entry.recipe.key, ...(pronounce && !supplied ? [text.article.key] : [])];
|
|
100
|
+
const invalidGlossary = !supplied && text.glossary !== null && !text.glossary.ok ? text.glossary.reason : undefined;
|
|
101
|
+
if (prepare &&
|
|
102
|
+
!supplied &&
|
|
103
|
+
invalidGlossary === undefined &&
|
|
104
|
+
(entry.text === null || text.glossary === null))
|
|
105
|
+
recipes.push(preparationFuture(context, category, entry.recipe, pronounce ? [text.article] : []));
|
|
84
106
|
const parts = entry.text === null
|
|
85
|
-
? [
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
version: 1,
|
|
89
|
-
operation: `${category}-narration`,
|
|
90
|
-
template: [
|
|
91
|
-
entry.recipe.fingerprint,
|
|
92
|
-
voiceValues(context),
|
|
93
|
-
...(usesNarrationPreparation(config) ? [preparationTemplate(context)] : []),
|
|
94
|
-
],
|
|
95
|
-
}, [entry.recipe.key, ...preparationKeys(recipes, category)]),
|
|
96
|
-
]
|
|
97
|
-
: narrationParts(context, `audio:${category}`, entry.text, category, [entry.recipe.key], recipes);
|
|
98
|
-
if (parts.length === 0 && usesNarrationPreparation(config))
|
|
107
|
+
? []
|
|
108
|
+
: narrationParts(context, logicalKey, entry.text, category, dependencies, recipes, text.glossary);
|
|
109
|
+
if (parts.length === 0)
|
|
99
110
|
parts.push(recipe(context, `audio:${category}:future`, "audio", {
|
|
100
111
|
kind: "deferred",
|
|
101
112
|
version: 1,
|
|
@@ -103,9 +114,10 @@ export function audioRecipes(context, text) {
|
|
|
103
114
|
template: [
|
|
104
115
|
entry.recipe.fingerprint,
|
|
105
116
|
voiceValues(context),
|
|
106
|
-
preparationTemplate(context),
|
|
117
|
+
...(prepare ? [preparationTemplate(context)] : []),
|
|
118
|
+
...pronunciationFutureValues(context, text.glossary, text.article, logicalKey, entry.text),
|
|
107
119
|
],
|
|
108
|
-
}, [
|
|
120
|
+
}, [...dependencies, ...preparationKeys(recipes, category)], invalidGlossary === undefined ? {} : { unresolved: true, refusal: invalidGlossary }));
|
|
109
121
|
recipes.push(...parts);
|
|
110
122
|
const audio = recipe(context, `audio:${category}`, "audio", {
|
|
111
123
|
kind: "local",
|
|
@@ -117,9 +129,14 @@ export function audioRecipes(context, text) {
|
|
|
117
129
|
],
|
|
118
130
|
}, parts.map((part) => part.key));
|
|
119
131
|
recipes.push(audio);
|
|
120
|
-
if (
|
|
132
|
+
if (narrationFiles)
|
|
121
133
|
recipes.push(narrationFileRecipe(context, category, parts));
|
|
122
|
-
ordered.push({
|
|
134
|
+
ordered.push({
|
|
135
|
+
value: audio,
|
|
136
|
+
transcript: entry.text === null
|
|
137
|
+
? entry.recipe.fingerprint
|
|
138
|
+
: effectiveText(context, logicalKey, entry.text),
|
|
139
|
+
});
|
|
123
140
|
}
|
|
124
141
|
const timeline = ordered.map(({ value, transcript }) => {
|
|
125
142
|
const retained = context.manifest.outputs.find((row) => row.workKey === value.key && row.state === "ready" && selectedReference(row));
|
|
@@ -140,6 +157,10 @@ export function audioRecipes(context, text) {
|
|
|
140
157
|
keys: ordered.map(({ value }) => value.key),
|
|
141
158
|
};
|
|
142
159
|
}
|
|
160
|
+
function effectiveText(context, key, original) {
|
|
161
|
+
const override = context.content.narrationOverrides[key];
|
|
162
|
+
return override?.kind === "text" ? normalizeNarrationText(override.text) : original;
|
|
163
|
+
}
|
|
143
164
|
function chunkingValues(context) {
|
|
144
165
|
const chunking = context.config.chunking ?? defaultChunking;
|
|
145
166
|
return [
|
|
@@ -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
|
+
}
|