@gentbajko/slopify 3.17.0 → 3.18.2
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/dist/edge/http/actions.js +2 -2
- package/dist/edge/http/actor.js +10 -0
- package/dist/edge/http/app.js +21 -0
- package/dist/edge/http/checkpoints.js +14 -6
- package/dist/edge/http/external-reviews.js +95 -0
- package/dist/edge/http/projects.js +36 -10
- package/dist/edge/http/reviews.js +53 -18
- package/dist/edge/http/revisions.js +5 -1
- package/dist/edge/http/settings.js +9 -1
- package/dist/edge/http/starts.js +41 -0
- package/dist/edge/mcp/attribution.js +38 -0
- package/dist/edge/mcp/call-route.js +203 -0
- package/dist/edge/mcp/excluded-routes.js +95 -0
- package/dist/edge/mcp/file-tools.js +141 -0
- package/dist/edge/mcp/http.js +79 -0
- package/dist/edge/mcp/instructions.js +17 -0
- package/dist/edge/mcp/library-tools.js +143 -0
- package/dist/edge/mcp/remake-tools.js +90 -0
- package/dist/edge/mcp/review-tools.js +64 -0
- package/dist/edge/mcp/route-tool.js +61 -0
- package/dist/edge/mcp/server.js +48 -0
- package/dist/edge/mcp/state-shapes.js +60 -0
- package/dist/edge/mcp/tools-channels.js +153 -0
- package/dist/edge/mcp/tools-library.js +151 -0
- package/dist/edge/mcp/tools-planning.js +137 -0
- package/dist/edge/mcp/tools-projects.js +167 -0
- package/dist/edge/mcp/tools-publishing.js +148 -0
- package/dist/edge/mcp/tools-runs.js +94 -0
- package/dist/edge/mcp/tools-schedules.js +143 -0
- package/dist/edge/mcp/tools-setup.js +234 -0
- package/dist/edge/mcp/tools-storage.js +99 -0
- package/dist/edge/mcp/tools.js +21 -0
- package/dist/extension/slopify-studio-chrome.zip +0 -0
- package/dist/extension/slopify-studio-firefox.zip +0 -0
- package/dist/kernel/db/migrations/0055-external-reviews.sql +56 -0
- package/dist/kernel/db/migrations/0056-agent-actions.sql +20 -0
- package/dist/kernel/db/migrations/0057-agent-action-once.sql +5 -0
- package/dist/kernel/db/migrations/0058-revision-work-admission.sql +3 -0
- package/dist/kernel/db/migrations/0059-external-review-canceled.sql +29 -0
- package/dist/kernel/db/migrations/0060-reservation-work.sql +4 -0
- package/dist/kernel/db/migrations/0061-project-control-counts.sql +4 -0
- package/dist/kernel/db/migrations/0062-told-failures.sql +8 -0
- package/dist/kernel/pipeline.js +6 -0
- package/dist/kernel/runner/graph.js +37 -2
- package/dist/kernel/runner/index.js +6 -3
- package/dist/main.js +29 -13
- package/dist/patch-notes/3.18.0.md +13 -0
- package/dist/patch-notes/3.18.1.md +11 -0
- package/dist/patch-notes/3.18.2.md +10 -0
- package/dist/patch-notes/index.json +18 -0
- package/dist/slices/admission/admitted-work.js +53 -0
- package/dist/slices/admission/heads.js +5 -0
- package/dist/slices/admission/repo.js +47 -29
- package/dist/slices/admission/schema.js +1 -0
- package/dist/slices/admission/stage-writes.js +21 -0
- package/dist/slices/admission/start.js +2 -1
- package/dist/slices/admission/state-words.js +110 -0
- package/dist/slices/agents/attention-work.js +233 -0
- package/dist/slices/agents/attention.js +168 -0
- package/dist/slices/agents/connection.js +15 -0
- package/dist/slices/agents/model.js +29 -0
- package/dist/slices/agents/repo.js +66 -0
- package/dist/slices/cancel/index.js +4 -1
- package/dist/slices/control/index.js +2 -1
- package/dist/slices/episodes/summarize.js +2 -1
- package/dist/slices/estimate/index.js +6 -2
- package/dist/slices/images/run.js +2 -1
- package/dist/slices/library/transfer.js +69 -0
- package/dist/slices/narration/run.js +2 -1
- package/dist/slices/notifications/address.js +40 -0
- package/dist/slices/notifications/notifier.js +43 -10
- package/dist/slices/notifications/rules.js +111 -45
- package/dist/slices/notifications/server.js +14 -0
- package/dist/slices/notifications/settings.js +1 -1
- package/dist/slices/notifications/subject.js +30 -0
- package/dist/slices/notifications/told.js +33 -0
- package/dist/slices/play-drafts/readiness.js +9 -2
- package/dist/slices/play-drafts/schema.js +2 -1
- package/dist/slices/rebuild/narration-words.js +76 -0
- package/dist/slices/rebuild/recipe-model.js +1 -0
- package/dist/slices/rebuild/recipe-reviews.js +44 -23
- package/dist/slices/rebuild/recovery-repo.js +0 -20
- package/dist/slices/rebuild/recovery-selection.js +14 -3
- package/dist/slices/rebuild/recovery.js +5 -2
- package/dist/slices/rebuild/remake-outdated.js +27 -0
- package/dist/slices/rebuild/repo.js +7 -2
- package/dist/slices/rebuild/review-redo.js +12 -0
- package/dist/slices/rebuild/runtime-agent-review.js +121 -0
- package/dist/slices/rebuild/runtime-export-inputs.js +26 -11
- package/dist/slices/rebuild/runtime-review.js +12 -2
- package/dist/slices/rebuild/runtime-run.js +11 -2
- package/dist/slices/rebuild/runtime-store.js +32 -5
- package/dist/slices/reruns/index.js +2 -1
- package/dist/slices/research/run.js +2 -1
- package/dist/slices/reviews/external-model.js +21 -0
- package/dist/slices/reviews/external-release.js +38 -0
- package/dist/slices/reviews/external-repo.js +109 -0
- package/dist/slices/reviews/external-rows.js +68 -0
- package/dist/slices/reviews/external-waiting.js +75 -0
- package/dist/slices/reviews/holds.js +144 -0
- package/dist/slices/reviews/model.js +14 -0
- package/dist/slices/reviews/repo.js +27 -10
- package/dist/slices/reviews/rules.js +5 -3
- package/dist/slices/reviews/verdict.js +18 -8
- package/dist/slices/revisions/repo.js +3 -0
- package/dist/slices/revisions/schema.js +1 -0
- package/dist/slices/schedules/agenda.js +47 -17
- package/dist/slices/schedules/prepare.js +6 -0
- package/dist/slices/schedules/schema.js +13 -1
- package/dist/slices/storage/arrange.js +21 -8
- package/dist/slices/storage/consequences.js +27 -0
- package/dist/slices/storage/portable.js +3 -0
- package/dist/slices/storage/project-files.js +66 -0
- package/dist/slices/storage/trim.js +6 -2
- package/dist/slices/subtitles/prepare.js +2 -1
- package/dist/slices/uploads/repo.js +12 -6
- package/dist/slices/uploads/rules.js +5 -0
- package/dist/slices/video/write-export.js +1 -1
- package/dist/slices/video/write-subtitles.js +1 -1
- package/dist/tutorials/Agents-MCP.md +168 -0
- package/dist/tutorials/Calendar.md +2 -1
- package/dist/tutorials/First-Launch-and-Welcome.md +1 -0
- package/dist/tutorials/Home-and-Projects.md +16 -9
- package/dist/tutorials/Home.md +1 -0
- package/dist/tutorials/Notifications.md +25 -6
- package/dist/tutorials/Project-Page.md +9 -3
- package/dist/tutorials/Publishing-to-YouTube.md +1 -0
- package/dist/tutorials/Recovery-and-Retries.md +17 -3
- package/dist/tutorials/Reviews-and-Checkpoints.md +22 -5
- package/dist/tutorials/Schedules.md +1 -1
- package/dist/tutorials/Settings-Reference.md +16 -1
- package/dist/tutorials/Troubleshooting.md +3 -1
- package/dist/tutorials/Where-Your-Files-Live.md +3 -3
- package/dist/tutorials/Your-First-Short.md +1 -0
- package/dist/tutorials/_Sidebar.md +1 -0
- package/dist/web/assets/{index-DpdXRssU.css → index-BKbZIxAq.css} +1 -1
- package/dist/web/assets/index-DASWFus0.js +326 -0
- package/dist/web/assets/{pdf-B42WW32R.js → pdf-CF97Fw5v.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CbmBh8V-.js +0 -325
|
@@ -9,7 +9,7 @@ import { chunkNarration, defaultChunking } from "../narration/chunk.js";
|
|
|
9
9
|
import { describeMessages } from "../narration/describe.js";
|
|
10
10
|
import { normalizeNarrationText } from "../narration/plan.js";
|
|
11
11
|
import { preparationMessages } from "../narration/preparation.js";
|
|
12
|
-
import { defaultReviewPrompts, reviewPromptKey, reviewRetriesOf } from "../reviews/model.js";
|
|
12
|
+
import { defaultReviewPrompts, reviewedByAgent, reviewPromptKey, reviewRetriesOf, } from "../reviews/model.js";
|
|
13
13
|
import { activeReviewStages } from "../reviews/rules.js";
|
|
14
14
|
import { isLocalCliProvider } from "../settings/model.js";
|
|
15
15
|
import { defaultShortsImagePrompt, defaultShortsPrompt, shortsImageUpperBound, shortsSettingsProblems, } from "../shorts/model.js";
|
|
@@ -260,7 +260,11 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
|
|
|
260
260
|
// Automatic reviews: one call per reviewed item, on the reviewer's model. A failed item
|
|
261
261
|
// made again is reviewed again, at most the retries allowed, which is not priced here.
|
|
262
262
|
const reviews = draft.reviews;
|
|
263
|
-
if (reviews !== undefined)
|
|
263
|
+
if (reviews !== undefined && reviewedByAgent(reviews)) {
|
|
264
|
+
if (activeReviewStages(draft).length > 0)
|
|
265
|
+
local("Reviews", "An external agent answers each review through MCP; no API fee.");
|
|
266
|
+
}
|
|
267
|
+
else if (reviews !== undefined)
|
|
264
268
|
for (const stage of activeReviewStages(draft)) {
|
|
265
269
|
const picked = rendered[reviewPromptKey(stage)]?.trim() ?? "";
|
|
266
270
|
const prompt = picked === "" ? defaultReviewPrompts[stage].length : picked.length;
|
|
@@ -3,7 +3,8 @@ import { dirname } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { transact } from "../../kernel/db/tx.js";
|
|
5
5
|
import { insertPiece, piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
6
|
-
import { projectById
|
|
6
|
+
import { projectById } from "../admission/repo.js";
|
|
7
|
+
import { setStageProgress } from "../admission/stage-writes.js";
|
|
7
8
|
import { outputFileName, outputPath } from "../storage/layout.js";
|
|
8
9
|
import { insertOutput, outputsOf } from "../storage/repo.js";
|
|
9
10
|
import { imageCountsOf } from "./scale.js";
|
|
@@ -162,3 +162,72 @@ function freeName(wanted, taken, max, suffix, keepIfFree = true) {
|
|
|
162
162
|
return candidate;
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
+
const tries = 3;
|
|
166
|
+
// Saves the items one by one under a name free in their group (a prompt kind, an intro or
|
|
167
|
+
// outro, every PDF theme). A name the server still refuses moves on to the next free one; any
|
|
168
|
+
// other refusal skips the item with the server's sentence. The web's Import and the agent's
|
|
169
|
+
// import_library both use it.
|
|
170
|
+
export async function importEach(items, options) {
|
|
171
|
+
const taken = new Map();
|
|
172
|
+
const takenIn = (group) => {
|
|
173
|
+
const found = taken.get(group);
|
|
174
|
+
if (found !== undefined)
|
|
175
|
+
return found;
|
|
176
|
+
const made = new Set();
|
|
177
|
+
taken.set(group, made);
|
|
178
|
+
return made;
|
|
179
|
+
};
|
|
180
|
+
for (const one of options.existing)
|
|
181
|
+
takenIn(one.group).add(one.name.trim().toLowerCase());
|
|
182
|
+
let added = 0;
|
|
183
|
+
let renamed = 0;
|
|
184
|
+
const skipped = [];
|
|
185
|
+
for (const item of items) {
|
|
186
|
+
const names = takenIn(options.groupOf(item));
|
|
187
|
+
let reason = "";
|
|
188
|
+
for (let attempt = 0; attempt < tries; attempt += 1) {
|
|
189
|
+
const name = importedName(item.name, names, options.max);
|
|
190
|
+
try {
|
|
191
|
+
const reply = await options.save({ ...item, name });
|
|
192
|
+
names.add(name.toLowerCase());
|
|
193
|
+
if (reply.ok) {
|
|
194
|
+
added += 1;
|
|
195
|
+
if (name !== item.name.trim())
|
|
196
|
+
renamed += 1;
|
|
197
|
+
reason = "";
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
reason = reply.fields.map((field) => field.message).join(" ");
|
|
201
|
+
if (!reply.fields.some((field) => field.field === "name"))
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (reason !== "")
|
|
210
|
+
skipped.push({ item: item.name.trim() || "An item", reason });
|
|
211
|
+
}
|
|
212
|
+
return { added, renamed, skipped };
|
|
213
|
+
}
|
|
214
|
+
// Folds imported or pasted aliases into a list: a written form already in the list gets the
|
|
215
|
+
// new spoken form, anything else is added at the end.
|
|
216
|
+
export function mergeAliases(rows, pasted, make) {
|
|
217
|
+
const next = [...rows];
|
|
218
|
+
let added = 0;
|
|
219
|
+
let updated = 0;
|
|
220
|
+
for (const alias of pasted) {
|
|
221
|
+
const at = next.findIndex((row) => row.written.trim() === alias.written);
|
|
222
|
+
const existing = at < 0 ? undefined : next[at];
|
|
223
|
+
if (existing === undefined) {
|
|
224
|
+
next.push(make(alias));
|
|
225
|
+
added += 1;
|
|
226
|
+
}
|
|
227
|
+
else if (existing.spoken.trim() !== alias.spoken) {
|
|
228
|
+
next[at] = { ...existing, spoken: alias.spoken };
|
|
229
|
+
updated += 1;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { rows: next, added, updated };
|
|
233
|
+
}
|
|
@@ -3,7 +3,8 @@ import { dirname } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { piecesOf, setPiece } from "../../kernel/runner/piece-repo.js";
|
|
5
5
|
import { entryModes } from "../admission/model.js";
|
|
6
|
-
import { projectById,
|
|
6
|
+
import { projectById, stagesOf } from "../admission/repo.js";
|
|
7
|
+
import { setStageProgress } from "../admission/stage-writes.js";
|
|
7
8
|
import { entryCategories } from "../library/model.js";
|
|
8
9
|
import { outputFileName, outputPath } from "../storage/layout.js";
|
|
9
10
|
import { insertOutput, outputsOf } from "../storage/repo.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Where notifications go: the Notification URL, what it is sent, and the project's address in
|
|
2
|
+
// it. Pure: the web bundle imports this file.
|
|
3
|
+
export const testNotice = {
|
|
4
|
+
headline: "Slopify test notification",
|
|
5
|
+
detail: "Notifications work. You'll get one when a run finishes, fails or waits for you, and when a review needs your decision.",
|
|
6
|
+
};
|
|
7
|
+
// The body a Notification URL receives: plain text, which ntfy shows as the message. Nothing
|
|
8
|
+
// but the project's title, what happened and the provider's own short reason goes in it.
|
|
9
|
+
export function webhookBody(text) {
|
|
10
|
+
return `${text.headline}\n${text.detail}\n${text.link === undefined ? "" : `${text.link}\n`}`;
|
|
11
|
+
}
|
|
12
|
+
// Where a project opens in the browser, from the address the server listens on. A wildcard
|
|
13
|
+
// (0.0.0.0 in Docker) or loopback address is reached as localhost from the same machine.
|
|
14
|
+
export function projectLink(host, port, projectId) {
|
|
15
|
+
const wildcard = host === "0.0.0.0" || host === "::" || host === "" || host === "127.0.0.1";
|
|
16
|
+
const authority = wildcard ? "localhost" : host.includes(":") ? `[${host}]` : host;
|
|
17
|
+
return `http://${authority}:${String(port)}/projects/${encodeURIComponent(projectId)}`;
|
|
18
|
+
}
|
|
19
|
+
export const notificationUrlMax = 2048;
|
|
20
|
+
// Empty clears the setting. Otherwise one absolute http(s) URL with no user name or password:
|
|
21
|
+
// fetch refuses those, and a password in a URL would sit in the database in plain sight.
|
|
22
|
+
export function notificationUrlProblem(value) {
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
if (trimmed === "")
|
|
25
|
+
return undefined;
|
|
26
|
+
if (trimmed.length > notificationUrlMax)
|
|
27
|
+
return `Keep the Notification URL to ${String(notificationUrlMax)} characters or fewer.`;
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(trimmed);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return "Enter a full address starting with https:// or http://, for example https://ntfy.sh/your-topic.";
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
36
|
+
return "The Notification URL must start with https:// or http://.";
|
|
37
|
+
if (parsed.username !== "" || parsed.password !== "")
|
|
38
|
+
return "Remove the user name and password from the Notification URL; Slopify can't send them.";
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
import { redact } from "../../kernel/log.js";
|
|
2
|
-
import {
|
|
2
|
+
import { webhookBody } from "./address.js";
|
|
3
|
+
import { autoPassNoticeText, noticeOf, noticeText, reviewNoticeText, runNotices, topicsNoticeText, } from "./rules.js";
|
|
3
4
|
import { sendFailureText } from "./send.js";
|
|
5
|
+
import { toldInMemory } from "./told.js";
|
|
4
6
|
export function createRunNotifier(deps) {
|
|
5
7
|
// ceiling: one entry per project seen since boot, a few dozen bytes each.
|
|
6
8
|
const states = new Map();
|
|
7
|
-
const reasons = new Map();
|
|
8
9
|
const inflight = new Set();
|
|
9
10
|
let closed = false;
|
|
10
|
-
const
|
|
11
|
+
const told = deps.told ?? toldInMemory();
|
|
12
|
+
const notify = (projectId, cause) => {
|
|
11
13
|
const url = deps.url();
|
|
12
14
|
if (url === null)
|
|
13
15
|
return;
|
|
14
16
|
const subject = deps.subject(projectId);
|
|
15
17
|
if (subject === undefined)
|
|
16
18
|
return;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
+
const before = told.read(projectId);
|
|
20
|
+
const now = runNotices(cause, subject, before);
|
|
21
|
+
for (const one of now.notices) {
|
|
22
|
+
const text = noticeText(one.kind, { ...subject, reason: one.reason });
|
|
23
|
+
post(url, { ...text, link: deps.link?.(projectId) }, one.kind, { projectId });
|
|
24
|
+
}
|
|
25
|
+
// Kept once the notices are on their way, and only when it changed.
|
|
26
|
+
if (now.told.size !== before.size || [...now.told].some((id) => !before.has(id)))
|
|
27
|
+
told.keep(projectId, now.told);
|
|
19
28
|
};
|
|
20
29
|
// Once per verdict: a review asked again for the same item replaces its verdict in place.
|
|
21
30
|
// ceiling: one id per flagged verdict since boot.
|
|
@@ -34,11 +43,33 @@ export function createRunNotifier(deps) {
|
|
|
34
43
|
title: subject.title,
|
|
35
44
|
stage: event.stage,
|
|
36
45
|
reason: event.reason,
|
|
46
|
+
...(event.byAgent === true ? { byAgent: true } : {}),
|
|
37
47
|
});
|
|
38
48
|
post(url, { ...text, link: deps.link?.(event.projectId) }, "review", {
|
|
39
49
|
projectId: event.projectId,
|
|
40
50
|
});
|
|
41
51
|
};
|
|
52
|
+
// Once per review. ceiling: one id per auto-passed review since boot.
|
|
53
|
+
const passed = new Set();
|
|
54
|
+
const notifyAutoPass = (event) => {
|
|
55
|
+
if (passed.has(event.reviewId))
|
|
56
|
+
return;
|
|
57
|
+
passed.add(event.reviewId);
|
|
58
|
+
const url = deps.url();
|
|
59
|
+
if (url === null)
|
|
60
|
+
return;
|
|
61
|
+
const subject = deps.subject(event.projectId);
|
|
62
|
+
if (subject === undefined)
|
|
63
|
+
return;
|
|
64
|
+
const text = autoPassNoticeText({
|
|
65
|
+
title: subject.title,
|
|
66
|
+
stage: event.stage,
|
|
67
|
+
item: event.item,
|
|
68
|
+
});
|
|
69
|
+
post(url, { ...text, link: deps.link?.(event.projectId) }, "auto-pass", {
|
|
70
|
+
projectId: event.projectId,
|
|
71
|
+
});
|
|
72
|
+
};
|
|
42
73
|
const post = (url, text, kind, about) => {
|
|
43
74
|
const body = redact(webhookBody(text));
|
|
44
75
|
const sending = deps
|
|
@@ -67,16 +98,18 @@ export function createRunNotifier(deps) {
|
|
|
67
98
|
return;
|
|
68
99
|
try {
|
|
69
100
|
if (event.type === "stage.state") {
|
|
70
|
-
if (event.state === "failed"
|
|
71
|
-
|
|
72
|
-
else if (event.state === "running")
|
|
73
|
-
reasons.delete(event.projectId);
|
|
101
|
+
if (event.state === "failed")
|
|
102
|
+
notify(event.projectId, { stepFailed: event.stage });
|
|
74
103
|
return;
|
|
75
104
|
}
|
|
76
105
|
if (event.type === "review.flagged") {
|
|
77
106
|
notifyReview(event);
|
|
78
107
|
return;
|
|
79
108
|
}
|
|
109
|
+
if (event.type === "review.auto-passed") {
|
|
110
|
+
notifyAutoPass(event);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
80
113
|
if (event.type !== "project.state")
|
|
81
114
|
return;
|
|
82
115
|
const previous = states.get(event.projectId);
|
|
@@ -85,7 +118,7 @@ export function createRunNotifier(deps) {
|
|
|
85
118
|
states.set(event.projectId, event.state);
|
|
86
119
|
const kind = noticeOf(previous, event.state);
|
|
87
120
|
if (kind !== undefined)
|
|
88
|
-
notify(event.projectId, kind);
|
|
121
|
+
notify(event.projectId, { kind });
|
|
89
122
|
}
|
|
90
123
|
catch (error) {
|
|
91
124
|
deps.log.write("warn", "notification.failed", {
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
import { failureId, } from "../../kernel/pipeline.js";
|
|
2
|
+
// Only a run seen running or waiting can finish, fail or stop to wait: a project first seen
|
|
3
|
+
// already done was done before anyone was watching. Paused and canceled are what the user just
|
|
4
|
+
// pressed, so they say nothing. `pending` after `running` is the runner finding nothing it may
|
|
5
|
+
// start: a review checkpoint holding the next step, or held work waiting for Resume. A run seen
|
|
6
|
+
// waiting can end without being seen running again: an answered review's step may finish in
|
|
7
|
+
// the tick that releases it, or a failed step beside it may leave nothing else to go on.
|
|
5
8
|
export function noticeOf(previous, next) {
|
|
6
|
-
if (previous !== "running")
|
|
9
|
+
if (previous !== "running" && previous !== "pending")
|
|
10
|
+
return undefined;
|
|
11
|
+
if (previous === "pending" && next === "pending")
|
|
7
12
|
return undefined;
|
|
8
13
|
switch (next) {
|
|
9
14
|
case "done":
|
|
@@ -31,10 +36,96 @@ export function reviewNoticeText(subject) {
|
|
|
31
36
|
const item = reviewItemNames[subject.stage] ?? "an item";
|
|
32
37
|
const headline = `Review needs a decision: ${title}`;
|
|
33
38
|
return {
|
|
34
|
-
headline: reason === undefined ? headline : `${headline} — ${reason}`,
|
|
35
|
-
detail:
|
|
39
|
+
headline: `${reason === undefined ? headline : `${headline} — ${reason}`}${subject.byAgent === true ? " · by agent" : ""}`,
|
|
40
|
+
detail: `${subject.byAgent === true ? "An agent's review" : "The automatic review"} flagged ${item} and kept it. Open the project and press Overrule to keep it or Redo to make it again.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// A reviewed item by the name the project page gives it: an image by its place in the image
|
|
44
|
+
// order ("image 3"), a short or a thumbnail variant by its number. Undefined for keys that are
|
|
45
|
+
// no reviewed item.
|
|
46
|
+
export function reviewItemName(itemKey, imageOrder) {
|
|
47
|
+
if (itemKey === "article:body")
|
|
48
|
+
return "the article";
|
|
49
|
+
if (itemKey === "narration")
|
|
50
|
+
return "the narration";
|
|
51
|
+
if (itemKey === "thumbnail:image")
|
|
52
|
+
return "the thumbnail";
|
|
53
|
+
const variant = /^thumbnail:image:(\d+)$/.exec(itemKey);
|
|
54
|
+
if (variant !== null)
|
|
55
|
+
return `thumbnail ${variant[1] ?? ""}`;
|
|
56
|
+
const short = /^shorts:(\d+)$/.exec(itemKey);
|
|
57
|
+
if (short !== null)
|
|
58
|
+
return `short ${short[1] ?? ""}`;
|
|
59
|
+
if (!itemKey.startsWith("image:"))
|
|
60
|
+
return undefined;
|
|
61
|
+
const at = imageOrder.indexOf(itemKey.slice("image:".length));
|
|
62
|
+
return at === -1 ? "an image" : `image ${String(at + 1)}`;
|
|
63
|
+
}
|
|
64
|
+
// An external review's two hours ran out with no agent's verdict (`review.auto-passed`).
|
|
65
|
+
export function autoPassNoticeText(subject) {
|
|
66
|
+
const title = subject.title.trim() === "" ? "Untitled project" : subject.title.trim();
|
|
67
|
+
const item = subject.item ?? reviewItemNames[subject.stage] ?? "an item";
|
|
68
|
+
return {
|
|
69
|
+
headline: "Passed without review: no agent answered in 2 h",
|
|
70
|
+
detail: `${title}: ${item} went ahead unreviewed. Open the project to look at it, or check that your agent's routine is running.`,
|
|
36
71
|
};
|
|
37
72
|
}
|
|
73
|
+
// The failed steps of a run now, in the run's order: the first is the one a run's notice names.
|
|
74
|
+
export function failuresOf(stages) {
|
|
75
|
+
return stages
|
|
76
|
+
.filter((stage) => stage.state === "failed")
|
|
77
|
+
.map((stage) => ({
|
|
78
|
+
id: failureId(stage.kind, stage.finishedAt),
|
|
79
|
+
kind: stage.kind,
|
|
80
|
+
...(stage.failureReason === null ? {} : { reason: stage.failureReason }),
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
// What a project's notices say, for the server's Notification URL and the browser alike, given the
|
|
84
|
+
// failures already told for the project. A step that fails while another waits for the agent, the
|
|
85
|
+
// person or its day is said right away ("Step failed"): as it fails while a third step still runs,
|
|
86
|
+
// or when the run stops to wait beside it; once per failure. One that fails with nothing waiting is
|
|
87
|
+
// told only by the run's end notice. A run that ends on its failures names them all; one that ends
|
|
88
|
+
// Failed only on failures already said is not told again. Each notice names the reason of a step
|
|
89
|
+
// failed now: the run's first, or the first "Step failed" tells.
|
|
90
|
+
export function runNotices(cause, subject, told) {
|
|
91
|
+
const failures = subject.failures ?? [];
|
|
92
|
+
const untold = failures.filter((failure) => !told.has(failure.id));
|
|
93
|
+
const notices = [];
|
|
94
|
+
// The failures "Step failed" names now.
|
|
95
|
+
let stepNews = [];
|
|
96
|
+
if ("kind" in cause) {
|
|
97
|
+
const { kind } = cause;
|
|
98
|
+
const repeat = kind === "failed" && failures.length > 0 && untold.length === 0;
|
|
99
|
+
if (!repeat && announces(kind, subject))
|
|
100
|
+
notices.push({ kind, reason: failures[0]?.reason });
|
|
101
|
+
if (kind === "waiting" && subject.stepFailedWhileWaiting === true)
|
|
102
|
+
stepNews = untold;
|
|
103
|
+
}
|
|
104
|
+
else if (subject.status === "running" && subject.stepFailedWhileWaiting === true)
|
|
105
|
+
// Stopped to wait or ended, the run's own transition tells it.
|
|
106
|
+
stepNews = untold.filter((failure) => failure.kind === cause.stepFailed);
|
|
107
|
+
if (stepNews.length > 0)
|
|
108
|
+
notices.push({ kind: "step-failed", reason: stepNews[0]?.reason });
|
|
109
|
+
const ended = notices.some((one) => one.kind === "failed" || one.kind === "partial");
|
|
110
|
+
return {
|
|
111
|
+
notices,
|
|
112
|
+
told: new Set(failures
|
|
113
|
+
.filter((failure) => ended || told.has(failure.id) || stepNews.includes(failure))
|
|
114
|
+
.map((failure) => failure.id)),
|
|
115
|
+
news: stepNews.map((failure) => failure.id),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// Whether a prepared run's own Before Video hold is what holds it now (`atItsDay`), from the
|
|
119
|
+
// project's `prepared` and `holding` (`reviews/holds.ts`): the notifier and the browser both ask.
|
|
120
|
+
export function atItsDay(prepared, holding) {
|
|
121
|
+
return prepared && holding.some((hold) => hold.waitsFor === "day");
|
|
122
|
+
}
|
|
123
|
+
// A run parked on an agent's review waits for nobody: the agent answers, or the review passes
|
|
124
|
+
// at its deadline and says so then. One at its day's hold waits for its scheduled day. Every
|
|
125
|
+
// other notice goes out.
|
|
126
|
+
function announces(kind, subject) {
|
|
127
|
+
return kind !== "waiting" || (subject.waitingForAgent !== true && subject.atItsDay !== true);
|
|
128
|
+
}
|
|
38
129
|
const reasonMax = 140;
|
|
39
130
|
// A provider's failure can run to paragraphs; a notification has a line.
|
|
40
131
|
export function shortReason(reason) {
|
|
@@ -47,6 +138,12 @@ export function shortReason(reason) {
|
|
|
47
138
|
return line.length <= reasonMax ? line : `${line.slice(0, reasonMax - 1).trimEnd()}…`;
|
|
48
139
|
}
|
|
49
140
|
export function noticeText(kind, subject) {
|
|
141
|
+
const text = runNoticeText(kind, subject);
|
|
142
|
+
return subject.startedBy === "agent"
|
|
143
|
+
? { ...text, headline: `${text.headline} · started by agent` }
|
|
144
|
+
: text;
|
|
145
|
+
}
|
|
146
|
+
function runNoticeText(kind, subject) {
|
|
50
147
|
const title = subject.title.trim() === "" ? "Untitled project" : subject.title.trim();
|
|
51
148
|
switch (kind) {
|
|
52
149
|
case "ready":
|
|
@@ -70,6 +167,13 @@ export function noticeText(kind, subject) {
|
|
|
70
167
|
detail: "Open the project to see what stopped it and press Retry.",
|
|
71
168
|
};
|
|
72
169
|
}
|
|
170
|
+
case "step-failed": {
|
|
171
|
+
const reason = shortReason(subject.reason);
|
|
172
|
+
return {
|
|
173
|
+
headline: reason === undefined ? `Step failed: ${title}` : `Step failed: ${title} — ${reason}`,
|
|
174
|
+
detail: "The run still waits on another step, so it isn't over. Open the project to see which step failed and press Retry.",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
73
177
|
case "waiting":
|
|
74
178
|
return {
|
|
75
179
|
headline: `Waiting for you: ${title}`,
|
|
@@ -86,41 +190,3 @@ export function topicsNoticeText(subject) {
|
|
|
86
190
|
detail: `Open Calendar → Suggested topics → ${name} to queue or reject ${count === 1 ? "it" : "them"}.${total}`,
|
|
87
191
|
};
|
|
88
192
|
}
|
|
89
|
-
export const testNotice = {
|
|
90
|
-
headline: "Slopify test notification",
|
|
91
|
-
detail: "Notifications work. You'll get one when a run finishes, fails or waits for you, and when a review needs your decision.",
|
|
92
|
-
};
|
|
93
|
-
// The body a Notification URL receives: plain text, which ntfy shows as the message. Nothing
|
|
94
|
-
// but the project's title, what happened and the provider's own short reason goes in it.
|
|
95
|
-
export function webhookBody(text) {
|
|
96
|
-
return `${text.headline}\n${text.detail}\n${text.link === undefined ? "" : `${text.link}\n`}`;
|
|
97
|
-
}
|
|
98
|
-
// Where a project opens in the browser, from the address the server listens on. A wildcard
|
|
99
|
-
// (0.0.0.0 in Docker) or loopback address is reached as localhost from the same machine.
|
|
100
|
-
export function projectLink(host, port, projectId) {
|
|
101
|
-
const wildcard = host === "0.0.0.0" || host === "::" || host === "" || host === "127.0.0.1";
|
|
102
|
-
const authority = wildcard ? "localhost" : host.includes(":") ? `[${host}]` : host;
|
|
103
|
-
return `http://${authority}:${String(port)}/projects/${encodeURIComponent(projectId)}`;
|
|
104
|
-
}
|
|
105
|
-
export const notificationUrlMax = 2048;
|
|
106
|
-
// Empty clears the setting. Otherwise one absolute http(s) URL with no user name or password:
|
|
107
|
-
// fetch refuses those, and a password in a URL would sit in the database in plain sight.
|
|
108
|
-
export function notificationUrlProblem(value) {
|
|
109
|
-
const trimmed = value.trim();
|
|
110
|
-
if (trimmed === "")
|
|
111
|
-
return undefined;
|
|
112
|
-
if (trimmed.length > notificationUrlMax)
|
|
113
|
-
return `Keep the Notification URL to ${String(notificationUrlMax)} characters or fewer.`;
|
|
114
|
-
let parsed;
|
|
115
|
-
try {
|
|
116
|
-
parsed = new URL(trimmed);
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return "Enter a full address starting with https:// or http://, for example https://ntfy.sh/your-topic.";
|
|
120
|
-
}
|
|
121
|
-
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
122
|
-
return "The Notification URL must start with https:// or http://.";
|
|
123
|
-
if (parsed.username !== "" || parsed.password !== "")
|
|
124
|
-
return "Remove the user name and password from the Notification URL; Slopify can't send them.";
|
|
125
|
-
return undefined;
|
|
126
|
-
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createRunNotifier } from "./notifier.js";
|
|
2
|
+
import { readNotificationUrl } from "./settings.js";
|
|
3
|
+
import { noticeSubject } from "./subject.js";
|
|
4
|
+
import { toldFailures } from "./told.js";
|
|
5
|
+
// The server's run notifier (`main.ts`): the Notification URL saved in Settings, each notice
|
|
6
|
+
// read from the project's rows as it goes out, and what was told kept beside them.
|
|
7
|
+
export function createServerNotifier(db, deps) {
|
|
8
|
+
return createRunNotifier({
|
|
9
|
+
...deps,
|
|
10
|
+
url: () => readNotificationUrl(db),
|
|
11
|
+
subject: (projectId) => noticeSubject(db, projectId),
|
|
12
|
+
told: toldFailures(db),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readSetting, writeSetting } from "../settings/repo.js";
|
|
2
|
-
import { notificationUrlProblem } from "./
|
|
2
|
+
import { notificationUrlProblem } from "./address.js";
|
|
3
3
|
// Its own row in the key/value `settings` table. Backups leave it out
|
|
4
4
|
// (`slices/storage/portable.ts`): an ntfy topic in the URL is as good as a password.
|
|
5
5
|
export const notificationUrlKey = "notificationUrl";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { derive } from "../../kernel/runner/graph.js";
|
|
2
|
+
import { projectById, stagesOf } from "../admission/repo.js";
|
|
3
|
+
import { stepFailedWhileWaiting } from "../admission/state-words.js";
|
|
4
|
+
import { lastStarter } from "../agents/repo.js";
|
|
5
|
+
import { projectHolds } from "../reviews/holds.js";
|
|
6
|
+
import { uploadedAndSettled } from "../uploads/repo.js";
|
|
7
|
+
import { settledStatus } from "../uploads/rules.js";
|
|
8
|
+
import { atItsDay, failuresOf } from "./rules.js";
|
|
9
|
+
// What a notification says about a project, read at the moment it goes out, by the rule the
|
|
10
|
+
// Projects list reads it with (`edge/http/projects.ts`).
|
|
11
|
+
export function noticeSubject(db, projectId) {
|
|
12
|
+
const project = projectById(db, projectId);
|
|
13
|
+
if (project === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
const stages = stagesOf(db, projectId);
|
|
16
|
+
const settled = uploadedAndSettled(db, projectId);
|
|
17
|
+
const holds = projectHolds(db, { projectId, standings: new Map([[projectId, stages]]), settled });
|
|
18
|
+
const status = settledStatus(derive(stages, project.paused), settled.has(projectId));
|
|
19
|
+
const failures = failuresOf(stages);
|
|
20
|
+
return {
|
|
21
|
+
title: project.title,
|
|
22
|
+
makesVideo: project.config.sources.video !== "off",
|
|
23
|
+
startedBy: lastStarter(db, projectId),
|
|
24
|
+
waitingForAgent: holds.waitingForAgent.has(projectId),
|
|
25
|
+
atItsDay: atItsDay(holds.prepared.has(projectId), holds.holding.get(projectId) ?? []),
|
|
26
|
+
status,
|
|
27
|
+
...(stepFailedWhileWaiting(status, stages) ? { stepFailedWhileWaiting: true } : {}),
|
|
28
|
+
...(failures.length === 0 ? {} : { failures }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { transact } from "../../kernel/db/tx.js";
|
|
2
|
+
// In memory, for a notifier whose told failures needn't outlive it: tests.
|
|
3
|
+
// ceiling: one entry per project with a told failure.
|
|
4
|
+
export function toldInMemory() {
|
|
5
|
+
const told = new Map();
|
|
6
|
+
return {
|
|
7
|
+
read: (projectId) => told.get(projectId) ?? new Set(),
|
|
8
|
+
keep: (projectId, ids) => {
|
|
9
|
+
if (ids.size === 0)
|
|
10
|
+
told.delete(projectId);
|
|
11
|
+
else
|
|
12
|
+
told.set(projectId, ids);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
// The server's, in `told_failures`, so a restart neither tells a failure again nor counts one
|
|
17
|
+
// never told as told. A project's rows go with it.
|
|
18
|
+
export function toldFailures(db) {
|
|
19
|
+
return {
|
|
20
|
+
read: (projectId) => new Set(db
|
|
21
|
+
.prepare("SELECT failure_id FROM told_failures WHERE project_id=?")
|
|
22
|
+
.all(projectId)
|
|
23
|
+
.map((row) => String(row.failure_id))),
|
|
24
|
+
keep: (projectId, told) => {
|
|
25
|
+
transact(db, () => {
|
|
26
|
+
db.prepare("DELETE FROM told_failures WHERE project_id=?").run(projectId);
|
|
27
|
+
const insert = db.prepare("INSERT INTO told_failures(project_id,failure_id) VALUES (?,?)");
|
|
28
|
+
for (const id of told)
|
|
29
|
+
insert.run(projectId, id);
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -2,6 +2,7 @@ import { checkRuntimeModel } from "../../catalog/runtime-models.js";
|
|
|
2
2
|
import { modelFields } from "../../catalog/validate.js";
|
|
3
3
|
import { readinessIsUsable } from "../../kernel/ports/model.js";
|
|
4
4
|
import { usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
|
|
5
|
+
import { reviewedByAgent } from "../reviews/model.js";
|
|
5
6
|
import { usesReviews } from "../reviews/rules.js";
|
|
6
7
|
import { cliPathChanged } from "../settings/cli-paths.js";
|
|
7
8
|
import { hasKey, listVoices } from "../settings/repo.js";
|
|
@@ -37,8 +38,14 @@ export function choices(runs) {
|
|
|
37
38
|
usesShorts(d) ||
|
|
38
39
|
usesAnimation(d),
|
|
39
40
|
},
|
|
40
|
-
// The reviewer is its own text model, checked like the LLM row
|
|
41
|
-
|
|
41
|
+
// The reviewer is its own text model, checked like the LLM row; an external agent
|
|
42
|
+
// is no provider at all.
|
|
43
|
+
{
|
|
44
|
+
field: "reviews",
|
|
45
|
+
family: "llm",
|
|
46
|
+
choice: d.reviews,
|
|
47
|
+
needed: usesReviews(d) && !reviewedByAgent(d.reviews),
|
|
48
|
+
},
|
|
42
49
|
])
|
|
43
50
|
.flatMap((c) => (c.needed && c.choice ? [{ ...c, ...c.choice }] : []));
|
|
44
51
|
return [...new Map(selected.map((c) => [`${c.family}:${c.provider}:${c.model}`, c])).values()];
|
|
@@ -9,7 +9,7 @@ import { checkpointRowSchema, checkpointStageSchema } from "../checkpoints/schem
|
|
|
9
9
|
import { documentSettingsSchema } from "../document/theme-schema.js";
|
|
10
10
|
import { librarySnapshotSchema } from "../library/snapshot.js";
|
|
11
11
|
import { chunkModes } from "../narration/chunk.js";
|
|
12
|
-
import { reviewModes, reviewStages } from "../reviews/model.js";
|
|
12
|
+
import { reviewers, reviewModes, reviewStages } from "../reviews/model.js";
|
|
13
13
|
import { subtitleModes, subtitlePositions } from "../subtitles/model.js";
|
|
14
14
|
import { ambientBedSources } from "../video/ambient-bed.js";
|
|
15
15
|
import { voicesSettingsSchema } from "../voices/model.js";
|
|
@@ -122,6 +122,7 @@ export const playDraftFormSchema = z
|
|
|
122
122
|
provider: text,
|
|
123
123
|
model: text,
|
|
124
124
|
thinking: z.enum(thinkingModes).optional(),
|
|
125
|
+
reviewer: z.enum(reviewers).optional(),
|
|
125
126
|
retries: text,
|
|
126
127
|
stages: z
|
|
127
128
|
.partialRecord(z.enum(reviewStages), z
|