@gentbajko/slopify 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/adapter-registry.js +8 -1
  2. package/dist/adapters/host-cli/index.js +11 -0
  3. package/dist/adapters/host-cli/transport.js +6 -1
  4. package/dist/adapters/image/codex-output.js +59 -17
  5. package/dist/adapters/image/codex.js +92 -16
  6. package/dist/adapters/image/fal.js +22 -2
  7. package/dist/adapters/image/google.js +13 -1
  8. package/dist/adapters/image/openai.js +37 -15
  9. package/dist/adapters/image/reference.js +7 -0
  10. package/dist/adapters/image/replicate.js +7 -0
  11. package/dist/adapters/llm/codex-models.js +2 -1
  12. package/dist/adapters/llm/codex-server-models.js +2 -1
  13. package/dist/adapters/llm/run-cli.js +14 -2
  14. package/dist/assets/models.yaml +7 -0
  15. package/dist/catalog/registry.js +24 -3
  16. package/dist/catalog/runtime-models.js +1 -1
  17. package/dist/catalog/schema.js +10 -1
  18. package/dist/catalog/validate.js +17 -2
  19. package/dist/edge/events/hub.js +11 -0
  20. package/dist/edge/http/app.js +2 -0
  21. package/dist/edge/http/backups.js +40 -0
  22. package/dist/edge/http/host-cli.js +17 -2
  23. package/dist/edge/http/problem.js +1 -0
  24. package/dist/edge/http/schedules.js +1 -1
  25. package/dist/edge/http/settings.js +44 -0
  26. package/dist/host-cli/runtime.js +3 -2
  27. package/dist/kernel/db/migrations/0022-drop-duplicate-held-work.sql +56 -0
  28. package/dist/kernel/db/migrations/0024-reference-attachments.sql +18 -0
  29. package/dist/kernel/ports/host-cli.js +17 -2
  30. package/dist/kernel/ports/image.js +3 -1
  31. package/dist/kernel/ports/llm.js +3 -1
  32. package/dist/kernel/runner/attempt.js +1 -1
  33. package/dist/kernel/runner/providers.js +22 -2
  34. package/dist/main.js +52 -7
  35. package/dist/slices/admission/model.js +8 -0
  36. package/dist/slices/admission/rules.js +47 -0
  37. package/dist/slices/admission/schema.js +11 -1
  38. package/dist/slices/admission/start.js +53 -15
  39. package/dist/slices/backups/files.js +135 -0
  40. package/dist/slices/backups/folder.js +35 -0
  41. package/dist/slices/backups/model.js +65 -0
  42. package/dist/slices/backups/repo.js +28 -0
  43. package/dist/slices/backups/schedule.js +60 -0
  44. package/dist/slices/backups/service.js +311 -0
  45. package/dist/slices/batch/index.js +2 -0
  46. package/dist/slices/estimate/index.js +9 -1
  47. package/dist/slices/library/slots.js +8 -2
  48. package/dist/slices/library/snapshot.js +8 -2
  49. package/dist/slices/notifications/notifier.js +76 -0
  50. package/dist/slices/notifications/rules.js +81 -0
  51. package/dist/slices/notifications/send.js +40 -0
  52. package/dist/slices/notifications/settings.js +34 -0
  53. package/dist/slices/play-drafts/convert.js +35 -3
  54. package/dist/slices/play-drafts/repo.js +7 -1
  55. package/dist/slices/play-drafts/review-inputs.js +2 -0
  56. package/dist/slices/play-drafts/schema.js +20 -2
  57. package/dist/slices/play-drafts/service.js +6 -0
  58. package/dist/slices/play-drafts/uploads.js +2 -1
  59. package/dist/slices/project-templates/from-project.js +20 -0
  60. package/dist/slices/project-templates/setup.js +11 -0
  61. package/dist/slices/rebuild/preview-details.js +24 -2
  62. package/dist/slices/rebuild/recipe-build.js +9 -3
  63. package/dist/slices/rebuild/recipe-input-schema.js +8 -1
  64. package/dist/slices/rebuild/recipe-legacy.js +3 -0
  65. package/dist/slices/rebuild/recipe-reference.js +44 -0
  66. package/dist/slices/rebuild/recipe-shorts.js +11 -4
  67. package/dist/slices/rebuild/recipe-validation.js +15 -1
  68. package/dist/slices/rebuild/recipe-visual.js +20 -8
  69. package/dist/slices/rebuild/runtime-actions.js +9 -2
  70. package/dist/slices/rebuild/runtime-image.js +50 -0
  71. package/dist/slices/rebuild/runtime-local.js +6 -3
  72. package/dist/slices/rebuild/runtime-piece-label.js +2 -0
  73. package/dist/slices/rebuild/runtime-provider.js +17 -10
  74. package/dist/slices/rebuild/runtime-shorts.js +4 -6
  75. package/dist/slices/rebuild/service-readiness.js +15 -1
  76. package/dist/slices/rebuild/transition-repo.js +1 -1
  77. package/dist/slices/revisions/adopt-content.js +7 -0
  78. package/dist/slices/revisions/adopt.js +7 -2
  79. package/dist/slices/revisions/mutation-assets.js +33 -9
  80. package/dist/slices/revisions/mutation-prepare.js +8 -10
  81. package/dist/slices/revisions/schema.js +5 -1
  82. package/dist/slices/schedules/repo.js +1 -1
  83. package/dist/slices/schedules/service.js +6 -1
  84. package/dist/slices/settings/model.js +5 -0
  85. package/dist/slices/storage/layout.js +10 -0
  86. package/dist/slices/storage/model.js +3 -0
  87. package/dist/slices/storage/portable.js +6 -3
  88. package/dist/slices/storage/reconcile.js +5 -1
  89. package/dist/web/assets/index-CktpxlJd.js +171 -0
  90. package/dist/web/assets/index-DB-vOnGU.css +1 -0
  91. package/dist/web/assets/{pdf-CAJNT254.js → pdf-BLF2i8c2.js} +1 -1
  92. package/dist/web/index.html +2 -2
  93. package/package.json +1 -1
  94. package/dist/web/assets/index-BGgYMm2u.css +0 -1
  95. package/dist/web/assets/index-BVVgtntL.js +0 -171
@@ -0,0 +1,60 @@
1
+ import { nextOccurrence } from "../schedules/calendar.js";
2
+ // After a start, the first backup waits this long so it never competes with the boot's own
3
+ // work (migrations, reconcile, the queue resuming).
4
+ export const startupDelayMs = 2 * 60_000;
5
+ // A failed backup is tried again this often until the slot's backup succeeds or the next slot.
6
+ export const retryAfterFailureMs = 60 * 60_000;
7
+ // A backup held back by projects being made looks again this often.
8
+ export const retryAfterWaitingMs = 10 * 60_000;
9
+ // A slot missed while Slopify was off is made up for after the next start, unless a backup
10
+ // already succeeded this recently ("about a day", with room for a slot that ran late).
11
+ export const catchUpFreshMs = 20 * 60 * 60_000;
12
+ // The most recent time-of-day slot at or before `now`, in the configured zone. Days are 23 to 25
13
+ // hours long, so 26 hours back always holds at least one slot.
14
+ export function latestSlot(time, timeZone, now) {
15
+ const daily = { kind: "daily", time };
16
+ let slot = nextOccurrence(daily, timeZone, new Date(now.valueOf() - 26 * 60 * 60_000));
17
+ if (slot === null || slot > now)
18
+ return null;
19
+ for (;;) {
20
+ const next = nextOccurrence(daily, timeZone, slot);
21
+ if (next === null || next > now)
22
+ return slot;
23
+ slot = next;
24
+ }
25
+ }
26
+ export function nextSlot(time, timeZone, now) {
27
+ return nextOccurrence({ kind: "daily", time }, timeZone, now);
28
+ }
29
+ // At most one automatic backup per daily slot. A slot is due once no backup has succeeded
30
+ // since it; a slot that passed while Slopify was off is caught up after the next start.
31
+ export function decideBackup(input) {
32
+ const { config, status, now, bootedAt } = input;
33
+ if (!config.enabled)
34
+ return { due: false, reason: "off" };
35
+ if (input.running)
36
+ return { due: false, reason: "running" };
37
+ const slot = latestSlot(config.time, config.timeZone, now);
38
+ if (slot === null)
39
+ return { due: false, reason: "not-yet" };
40
+ if (config.enabledAt !== null && slot.valueOf() < Date.parse(config.enabledAt))
41
+ return { due: false, reason: "not-yet" };
42
+ const lastSuccess = status.lastSuccessAt === null ? null : Date.parse(status.lastSuccessAt);
43
+ if (lastSuccess !== null && lastSuccess >= slot.valueOf())
44
+ return { due: false, reason: "done" };
45
+ const missed = bootedAt.valueOf() > slot.valueOf();
46
+ if (missed && lastSuccess !== null && now.valueOf() - lastSuccess < catchUpFreshMs)
47
+ return { due: false, reason: "done" };
48
+ if (now.valueOf() - bootedAt.valueOf() < startupDelayMs)
49
+ return { due: false, reason: "starting" };
50
+ if (status.lastSlot === slot.toISOString() && status.lastAttemptAt !== null) {
51
+ const wait = status.lastResult === "failed"
52
+ ? retryAfterFailureMs
53
+ : status.lastResult === "waiting"
54
+ ? retryAfterWaitingMs
55
+ : 0;
56
+ if (now.valueOf() - Date.parse(status.lastAttemptAt) < wait)
57
+ return { due: false, reason: "retry-later" };
58
+ }
59
+ return { due: true, slot: slot.toISOString(), trigger: missed ? "catch-up" : "scheduled" };
60
+ }
@@ -0,0 +1,311 @@
1
+ import { statfs } from "node:fs/promises";
2
+ import { BackupBusyError, planBackup, streamBackup, } from "../storage/backup-export.js";
3
+ import { defaultBackupsDir } from "../storage/layout.js";
4
+ import { backupFileName, codeOf, ensureFolder, listBackups, pruneBackups, removeLeftovers, writeAtomically, } from "./files.js";
5
+ import { effectiveFolder, folderProblem, hostFolder } from "./folder.js";
6
+ import { readConfig, readStatus, writeConfig, writeStatus } from "./repo.js";
7
+ import { decideBackup, nextSlot } from "./schedule.js";
8
+ // Scheduled backups reuse Export everything's own code path (planBackup, streamBackup) and so
9
+ // its rule too: nothing is copied while a project is being made, because its files are still
10
+ // being written. A backup that meets one waits and looks again, so it never competes with a
11
+ // render; the writing itself yields to the event loop after every chunk.
12
+ export function createBackupService(deps) {
13
+ let current;
14
+ let stopped = false;
15
+ const zone = deps.serverTimeZone ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
16
+ const config = () => readConfig(deps.db, zone);
17
+ const folderOf = (value) => effectiveFolder(deps.paths, value.folder);
18
+ const record = (status) => {
19
+ try {
20
+ writeStatus(deps.db, status);
21
+ }
22
+ catch (error) {
23
+ deps.log.write("error", "backups.status", { detail: String(error) });
24
+ }
25
+ };
26
+ const start = (plan, trigger, slot, release) => {
27
+ const abort = new AbortController();
28
+ const done = write(plan, trigger, slot, abort.signal)
29
+ .catch((error) => {
30
+ deps.log.write("error", "backups.run", { detail: String(error) });
31
+ })
32
+ .finally(() => {
33
+ release();
34
+ current = undefined;
35
+ });
36
+ current = { abort, done };
37
+ return done;
38
+ };
39
+ async function write(plan, trigger, slot, signal) {
40
+ const settings = config();
41
+ const folder = folderOf(settings);
42
+ const startedAt = deps.clock.now();
43
+ const previous = readStatus(deps.db);
44
+ const attempt = {
45
+ ...previous,
46
+ lastAttemptAt: startedAt.toISOString(),
47
+ lastTrigger: trigger,
48
+ lastSlot: slot,
49
+ };
50
+ try {
51
+ await ensureFolder(folder);
52
+ await removeLeftovers(folder);
53
+ await checkSpace(folder, plan.archiveBytes);
54
+ const name = backupFileName(startedAt);
55
+ const written = await writeAtomically(folder, name, streamBackup(plan), signal);
56
+ let detail = null;
57
+ try {
58
+ await pruneBackups(folder, settings.keep);
59
+ }
60
+ catch (error) {
61
+ deps.log.write("warn", "backups.prune", { detail: String(error) });
62
+ detail = `The backup was saved, but older backups in ${folder} could not be removed (${codeOf(error) ?? "unknown error"}), so more than ${settings.keep} are kept. Check Slopify can delete files in that folder, or pick another Folder in Settings → Backups.`;
63
+ }
64
+ record({
65
+ ...attempt,
66
+ lastResult: "succeeded",
67
+ detail,
68
+ lastSuccessAt: deps.clock.now().toISOString(),
69
+ lastSuccessFile: written.path,
70
+ lastSuccessBytes: written.bytes,
71
+ lastDurationMs: deps.clock.now().valueOf() - startedAt.valueOf(),
72
+ });
73
+ }
74
+ catch (error) {
75
+ deps.log.write("error", "backups.run", { detail: String(error) });
76
+ record({
77
+ ...attempt,
78
+ lastResult: "failed",
79
+ detail: explain(error, folder, trigger !== "manual", signal),
80
+ lastDurationMs: deps.clock.now().valueOf() - startedAt.valueOf(),
81
+ });
82
+ }
83
+ }
84
+ const waiting = (projects, trigger, slot) => {
85
+ record({
86
+ ...readStatus(deps.db),
87
+ lastAttemptAt: deps.clock.now().toISOString(),
88
+ lastResult: "waiting",
89
+ lastTrigger: trigger,
90
+ lastSlot: slot,
91
+ detail: `Waiting for ${named(projects)} to finish: a backup never copies a project while its files are being written. It starts by itself when they finish or are paused.`,
92
+ lastDurationMs: null,
93
+ });
94
+ };
95
+ return {
96
+ view: async () => {
97
+ const settings = config();
98
+ const folder = folderOf(settings);
99
+ const now = deps.clock.now();
100
+ let files = [];
101
+ try {
102
+ files = await listBackups(folder);
103
+ }
104
+ catch {
105
+ files = [];
106
+ }
107
+ const decision = decideBackup({
108
+ config: settings,
109
+ status: readStatus(deps.db),
110
+ now,
111
+ bootedAt: deps.bootedAt,
112
+ running: current !== undefined,
113
+ });
114
+ const overdue = decision.due || (!decision.due && ["starting", "retry-later"].includes(decision.reason));
115
+ return {
116
+ config: {
117
+ enabled: settings.enabled,
118
+ time: settings.time,
119
+ timeZone: settings.timeZone,
120
+ keep: settings.keep,
121
+ folder: settings.folder,
122
+ },
123
+ folder,
124
+ defaultFolder: defaultBackupsDir(deps.paths),
125
+ hostFolder: hostFolder(deps.paths, deps.location, folder),
126
+ container: deps.location.container,
127
+ running: current !== undefined,
128
+ nextRunAt: settings.enabled
129
+ ? (nextSlot(settings.time, settings.timeZone, now)?.toISOString() ?? null)
130
+ : null,
131
+ overdue: settings.enabled && overdue,
132
+ status: readStatus(deps.db),
133
+ files,
134
+ };
135
+ },
136
+ save: (input) => {
137
+ const problem = folderProblem(deps.paths, input.folder);
138
+ if (problem !== undefined)
139
+ return { ok: false, detail: problem };
140
+ const before = config();
141
+ const now = deps.clock.now().toISOString();
142
+ const moved = before.time !== input.time || before.timeZone !== input.timeZone;
143
+ const enabledAt = !input.enabled
144
+ ? null
145
+ : !before.enabled || moved || before.enabledAt === null
146
+ ? now
147
+ : before.enabledAt;
148
+ writeConfig(deps.db, { ...input, enabledAt });
149
+ return { ok: true };
150
+ },
151
+ runNow: () => {
152
+ if (stopped)
153
+ return {
154
+ ok: false,
155
+ status: 409,
156
+ detail: "Slopify is shutting down. Start it again, then press Back up now.",
157
+ };
158
+ if (current !== undefined)
159
+ return {
160
+ ok: false,
161
+ status: 409,
162
+ detail: "A backup is already being written. Wait for it to finish; its result shows here.",
163
+ };
164
+ const problem = folderProblem(deps.paths, config().folder);
165
+ if (problem !== undefined)
166
+ return { ok: false, status: 400, detail: problem };
167
+ const release = deps.beginMutation === undefined ? () => { } : deps.beginMutation();
168
+ if (release === undefined)
169
+ return busyUpdating();
170
+ let plan;
171
+ try {
172
+ plan = planBackup(deps);
173
+ }
174
+ catch (error) {
175
+ release();
176
+ if (error instanceof BackupBusyError)
177
+ return {
178
+ ok: false,
179
+ status: 409,
180
+ detail: `Slopify can't back up while ${named(error.projects)} ${error.projects.length === 1 ? "is" : "are"} being made: their files are still being written. Wait for them to finish, or pause them on their project page, then press Back up now again.`,
181
+ };
182
+ throw error;
183
+ }
184
+ void start(plan, "manual", null, release);
185
+ return { ok: true };
186
+ },
187
+ tick: async () => {
188
+ if (stopped || current !== undefined)
189
+ return;
190
+ const settings = config();
191
+ const decision = decideBackup({
192
+ config: settings,
193
+ status: readStatus(deps.db),
194
+ now: deps.clock.now(),
195
+ bootedAt: deps.bootedAt,
196
+ running: false,
197
+ });
198
+ if (!decision.due)
199
+ return;
200
+ if (folderProblem(deps.paths, settings.folder) !== undefined)
201
+ return;
202
+ const release = deps.beginMutation === undefined ? () => { } : deps.beginMutation();
203
+ // An update is being installed; the backup runs after the restart.
204
+ if (release === undefined)
205
+ return;
206
+ let plan;
207
+ try {
208
+ plan = planBackup(deps);
209
+ }
210
+ catch (error) {
211
+ release();
212
+ if (error instanceof BackupBusyError) {
213
+ waiting(error.projects, decision.trigger, decision.slot);
214
+ return;
215
+ }
216
+ deps.log.write("error", "backups.plan", { detail: String(error) });
217
+ record({
218
+ ...readStatus(deps.db),
219
+ lastAttemptAt: deps.clock.now().toISOString(),
220
+ lastResult: "failed",
221
+ lastTrigger: decision.trigger,
222
+ lastSlot: decision.slot,
223
+ detail: "The backup was not saved because Slopify could not read what to back up. It tries again in an hour; if it keeps failing, use Download diagnostics in Settings and report it.",
224
+ lastDurationMs: null,
225
+ });
226
+ return;
227
+ }
228
+ await start(plan, decision.trigger, decision.slot, release);
229
+ },
230
+ stop: async () => {
231
+ stopped = true;
232
+ const running = current;
233
+ if (running === undefined)
234
+ return;
235
+ running.abort.abort(new Error("Slopify is stopping."));
236
+ await running.done;
237
+ },
238
+ };
239
+ }
240
+ function busyUpdating() {
241
+ return {
242
+ ok: false,
243
+ status: 409,
244
+ detail: "Slopify is installing an update. Press Back up now again after it restarts.",
245
+ };
246
+ }
247
+ async function checkSpace(folder, needed) {
248
+ let free;
249
+ try {
250
+ const stats = await statfs(folder);
251
+ free = Number(stats.bavail) * Number(stats.bsize);
252
+ }
253
+ catch {
254
+ return;
255
+ }
256
+ if (free < needed)
257
+ throw new NoSpaceError(needed, free);
258
+ }
259
+ class NoSpaceError extends Error {
260
+ needed;
261
+ free;
262
+ constructor(needed, free) {
263
+ super("Not enough free space for the backup.");
264
+ this.needed = needed;
265
+ this.free = free;
266
+ }
267
+ }
268
+ function named(projects) {
269
+ const titles = projects
270
+ .slice(0, 3)
271
+ .map((project) => `"${project.title}"`)
272
+ .join(", ");
273
+ return projects.length > 3 ? `${titles} and ${projects.length - 3} more` : titles;
274
+ }
275
+ // What failed, why, and what to change - the sentence Settings → Backups shows.
276
+ export function explain(error, folder, retries, signal) {
277
+ const fix = "Pick another Folder in Settings → Backups, or leave it empty to use the default";
278
+ if (signal?.aborted === true && error === signal.reason)
279
+ return "The backup was not saved: Slopify stopped while writing it, and the unfinished file was removed. It runs again after Slopify starts.";
280
+ if (error instanceof NoSpaceError)
281
+ return `The backup was not saved: it needs about ${bytes(error.needed)}, but the disk holding ${folder} has ${bytes(error.free)} free. Free up space there, lower "Keep last" in Settings → Backups, or pick another Folder.`;
282
+ switch (codeOf(error)) {
283
+ case "ENOSPC":
284
+ case "EDQUOT":
285
+ return `The backup was not saved: the disk holding ${folder} ran out of space. Free up space there, lower "Keep last" in Settings → Backups, or pick another Folder.`;
286
+ case "EACCES":
287
+ case "EPERM":
288
+ case "EROFS":
289
+ return `The backup was not saved: Slopify is not allowed to write to ${folder}. ${fix}.`;
290
+ case "ENOTDIR":
291
+ case "EEXIST":
292
+ return `The backup was not saved: ${folder} (or a part of its path) is a file, not a folder. ${fix}.`;
293
+ }
294
+ const again = retries
295
+ ? "It tries again within the hour, or press Back up now"
296
+ : "Press Back up now to try again";
297
+ if (error instanceof Error &&
298
+ error.message.endsWith("changed while the backup was being written."))
299
+ return `The backup was not saved: a project file changed while it was being copied. ${again}.`;
300
+ return `The backup was not saved because of an unexpected error. ${again}; if it keeps failing, use Download diagnostics in Settings and report it.`;
301
+ }
302
+ function bytes(value) {
303
+ const units = ["B", "KB", "MB", "GB", "TB"];
304
+ let amount = value;
305
+ let unit = 0;
306
+ while (amount >= 1024 && unit < units.length - 1) {
307
+ amount /= 1024;
308
+ unit += 1;
309
+ }
310
+ return `${amount >= 10 || unit === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`;
311
+ }
@@ -48,6 +48,8 @@ export function enqueueBatch(deps, batchId, runs, retainStaged = false) {
48
48
  if (draft.sources.images === "provide")
49
49
  for (const id of draft.provided.images ?? [])
50
50
  used.add(id);
51
+ if (draft.shorts?.enabled === true && draft.provided.shortsMusic)
52
+ used.add(draft.provided.shortsMusic);
51
53
  }
52
54
  for (const id of retainStaged ? [] : used) {
53
55
  sources.add(id);
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { sourceOf } from "../admission/model.js";
3
- import { imageSecondsProblem, usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
3
+ import { imageSecondsProblem, usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
4
4
  import { plainText } from "../article/plain.js";
5
5
  import { splitEndMatter } from "../article/split.js";
6
6
  import { chunkNarration, defaultChunking } from "../narration/chunk.js";
@@ -120,6 +120,14 @@ export function estimateRun(draft, rendered, expectedWords, catalogue) {
120
120
  });
121
121
  if (images === 0)
122
122
  local("Images", "Provided or off.");
123
+ // The establishing image is one more image when it is made from a prompt.
124
+ if (usesReference(draft) && draft.reference?.source === "prompt")
125
+ requests.push({
126
+ kind: "image",
127
+ stage: "Establishing image",
128
+ provider: image.provider,
129
+ model: image.model,
130
+ });
123
131
  if (["from_prompt", "prompt_by_llm"].includes(draft.sources.thumbnail))
124
132
  requests.push({
125
133
  kind: "image",
@@ -2,7 +2,7 @@
2
2
  // the picked templates, the slot names they ask for, and the rendered text stored on the
3
3
  // project. The bodies are read at the click, not at selection, so an edit made in between
4
4
  // is the one that runs.
5
- import { normaliseDraft, usesNarrationPreparation, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
5
+ import { normaliseDraft, usesNarrationPreparation, usesReference, usesShorts, usesYoutubeDescription, } from "../admission/rules.js";
6
6
  import { collectFields, render } from "../admission/substitute.js";
7
7
  import { snapshotEntry, snapshotPrompt } from "./snapshot.js";
8
8
  export function pickTemplates(db, input, snapshot) {
@@ -30,6 +30,10 @@ export function pickTemplates(db, input, snapshot) {
30
30
  body(db, "shorts", draft.shorts?.prompt, "shorts.prompt", missing, text, "shorts", snapshot);
31
31
  body(db, "image", draft.shorts?.imagePrompt, "shorts.imagePrompt", missing, image, "shortsImage", snapshot);
32
32
  }
33
+ // The establishing image's prompt: an image prompt like the others, its keywords filled the
34
+ // same way.
35
+ if (usesReference(draft) && draft.reference?.source === "prompt")
36
+ body(db, "image", draft.reference.prompt, "reference.prompt", missing, image, "referencePrompt", snapshot);
33
37
  if (sources.images === "generate") {
34
38
  for (const [index, picked] of draft.imagePrompts.entries()) {
35
39
  const field = `imagePrompts.${String(index)}.name`;
@@ -41,7 +45,9 @@ export function pickTemplates(db, input, snapshot) {
41
45
  if (sources.thumbnail === "from_prompt" || sources.thumbnail === "prompt_by_llm") {
42
46
  body(db, "thumbnail", draft.thumbnailPrompt, "thumbnailPrompt", missing, image, "thumbnailPrompt", snapshot);
43
47
  }
44
- const fields = collectFields(text.map((picked) => picked.body), image.map((picked) => picked.body));
48
+ const fields = collectFields(
49
+ // The title's keywords are asked for like a prompt's: it is filled when the run starts.
50
+ [draft.title, ...text.map((picked) => picked.body)], image.map((picked) => picked.body));
45
51
  return {
46
52
  draft: withSavedModes(draft, intro, outro),
47
53
  requiredSlots: fields.map((field) => field.name),
@@ -25,9 +25,15 @@ export const librarySnapshotSchema = z
25
25
  })
26
26
  .strict()
27
27
  .readonly();
28
+ // A template names its prompts and intros/outros; the Library's current one of that name is
29
+ // what a run from it uses, so editing a prompt reaches every template and schedule that names
30
+ // it. The copy the template saved is only the fallback for one since deleted or renamed, so a
31
+ // template never stops working because its Library changed.
28
32
  export function snapshotPrompt(db, snapshot, kind, name) {
29
- return (snapshot?.prompts.find((row) => row.kind === kind && row.name.toLowerCase() === name.toLowerCase()) ?? promptByName(db, kind, name));
33
+ return (promptByName(db, kind, name) ??
34
+ snapshot?.prompts.find((row) => row.kind === kind && row.name.toLowerCase() === name.toLowerCase()));
30
35
  }
31
36
  export function snapshotEntry(db, snapshot, category, name) {
32
- return (snapshot?.entries.find((row) => row.category === category && row.name.toLowerCase() === name.toLowerCase()) ?? entryByName(db, category, name));
37
+ return (entryByName(db, category, name) ??
38
+ snapshot?.entries.find((row) => row.category === category && row.name.toLowerCase() === name.toLowerCase()));
33
39
  }
@@ -0,0 +1,76 @@
1
+ import { redact } from "../../kernel/log.js";
2
+ import { noticeOf, noticeText, webhookBody } from "./rules.js";
3
+ import { sendFailureText } from "./send.js";
4
+ export function createRunNotifier(deps) {
5
+ // ceiling: one entry per project seen since boot, a few dozen bytes each.
6
+ const states = new Map();
7
+ const reasons = new Map();
8
+ const inflight = new Set();
9
+ let closed = false;
10
+ const notify = (projectId, kind) => {
11
+ const url = deps.url();
12
+ if (url === null)
13
+ return;
14
+ const subject = deps.subject(projectId);
15
+ if (subject === undefined)
16
+ return;
17
+ const text = noticeText(kind, { ...subject, reason: reasons.get(projectId) });
18
+ const body = redact(webhookBody(text));
19
+ const sending = deps
20
+ .send(url, body)
21
+ .then((result) => {
22
+ if (!result.ok)
23
+ deps.log.write("warn", "notification.failed", {
24
+ projectId,
25
+ detail: `${kind} notification not delivered: ${sendFailureText(result)}`,
26
+ });
27
+ })
28
+ .catch((error) => {
29
+ deps.log.write("warn", "notification.failed", {
30
+ projectId,
31
+ detail: `${kind} notification not delivered: ${error instanceof Error ? error.message : String(error)}`,
32
+ });
33
+ })
34
+ .finally(() => {
35
+ inflight.delete(sending);
36
+ });
37
+ inflight.add(sending);
38
+ };
39
+ return {
40
+ observe: (event) => {
41
+ if (closed)
42
+ return;
43
+ try {
44
+ if (event.type === "stage.state") {
45
+ if (event.state === "failed" && event.failureReason !== undefined)
46
+ reasons.set(event.projectId, event.failureReason);
47
+ else if (event.state === "running")
48
+ reasons.delete(event.projectId);
49
+ return;
50
+ }
51
+ if (event.type !== "project.state")
52
+ return;
53
+ const previous = states.get(event.projectId);
54
+ if (previous === event.state)
55
+ return;
56
+ states.set(event.projectId, event.state);
57
+ const kind = noticeOf(previous, event.state);
58
+ if (kind !== undefined)
59
+ notify(event.projectId, kind);
60
+ }
61
+ catch (error) {
62
+ deps.log.write("warn", "notification.failed", {
63
+ projectId: event.projectId,
64
+ detail: `notification skipped: ${error instanceof Error ? error.message : String(error)}`,
65
+ });
66
+ }
67
+ },
68
+ close: () => {
69
+ closed = true;
70
+ },
71
+ settled: async () => {
72
+ while (inflight.size > 0)
73
+ await Promise.all([...inflight]);
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,81 @@
1
+ // Only a run seen running can finish, fail or stop to wait: a project first seen already done
2
+ // was done before anyone was watching. Paused and canceled are what the user just pressed, so
3
+ // they say nothing. `pending` after `running` is the runner finding nothing it may start: a
4
+ // review checkpoint holding the next step, or held work waiting for Resume.
5
+ export function noticeOf(previous, next) {
6
+ if (previous !== "running")
7
+ return undefined;
8
+ switch (next) {
9
+ case "done":
10
+ return "ready";
11
+ case "failed":
12
+ return "failed";
13
+ case "pending":
14
+ return "waiting";
15
+ default:
16
+ return undefined;
17
+ }
18
+ }
19
+ const reasonMax = 140;
20
+ // A provider's failure can run to paragraphs; a notification has a line.
21
+ export function shortReason(reason) {
22
+ const line = reason
23
+ ?.split(/\r?\n/)
24
+ .map((part) => part.trim())
25
+ .find((part) => part !== "");
26
+ if (line === undefined)
27
+ return undefined;
28
+ return line.length <= reasonMax ? line : `${line.slice(0, reasonMax - 1).trimEnd()}…`;
29
+ }
30
+ export function noticeText(kind, subject) {
31
+ const title = subject.title.trim() === "" ? "Untitled project" : subject.title.trim();
32
+ switch (kind) {
33
+ case "ready":
34
+ return subject.makesVideo
35
+ ? { headline: `Video ready: ${title}`, detail: "Open the project to watch it." }
36
+ : { headline: `Run finished: ${title}`, detail: "Open the project to see its files." };
37
+ case "failed": {
38
+ const reason = shortReason(subject.reason);
39
+ return {
40
+ headline: reason === undefined ? `Run failed: ${title}` : `Run failed: ${title} — ${reason}`,
41
+ detail: "Open the project to see what stopped it and press Retry.",
42
+ };
43
+ }
44
+ case "waiting":
45
+ return {
46
+ headline: `Waiting for you: ${title}`,
47
+ detail: "Open the project to review the held step or press Resume.",
48
+ };
49
+ }
50
+ }
51
+ export const testNotice = {
52
+ headline: "Slopify test notification",
53
+ detail: "Notifications work. You'll get one when a run finishes, fails or waits for you.",
54
+ };
55
+ // The body a Notification URL receives: plain text, which ntfy shows as the message. Nothing
56
+ // but the project's title, what happened and the provider's own short reason goes in it.
57
+ export function webhookBody(text) {
58
+ return `${text.headline}\n${text.detail}\n`;
59
+ }
60
+ export const notificationUrlMax = 2048;
61
+ // Empty clears the setting. Otherwise one absolute http(s) URL with no user name or password:
62
+ // fetch refuses those, and a password in a URL would sit in the database in plain sight.
63
+ export function notificationUrlProblem(value) {
64
+ const trimmed = value.trim();
65
+ if (trimmed === "")
66
+ return undefined;
67
+ if (trimmed.length > notificationUrlMax)
68
+ return `Keep the Notification URL to ${String(notificationUrlMax)} characters or fewer.`;
69
+ let parsed;
70
+ try {
71
+ parsed = new URL(trimmed);
72
+ }
73
+ catch {
74
+ return "Enter a full address starting with https:// or http://, for example https://ntfy.sh/your-topic.";
75
+ }
76
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
77
+ return "The Notification URL must start with https:// or http://.";
78
+ if (parsed.username !== "" || parsed.password !== "")
79
+ return "Remove the user name and password from the Notification URL; Slopify can't send them.";
80
+ return undefined;
81
+ }
@@ -0,0 +1,40 @@
1
+ // One POST to the user's Notification URL. Short, single-shot and never retried: a
2
+ // notification that did not arrive is logged, and a run never waits on one.
3
+ export const notificationTimeoutMs = 5_000;
4
+ export function createNotificationSender(fetchImpl, timeoutMs = notificationTimeoutMs) {
5
+ return async (url, body) => {
6
+ try {
7
+ const response = await fetchImpl(url, {
8
+ method: "POST",
9
+ headers: { "content-type": "text/plain; charset=utf-8" },
10
+ body,
11
+ redirect: "manual",
12
+ signal: AbortSignal.timeout(timeoutMs),
13
+ });
14
+ // The answer's body is never read; cancelling it frees the socket.
15
+ await response.body?.cancel().catch(() => { });
16
+ return response.ok ? { ok: true } : { ok: false, reason: "refused", status: response.status };
17
+ }
18
+ catch (error) {
19
+ return {
20
+ ok: false,
21
+ reason: error instanceof DOMException &&
22
+ (error.name === "TimeoutError" || error.name === "AbortError")
23
+ ? "timeout"
24
+ : "unreachable",
25
+ };
26
+ }
27
+ };
28
+ }
29
+ // What went wrong, in words fit for both the log and the Settings screen. The URL itself is
30
+ // never repeated: an ntfy topic in it is as good as a password.
31
+ export function sendFailureText(result) {
32
+ switch (result.reason) {
33
+ case "timeout":
34
+ return `the Notification URL didn't answer within ${String(notificationTimeoutMs / 1000)} seconds`;
35
+ case "unreachable":
36
+ return "the Notification URL couldn't be reached (wrong address, no network, or the server is down)";
37
+ case "refused":
38
+ return `the Notification URL answered HTTP ${String(result.status)}`;
39
+ }
40
+ }