@gentbajko/slopify 0.8.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +28 -6
  2. package/SUBTITLES.md +2 -0
  3. package/dist/adapters/alignment/window.js +46 -5
  4. package/dist/adapters/alignment/worker.js +2 -2
  5. package/dist/adapters/llm/claude-code.js +6 -3
  6. package/dist/adapters/llm/codex.js +102 -12
  7. package/dist/adapters/llm/gemini.js +11 -8
  8. package/dist/adapters/llm/run-cli.js +61 -6
  9. package/dist/adapters/tts/inworld-async.js +4 -4
  10. package/dist/adapters/tts/inworld.js +73 -68
  11. package/dist/catalog/registry.js +13 -73
  12. package/dist/edge/cli.js +5 -2
  13. package/dist/edge/events/hub.js +7 -2
  14. package/dist/edge/events/preview-cache.js +7 -4
  15. package/dist/edge/events/visibility.js +33 -0
  16. package/dist/edge/http/actions.js +74 -79
  17. package/dist/edge/http/app.js +39 -6
  18. package/dist/edge/http/audio-preview.js +9 -1
  19. package/dist/edge/http/checkpoints.js +95 -0
  20. package/dist/edge/http/diagnostics.js +31 -0
  21. package/dist/edge/http/draft-files.js +69 -0
  22. package/dist/edge/http/draft-problem.js +15 -0
  23. package/dist/edge/http/drafts.js +95 -0
  24. package/dist/edge/http/multipart.js +86 -0
  25. package/dist/edge/http/mutations.js +49 -0
  26. package/dist/edge/http/planning.js +12 -1
  27. package/dist/edge/http/project-templates.js +74 -0
  28. package/dist/edge/http/projects.js +19 -2
  29. package/dist/edge/http/revision-files.js +84 -0
  30. package/dist/edge/http/revisions.js +103 -0
  31. package/dist/edge/http/schedules.js +100 -0
  32. package/dist/edge/http/staging.js +11 -52
  33. package/dist/edge/http/storage.js +110 -0
  34. package/dist/edge/http/subtitles.js +9 -79
  35. package/dist/edge/http/tutorial.js +26 -0
  36. package/dist/edge/signal-shutdown.js +23 -0
  37. package/dist/kernel/audio-preview.js +11 -5
  38. package/dist/kernel/db/migrations/0004-project-revisions.sql +91 -0
  39. package/dist/kernel/db/migrations/0005-revision-work.sql +103 -0
  40. package/dist/kernel/db/migrations/0006-play-drafts.sql +38 -0
  41. package/dist/kernel/db/migrations/0007-review-checkpoints.sql +28 -0
  42. package/dist/kernel/db/migrations/0008-project-templates.sql +21 -0
  43. package/dist/kernel/db/migrations/0009-scheduled-jobs.sql +37 -0
  44. package/dist/kernel/db/migrations/0010-retain-schedule-history.sql +62 -0
  45. package/dist/kernel/ports/model.js +5 -0
  46. package/dist/kernel/runner/attempt-repo.js +15 -0
  47. package/dist/kernel/runner/attempt.js +14 -2
  48. package/dist/kernel/runner/checkpoint-authority.js +23 -0
  49. package/dist/kernel/runner/index.js +35 -11
  50. package/dist/kernel/runner/providers.js +18 -8
  51. package/dist/kernel/runner/work-attempt.js +47 -0
  52. package/dist/kernel/runner/work-authority.js +37 -0
  53. package/dist/kernel/runner/work.js +23 -0
  54. package/dist/main.js +227 -69
  55. package/dist/slices/admission/repo.js +5 -56
  56. package/dist/slices/admission/schema.js +64 -0
  57. package/dist/slices/admission/start.js +13 -4
  58. package/dist/slices/article/continuation.js +9 -3
  59. package/dist/slices/article/provided-entries.js +7 -3
  60. package/dist/slices/article/run.js +20 -7
  61. package/dist/slices/article/segments.js +13 -4
  62. package/dist/slices/batch/index.js +8 -11
  63. package/dist/slices/cancel/index.js +78 -34
  64. package/dist/slices/checkpoints/change.js +250 -0
  65. package/dist/slices/checkpoints/fingerprint.js +33 -0
  66. package/dist/slices/checkpoints/index.js +2 -0
  67. package/dist/slices/checkpoints/model.js +1 -0
  68. package/dist/slices/checkpoints/recovery.js +116 -0
  69. package/dist/slices/checkpoints/repo.js +132 -0
  70. package/dist/slices/checkpoints/rules.js +30 -0
  71. package/dist/slices/checkpoints/schema.js +59 -0
  72. package/dist/slices/control/index.js +88 -22
  73. package/dist/slices/control/providers.js +3 -2
  74. package/dist/slices/control/revision-control-schema.js +7 -0
  75. package/dist/slices/control/revision-control.js +59 -0
  76. package/dist/slices/estimate/index.js +77 -59
  77. package/dist/slices/estimate/requests.js +102 -0
  78. package/dist/slices/images/run.js +14 -3
  79. package/dist/slices/library/slots.js +11 -11
  80. package/dist/slices/library/snapshot.js +33 -0
  81. package/dist/slices/narration/legacy-plan.js +58 -0
  82. package/dist/slices/narration/live.js +2 -2
  83. package/dist/slices/narration/plan.js +48 -54
  84. package/dist/slices/narration/run.js +21 -8
  85. package/dist/slices/play-drafts/convert.js +120 -0
  86. package/dist/slices/play-drafts/model.js +1 -0
  87. package/dist/slices/play-drafts/readiness.js +80 -0
  88. package/dist/slices/play-drafts/repo.js +76 -0
  89. package/dist/slices/play-drafts/review-inputs.js +150 -0
  90. package/dist/slices/play-drafts/review.js +109 -0
  91. package/dist/slices/play-drafts/schema.js +218 -0
  92. package/dist/slices/play-drafts/service.js +242 -0
  93. package/dist/slices/play-drafts/start-repo.js +113 -0
  94. package/dist/slices/play-drafts/start.js +148 -0
  95. package/dist/slices/play-drafts/uploads.js +67 -0
  96. package/dist/slices/project-templates/from-project.js +159 -0
  97. package/dist/slices/project-templates/model.js +1 -0
  98. package/dist/slices/project-templates/repo.js +41 -0
  99. package/dist/slices/project-templates/schema.js +37 -0
  100. package/dist/slices/project-templates/service.js +136 -0
  101. package/dist/slices/project-templates/setup.js +55 -0
  102. package/dist/slices/rebuild/admission-repo.js +120 -0
  103. package/dist/slices/rebuild/dependencies.js +33 -0
  104. package/dist/slices/rebuild/model.js +94 -0
  105. package/dist/slices/rebuild/narration-history.js +43 -0
  106. package/dist/slices/rebuild/narration-reuse.js +20 -0
  107. package/dist/slices/rebuild/preview-details.js +144 -0
  108. package/dist/slices/rebuild/preview-plan.js +223 -0
  109. package/dist/slices/rebuild/preview-retained.js +128 -0
  110. package/dist/slices/rebuild/provided-review.js +32 -0
  111. package/dist/slices/rebuild/recipe-audio.js +172 -0
  112. package/dist/slices/rebuild/recipe-build.js +17 -0
  113. package/dist/slices/rebuild/recipe-checkpoints.js +75 -0
  114. package/dist/slices/rebuild/recipe-exports.js +65 -0
  115. package/dist/slices/rebuild/recipe-fixture.js +150 -0
  116. package/dist/slices/rebuild/recipe-input-schema.js +82 -0
  117. package/dist/slices/rebuild/recipe-legacy.js +135 -0
  118. package/dist/slices/rebuild/recipe-model.js +77 -0
  119. package/dist/slices/rebuild/recipe-provider-choice.js +14 -0
  120. package/dist/slices/rebuild/recipe-save.js +221 -0
  121. package/dist/slices/rebuild/recipe-text.js +170 -0
  122. package/dist/slices/rebuild/recipe-validation.js +86 -0
  123. package/dist/slices/rebuild/recipe-visual.js +111 -0
  124. package/dist/slices/rebuild/recipe-work.js +212 -0
  125. package/dist/slices/rebuild/recipes.js +4 -0
  126. package/dist/slices/rebuild/repo.js +64 -0
  127. package/dist/slices/rebuild/runtime-actions.js +76 -0
  128. package/dist/slices/rebuild/runtime-admission.js +71 -0
  129. package/dist/slices/rebuild/runtime-article.js +117 -0
  130. package/dist/slices/rebuild/runtime-checkpoints.js +45 -0
  131. package/dist/slices/rebuild/runtime-export-inputs.js +109 -0
  132. package/dist/slices/rebuild/runtime-export.js +139 -0
  133. package/dist/slices/rebuild/runtime-instructions.js +51 -0
  134. package/dist/slices/rebuild/runtime-local.js +137 -0
  135. package/dist/slices/rebuild/runtime-materialize.js +105 -0
  136. package/dist/slices/rebuild/runtime-narration-reuse.js +134 -0
  137. package/dist/slices/rebuild/runtime-plan.js +103 -0
  138. package/dist/slices/rebuild/runtime-provider.js +184 -0
  139. package/dist/slices/rebuild/runtime-publication.js +172 -0
  140. package/dist/slices/rebuild/runtime-run.js +35 -0
  141. package/dist/slices/rebuild/runtime-store.js +166 -0
  142. package/dist/slices/rebuild/runtime-subtitles.js +202 -0
  143. package/dist/slices/rebuild/service-readiness.js +110 -0
  144. package/dist/slices/rebuild/service.js +149 -0
  145. package/dist/slices/rebuild/transition-repo.js +108 -0
  146. package/dist/slices/rebuild/work-records.js +44 -0
  147. package/dist/slices/reruns/index.js +5 -0
  148. package/dist/slices/research/run.js +33 -12
  149. package/dist/slices/revisions/adopt-assets.js +31 -0
  150. package/dist/slices/revisions/adopt-content.js +112 -0
  151. package/dist/slices/revisions/adopt.js +100 -0
  152. package/dist/slices/revisions/downloads.js +80 -0
  153. package/dist/slices/revisions/index.js +5 -0
  154. package/dist/slices/revisions/manifest-repo.js +125 -0
  155. package/dist/slices/revisions/model.js +1 -0
  156. package/dist/slices/revisions/mutation-assets.js +143 -0
  157. package/dist/slices/revisions/mutation-cues.js +95 -0
  158. package/dist/slices/revisions/mutation-prepare.js +182 -0
  159. package/dist/slices/revisions/mutation-request.js +58 -0
  160. package/dist/slices/revisions/mutation-work.js +47 -0
  161. package/dist/slices/revisions/mutations.js +194 -0
  162. package/dist/slices/revisions/projection.js +82 -0
  163. package/dist/slices/revisions/publication-model.js +1 -0
  164. package/dist/slices/revisions/publication-rules.js +166 -0
  165. package/dist/slices/revisions/publish.js +115 -0
  166. package/dist/slices/revisions/repo.js +83 -0
  167. package/dist/slices/revisions/restore.js +60 -0
  168. package/dist/slices/revisions/rules.js +99 -0
  169. package/dist/slices/revisions/schema.js +160 -0
  170. package/dist/slices/revisions/view.js +44 -0
  171. package/dist/slices/schedules/calendar.js +47 -0
  172. package/dist/slices/schedules/model.js +1 -0
  173. package/dist/slices/schedules/repo.js +281 -0
  174. package/dist/slices/schedules/scheduler.js +144 -0
  175. package/dist/slices/schedules/schema.js +80 -0
  176. package/dist/slices/schedules/service.js +178 -0
  177. package/dist/slices/settings/cli-paths.js +1 -1
  178. package/dist/slices/settings/cli-status.js +45 -5
  179. package/dist/slices/settings/tutorial-schema.js +39 -0
  180. package/dist/slices/settings/tutorial.js +66 -0
  181. package/dist/slices/storage/assets.js +108 -0
  182. package/dist/slices/storage/delete-project.js +2 -1
  183. package/dist/slices/storage/downloads.js +4 -1
  184. package/dist/slices/storage/portable.js +871 -0
  185. package/dist/slices/storage/prepare.js +67 -0
  186. package/dist/slices/storage/reconcile.js +25 -5
  187. package/dist/slices/storage/repo.js +2 -15
  188. package/dist/slices/storage/schema.js +35 -0
  189. package/dist/slices/storage/staging-refs.js +26 -0
  190. package/dist/slices/storage/staging.js +16 -11
  191. package/dist/slices/thumbnail/run.js +26 -9
  192. package/dist/slices/video/audio-export-args.js +33 -0
  193. package/dist/slices/video/audio-export.js +13 -36
  194. package/dist/slices/video/audio-inputs.js +4 -3
  195. package/dist/slices/video/ffmpeg.js +6 -0
  196. package/dist/slices/video/reuse-audio.js +11 -4
  197. package/dist/updater/install-flow.js +27 -2
  198. package/dist/updater/plan.js +28 -2
  199. package/dist/updater/service.js +27 -2
  200. package/dist/updater/worker.js +2 -1
  201. package/dist/web/assets/index-CO7DnVX9.js +137 -0
  202. package/dist/web/assets/index-DX1_chKs.css +1 -0
  203. package/dist/web/index.html +2 -2
  204. package/package.json +2 -2
  205. package/dist/adapters/tts/inworld-text.js +0 -32
  206. package/dist/web/assets/index-Ce2HApus.css +0 -1
  207. package/dist/web/assets/index-xbD0MZOY.js +0 -130
@@ -3,7 +3,6 @@ import { redact } from "../../kernel/log.js";
3
3
  import { providerError } from "../../kernel/ports/model.js";
4
4
  import { retryAfter } from "../retry-after.js";
5
5
  import { inworldAsync } from "./inworld-async.js";
6
- import { inworldTextParts } from "./inworld-text.js";
7
6
  export const inworldModels = [
8
7
  { id: "inworld-tts-2", name: "Realtime TTS-2" },
9
8
  { id: "inworld-tts-2-flash", name: "Realtime TTS-2 Flash" },
@@ -26,7 +25,7 @@ export function inworldTts(deps) {
26
25
  throw providerError({ kind: "missing_key", message: "No Inworld API key is stored." });
27
26
  const cancel = new AbortController();
28
27
  const signal = AbortSignal.any([request.signal, cancel.signal]);
29
- const iterator = synthesizeParts(deps, { ...request, signal }, key);
28
+ const iterator = synthesizeRequest(deps, { ...request, signal }, key);
30
29
  return {
31
30
  container: "mp3",
32
31
  audio: new ReadableStream({
@@ -51,85 +50,91 @@ export function inworldTts(deps) {
51
50
  },
52
51
  };
53
52
  }
54
- async function* synthesizeParts(deps, request, key) {
55
- if ((request.model ?? "inworld-tts-2") === "inworld-tts-2" && request.text.length > 4000) {
53
+ async function* synthesizeRequest(deps, request, key) {
54
+ if (request.continuation?.read() !== undefined) {
56
55
  yield* inworldAsync(deps, request, key);
57
56
  return;
58
57
  }
59
- const parts = inworldTextParts(request.text);
60
- if (parts.length === 0)
58
+ if (request.text.trim().length === 0)
61
59
  throw providerError({ kind: "unsupported", message: "Inworld needs text to narrate." });
62
- for (const text of parts) {
63
- request.signal.throwIfAborted();
64
- const response = await deps.fetch("https://api.inworld.ai/tts/v1/voice:stream", {
65
- method: "POST",
66
- redirect: "error",
67
- signal: request.signal,
68
- headers: { Authorization: `Basic ${key}`, "Content-Type": "application/json" },
69
- body: JSON.stringify({
70
- text,
71
- voiceId: request.voiceId,
72
- modelId: request.model ?? "inworld-tts-2",
73
- audioConfig: { audioEncoding: "MP3", sampleRateHertz: 48_000, bitRate: 128_000 },
74
- }),
60
+ if ((request.model ?? "inworld-tts-2") === "inworld-tts-2" && request.text.length > 4000) {
61
+ yield* inworldAsync(deps, request, key);
62
+ return;
63
+ }
64
+ if (request.text.length > 4000)
65
+ throw providerError({
66
+ kind: "unsupported",
67
+ message: "Inworld streaming accepts up to 4,000 characters per physical request. Rebuild narration with a supported request limit.",
75
68
  });
76
- if (!response.ok) {
77
- const detail = clean(await response.text().catch(() => ""), key);
78
- const retryAfterMs = retryAfter(response.headers.get("retry-after"));
69
+ request.signal.throwIfAborted();
70
+ const response = await deps.fetch("https://api.inworld.ai/tts/v1/voice:stream", {
71
+ method: "POST",
72
+ redirect: "error",
73
+ signal: request.signal,
74
+ headers: { Authorization: `Basic ${key}`, "Content-Type": "application/json" },
75
+ body: JSON.stringify({
76
+ text: request.text,
77
+ voiceId: request.voiceId,
78
+ modelId: request.model ?? "inworld-tts-2",
79
+ audioConfig: { audioEncoding: "MP3", sampleRateHertz: 48_000, bitRate: 128_000 },
80
+ }),
81
+ });
82
+ if (!response.ok) {
83
+ const detail = clean(await response.text().catch(() => ""), key);
84
+ const retryAfterMs = retryAfter(response.headers.get("retry-after"));
85
+ throw providerError({
86
+ kind: response.status === 401 || response.status === 403
87
+ ? "auth"
88
+ : response.status === 429
89
+ ? "rate_limit"
90
+ : response.status === 400 || response.status === 404
91
+ ? "unsupported"
92
+ : "other",
93
+ message: clean(`Inworld answered ${response.status} for voice ${request.voiceId}: ${detail || response.statusText}`, key),
94
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
95
+ });
96
+ }
97
+ if (response.body === null)
98
+ throw invalid("answered with no audio");
99
+ let heard = false;
100
+ for await (const line of lines(response.body)) {
101
+ request.signal.throwIfAborted();
102
+ let raw;
103
+ try {
104
+ raw = JSON.parse(line);
105
+ }
106
+ catch {
107
+ throw invalid("sent an unreadable audio chunk");
108
+ }
109
+ const parsed = envelope.safeParse(raw);
110
+ if (!parsed.success || (!parsed.data.result && !parsed.data.error))
111
+ throw invalid("sent an unreadable audio chunk");
112
+ if (parsed.data.error) {
113
+ const { code, message } = parsed.data.error;
79
114
  throw providerError({
80
- kind: response.status === 401 || response.status === 403
115
+ kind: code === 16 || code === 7
81
116
  ? "auth"
82
- : response.status === 429
117
+ : code === 8
83
118
  ? "rate_limit"
84
- : response.status === 400 || response.status === 404
119
+ : code === 3 || code === 5
85
120
  ? "unsupported"
86
121
  : "other",
87
- message: clean(`Inworld answered ${response.status} for voice ${request.voiceId}: ${detail || response.statusText}`, key),
88
- ...(retryAfterMs === undefined ? {} : { retryAfterMs }),
122
+ message: `Inworld stream failed: ${clean(message ?? "audio generation stopped", key)}`,
89
123
  });
90
124
  }
91
- if (response.body === null)
92
- throw invalid("answered with no audio");
93
- let heard = false;
94
- for await (const line of lines(response.body)) {
95
- request.signal.throwIfAborted();
96
- let raw;
97
- try {
98
- raw = JSON.parse(line);
99
- }
100
- catch {
101
- throw invalid("sent an unreadable audio chunk");
102
- }
103
- const parsed = envelope.safeParse(raw);
104
- if (!parsed.success || (!parsed.data.result && !parsed.data.error))
105
- throw invalid("sent an unreadable audio chunk");
106
- if (parsed.data.error) {
107
- const { code, message } = parsed.data.error;
108
- throw providerError({
109
- kind: code === 16 || code === 7
110
- ? "auth"
111
- : code === 8
112
- ? "rate_limit"
113
- : code === 3 || code === 5
114
- ? "unsupported"
115
- : "other",
116
- message: `Inworld stream failed: ${clean(message ?? "audio generation stopped", key)}`,
117
- });
118
- }
119
- const encoded = parsed.data.result?.audioContent;
120
- if (!encoded)
121
- continue;
122
- if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.length % 4 === 1)
123
- throw invalid("sent invalid encoded audio");
124
- const audio = Buffer.from(encoded, "base64");
125
- if (audio.length === 0)
126
- continue;
127
- heard = true;
128
- yield audio;
129
- }
130
- if (!heard)
131
- throw invalid("answered with no audio");
125
+ const encoded = parsed.data.result?.audioContent;
126
+ if (!encoded)
127
+ continue;
128
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.length % 4 === 1)
129
+ throw invalid("sent invalid encoded audio");
130
+ const audio = Buffer.from(encoded, "base64");
131
+ if (audio.length === 0)
132
+ continue;
133
+ heard = true;
134
+ yield audio;
132
135
  }
136
+ if (!heard)
137
+ throw invalid("answered with no audio");
133
138
  }
134
139
  async function* lines(body) {
135
140
  const reader = body.getReader();
@@ -1,5 +1,4 @@
1
1
  import { providerError } from "../kernel/ports/model.js";
2
- import { splitText } from "../kernel/ports/text.js";
3
2
  export function curateRegistry(registry, catalogue) {
4
3
  const requireModel = (provider, family, id) => {
5
4
  const models = catalogue.models(provider, family);
@@ -40,7 +39,10 @@ export function curateRegistry(registry, catalogue) {
40
39
  kind: "unsupported",
41
40
  message: "This model does not support the selected thinking setting. Choose an available setting.",
42
41
  });
43
- yield* port.complete({ ...request, ...(thinkingConfig ? { thinkingConfig } : {}) });
42
+ yield* port.complete({
43
+ ...request,
44
+ ...(request.thinkingConfig === undefined && thinkingConfig ? { thinkingConfig } : {}),
45
+ });
44
46
  },
45
47
  };
46
48
  },
@@ -66,82 +68,20 @@ export function curateRegistry(registry, catalogue) {
66
68
  ...port,
67
69
  models: async () => catalogue.models(id, "tts"),
68
70
  synthesize: async (request) => {
71
+ // Retrieval resumes an already accepted job even if new submissions are disabled.
72
+ if (request.continuation?.read() !== undefined)
73
+ return port.synthesize(request);
69
74
  const model = requireModel(id, "tts", request.model);
70
75
  if (!("tts" in model))
71
76
  throw new Error("Invalid TTS catalogue entry");
72
- const parts = splitText(request.text, model.tts.maxCharacters);
73
- if (parts.length <= 1)
74
- return port.synthesize({ ...request, model: model.id });
75
- const abort = new AbortController();
76
- const signal = AbortSignal.any([request.signal, abort.signal]);
77
- const iterator = audioParts(parts, { ...request, model: model.id, signal }, port.synthesize);
78
- return {
79
- container: "mp3",
80
- audio: new ReadableStream({
81
- async pull(controller) {
82
- try {
83
- const next = await iterator.next();
84
- if (next.done)
85
- controller.close();
86
- else
87
- controller.enqueue(next.value);
88
- }
89
- catch (error) {
90
- controller.error(error);
91
- }
92
- },
93
- async cancel() {
94
- abort.abort();
95
- await iterator.return();
96
- },
97
- }, { highWaterMark: 0 }),
98
- };
77
+ if (request.text.length > model.tts.maxCharacters)
78
+ throw providerError({
79
+ kind: "unsupported",
80
+ message: `${id}: this physical narration request exceeds the current ${model.tts.maxCharacters}-character limit. Rebuild narration to split it using the new limit.`,
81
+ });
82
+ return port.synthesize({ ...request, model: model.id });
99
83
  },
100
84
  };
101
85
  },
102
86
  };
103
87
  }
104
- async function* audioParts(parts, request, synthesize) {
105
- let tokens = {};
106
- const saved = request.continuation?.read();
107
- if (saved) {
108
- try {
109
- const parsed = JSON.parse(saved);
110
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
111
- tokens = Object.fromEntries(Object.entries(parsed).filter((entry) => typeof entry[1] === "string"));
112
- }
113
- catch {
114
- /* A legacy single-job token cannot identify several split jobs. */
115
- }
116
- }
117
- for (const [index, text] of parts.entries()) {
118
- request.signal.throwIfAborted();
119
- // Body chunks are persisted individually by narration. Long intro/outro text
120
- // is bounded here as well; each split has its own remote continuation.
121
- const response = await synthesize({
122
- ...request,
123
- text,
124
- continuation: {
125
- read: () => tokens[String(index)],
126
- write: (value) => {
127
- tokens[String(index)] = value;
128
- request.continuation?.write(JSON.stringify(tokens));
129
- },
130
- },
131
- });
132
- const reader = response.audio.getReader();
133
- try {
134
- for (;;) {
135
- const next = await reader.read();
136
- request.signal.throwIfAborted();
137
- if (next.done)
138
- break;
139
- yield next.value;
140
- }
141
- }
142
- finally {
143
- await reader.cancel().catch(() => { });
144
- reader.releaseLock();
145
- }
146
- }
147
- }
package/dist/edge/cli.js CHANGED
@@ -5,6 +5,7 @@ import { readVersion } from "../kernel/version.js";
5
5
  import { boot } from "../main.js";
6
6
  import { forwardManagedUpdate } from "../updater/forward.js";
7
7
  import { openBrowser } from "./open-browser.js";
8
+ import { installSignalShutdown } from "./signal-shutdown.js";
8
9
  const { values } = parseArgs({
9
10
  options: {
10
11
  port: { type: "string" },
@@ -31,8 +32,10 @@ try {
31
32
  console.warn(message);
32
33
  });
33
34
  }
34
- process.on("SIGINT", () => {
35
- stop().then(() => process.exit(0), () => process.exit(1));
35
+ installSignalShutdown({
36
+ on: (signal, listener) => process.on(signal, listener),
37
+ stop,
38
+ exit: (code) => process.exit(code),
36
39
  });
37
40
  }
38
41
  catch (error) {
@@ -52,7 +52,8 @@ export function createHub(deps) {
52
52
  });
53
53
  if (set.has(subscriber))
54
54
  for (const event of previews.snapshot(projectId))
55
- send(subscriber, event);
55
+ if (deps.acceptEvent?.(event) !== false)
56
+ send(subscriber, event);
56
57
  return subscriber.done;
57
58
  },
58
59
  subscribeGlobal: (stream, signal) => {
@@ -63,7 +64,11 @@ export function createHub(deps) {
63
64
  return subscriber.done;
64
65
  },
65
66
  emit: (projectId, event) => {
66
- previews.observe(event);
67
+ const accepted = deps.acceptEvent?.(event) !== false;
68
+ if (accepted || event.type === "stage.state")
69
+ previews.observe(event);
70
+ if (!accepted)
71
+ return;
67
72
  for (const subscriber of projects.get(projectId) ?? []) {
68
73
  send(subscriber, event);
69
74
  }
@@ -8,7 +8,9 @@ export function createPreviewCache() {
8
8
  const calls = projects.get(event.projectId);
9
9
  if (calls) {
10
10
  for (const [id, call] of calls)
11
- if (call.stage === event.stage)
11
+ if (call.stage === event.stage &&
12
+ call.revisionId === event.revisionId &&
13
+ call.workId === event.workId)
12
14
  calls.delete(id);
13
15
  if (calls.size === 0)
14
16
  projects.delete(event.projectId);
@@ -18,10 +20,11 @@ export function createPreviewCache() {
18
20
  if (event.type !== "llm.preview")
19
21
  return;
20
22
  const calls = projects.get(event.projectId) ?? new Map();
21
- const prior = calls.get(event.callId);
23
+ const identity = JSON.stringify([event.revisionId, event.workId, event.callId]);
24
+ const prior = calls.get(identity);
22
25
  const text = (event.reset ? event.text : `${prior?.text ?? ""}${event.text}`).slice(-64 * 1024);
23
- calls.delete(event.callId);
24
- calls.set(event.callId, { ...event, text, reset: true });
26
+ calls.delete(identity);
27
+ calls.set(identity, { ...event, text, reset: true });
25
28
  while (calls.size > 8) {
26
29
  const id = calls.keys().next().value;
27
30
  if (id !== undefined)
@@ -0,0 +1,33 @@
1
+ import { currentRevisionId } from "../../slices/revisions/repo.js";
2
+ export function currentWorkOrigin(db, projectId, origin) {
3
+ if (currentRevisionId(db, projectId) === undefined)
4
+ return origin.revisionId === undefined && origin.workId === undefined;
5
+ if (origin.revisionId === undefined || origin.workId === undefined)
6
+ return false;
7
+ return (db
8
+ .prepare(`
9
+ SELECT 1 FROM revision_work w
10
+ JOIN revision_work_reservations r ON r.work_id=w.id AND r.project_id=w.project_id
11
+ JOIN project_heads h ON h.project_id=r.project_id AND h.revision_id=r.revision_id
12
+ JOIN project_revisions v ON v.id=r.revision_id
13
+ JOIN json_each(v.fingerprints) f ON f.key=COALESCE(r.logical_key,r.work_key)
14
+ AND f.value=COALESCE(r.desired_fingerprint,r.fingerprint)
15
+ LEFT JOIN revision_work_pieces p ON p.id=r.piece_id AND p.work_id=w.id
16
+ WHERE w.project_id=? AND w.revision_id=? AND w.id=?
17
+ AND (? IS NULL OR r.piece_id=?)
18
+ AND (r.piece_id IS NULL OR (p.work_key=r.work_key AND p.fingerprint=r.fingerprint))
19
+ `)
20
+ .get(projectId, origin.revisionId, origin.workId, origin.workPieceId ?? null, origin.workPieceId ?? null) !== undefined);
21
+ }
22
+ export function currentProjectEvent(db, event) {
23
+ if (event.type === "project.updated")
24
+ return true;
25
+ if (event.type === "project.state" &&
26
+ event.revisionId === undefined &&
27
+ event.workId === undefined)
28
+ return true;
29
+ const head = currentRevisionId(db, event.projectId);
30
+ if (head !== event.revisionId)
31
+ return false;
32
+ return currentWorkOrigin(db, event.projectId, event);
33
+ }
@@ -5,17 +5,15 @@ import { stageKinds } from "../../kernel/pipeline.js";
5
5
  import { derive } from "../../kernel/runner/graph.js";
6
6
  import { projectById, projectPaused, stagesOf } from "../../slices/admission/repo.js";
7
7
  import { cancelProject } from "../../slices/cancel/index.js";
8
- import { changeProviders, pauseProject, resumeProject } from "../../slices/control/index.js";
9
- import { withProjectControl } from "../../slices/control/lock.js";
10
- import { providerChangesSchema } from "../../slices/control/providers.js";
11
- import { deleteImage, editArticle, regenerateImage, rerunStage, retryStage, } from "../../slices/reruns/index.js";
8
+ import { settleReleasedCheckpoints } from "../../slices/checkpoints/recovery.js";
9
+ import { pauseProject } from "../../slices/control/index.js";
10
+ import { revisionControlSchema, } from "../../slices/control/revision-control-schema.js";
11
+ import { adoptBaseline } from "../../slices/revisions/adopt.js";
12
+ import { currentRevisionId } from "../../slices/revisions/repo.js";
12
13
  import { allowsCustomModel } from "../../slices/settings/models.js";
13
14
  import { providerStatuses } from "../../slices/settings/readiness.js";
14
15
  import { outputsOf } from "../../slices/storage/repo.js";
15
16
  import { onInvalid, problem, titleOf } from "./problem.js";
16
- // The actions on the project header and on each stage: Cancel, Retry, Re-run, Save &
17
- // re-run, Regenerate one image, Delete one image. Every one changes rows and files through
18
- // a slice and then ticks the runner; the route itself decides nothing.
19
17
  const idParam = z.object({
20
18
  id: z
21
19
  .string()
@@ -31,35 +29,11 @@ const imageParam = idParam.extend({
31
29
  .max(64)
32
30
  .regex(/^[0-9A-Za-z_-]+$/),
33
31
  });
34
- // ceiling: half a megabyte of markdown, which is far past any narratable article and
35
- // keeps a paste from filling the process's memory. A longer one needs a streamed edit.
36
- const articleBody = z.object({ markdown: z.string().min(1).max(500_000) });
37
- const status = {
38
- "no-project": 404,
39
- "unknown-image": 404,
40
- "empty-article": 400,
41
- running: 409,
42
- "not-rerunnable": 409,
43
- "not-retryable": 409,
44
- "no-article": 409,
45
- "last-image": 409,
46
- };
47
- const details = {
48
- "no-project": "No project has that id.",
49
- "unknown-image": "This project has no image with that id.",
50
- "empty-article": "An article cannot be saved empty.",
51
- // The precondition on every re-run: no stage of the project is `running`.
52
- running: "This project is still running. Cancel it or wait for it to finish.",
53
- "not-rerunnable": "Only a stage that has finished, failed, or been canceled can be re-run.",
54
- "not-retryable": "Only a failed or canceled stage can be retried.",
55
- "no-article": "This project has no article to edit yet.",
56
- // At least one image always remains.
57
- "last-image": "At least one image must remain, so the last one cannot be deleted.",
58
- };
59
32
  // The return type is inferred so Hono keeps the route types the SPA's client is
60
33
  // generated from; see stagingRoutes.
61
34
  export function actionRoutes(deps) {
62
35
  const reruns = {
36
+ measureAudio: deps.measureAudio,
63
37
  db: deps.db,
64
38
  paths: deps.paths,
65
39
  ids: deps.ids,
@@ -71,11 +45,14 @@ export function actionRoutes(deps) {
71
45
  clock: deps.clock,
72
46
  log: deps.log,
73
47
  abort: (projectId) => deps.runner.abortProject(projectId),
48
+ hasInflight: deps.runner.hasInflight,
49
+ settleCheckpoints: (projectId) => settleReleasedCheckpoints(deps, projectId),
74
50
  emit: (projectId, event) => {
75
51
  deps.hub.emit(projectId, event);
76
52
  },
77
53
  };
78
54
  const control = {
55
+ catalogue: deps.catalogue,
79
56
  ...reruns,
80
57
  runner: deps.runner,
81
58
  emit: (projectId, event) => deps.hub.emit(projectId, event),
@@ -86,6 +63,7 @@ export function actionRoutes(deps) {
86
63
  const view = (projectId) => {
87
64
  const stages = stagesOf(deps.db, projectId);
88
65
  return {
66
+ revisionId: currentRevisionId(deps.db, projectId) ?? null,
89
67
  project: {
90
68
  ...projectById(deps.db, projectId),
91
69
  status: derive(stages, projectPaused(deps.db, projectId)),
@@ -94,21 +72,6 @@ export function actionRoutes(deps) {
94
72
  outputs: outputsOf(deps.db, projectId),
95
73
  };
96
74
  };
97
- // Every re-run action ends the same way: the rows are written, then the runner is asked
98
- // to look at the project. The cascade does the rest by itself.
99
- const started = (c, projectId, result) => {
100
- if (!result.ok) {
101
- return problem(c, {
102
- status: status[result.reason],
103
- title: titleOf(status[result.reason]),
104
- detail: details[result.reason],
105
- });
106
- }
107
- deps.hub.emit(projectId, { type: "project.updated", projectId });
108
- if (!projectPaused(deps.db, projectId))
109
- deps.runner.tick(projectId);
110
- return c.json({ ...view(projectId), redone: result.redone });
111
- };
112
75
  const controlled = (c, id, result) => {
113
76
  if (result.ok)
114
77
  return c.json(view(id));
@@ -118,6 +81,10 @@ export function actionRoutes(deps) {
118
81
  "not-editable": 409,
119
82
  "invalid-providers": 400,
120
83
  "catalog-unavailable": 503,
84
+ "revision-required": 409,
85
+ conflict: 409,
86
+ "idempotency-conflict": 409,
87
+ "rebuild-required": 409,
121
88
  };
122
89
  const messages = {
123
90
  "no-project": "No project has that id.",
@@ -125,56 +92,84 @@ export function actionRoutes(deps) {
125
92
  "not-editable": "Providers can be changed only while a project is paused or failed.",
126
93
  "invalid-providers": "The provider choices need attention.",
127
94
  "catalog-unavailable": "The provider model catalog could not be loaded. Try again.",
95
+ "revision-required": "Reload the project before using this control. A current revision and request ID are required.",
96
+ conflict: "The project changed. Reload it before using this control.",
97
+ "idempotency-conflict": "This request ID has already been used for a different action.",
98
+ "rebuild-required": "Review the affected outputs and cost before rebuilding this revision.",
128
99
  };
129
100
  return problem(c, {
130
101
  status: codes[result.reason],
131
102
  title: titleOf(codes[result.reason]),
132
103
  detail: messages[result.reason],
133
- ...(result.fields === undefined ? {} : { extensions: { fields: result.fields } }),
104
+ extensions: {
105
+ reason: result.reason,
106
+ currentRevisionId: currentRevisionId(deps.db, id) ?? null,
107
+ fields: result.fields ?? [],
108
+ },
134
109
  });
135
110
  };
111
+ const controlInput = async (c, id) => {
112
+ if (deps.catalogue !== undefined)
113
+ adoptBaseline(deps, id);
114
+ if (currentRevisionId(deps.db, id) === undefined)
115
+ return undefined;
116
+ let body;
117
+ try {
118
+ body = await c.req.json();
119
+ }
120
+ catch (error) {
121
+ if (!(error instanceof SyntaxError))
122
+ throw error;
123
+ return controlled(c, id, { ok: false, reason: "revision-required" });
124
+ }
125
+ const parsed = revisionControlSchema.safeParse(body);
126
+ return parsed.success
127
+ ? parsed.data
128
+ : controlled(c, id, { ok: false, reason: "revision-required" });
129
+ };
136
130
  return new Hono()
137
131
  .post("/:id/pause", zValidator("param", idParam, onInvalid), async (c) => {
138
132
  const { id } = c.req.valid("param");
139
- return controlled(c, id, await pauseProject(control, id));
140
- })
141
- .post("/:id/resume", zValidator("param", idParam, onInvalid), async (c) => {
142
- const { id } = c.req.valid("param");
143
- return controlled(c, id, await resumeProject(control, id));
133
+ const input = await controlInput(c, id);
134
+ return input instanceof Response
135
+ ? input
136
+ : controlled(c, id, await pauseProject(control, id, input));
144
137
  })
145
- .patch("/:id/providers", zValidator("param", idParam, onInvalid), zValidator("json", providerChangesSchema, onInvalid), async (c) => {
138
+ .post("/:id/resume", zValidator("param", idParam, onInvalid), (c) => {
146
139
  const { id } = c.req.valid("param");
147
- return controlled(c, id, await changeProviders(control, id, c.req.valid("json")));
140
+ return controlled(c, id, {
141
+ ok: false,
142
+ reason: projectById(deps.db, id) === undefined ? "no-project" : "rebuild-required",
143
+ });
148
144
  })
145
+ .patch("/:id/providers", zValidator("param", idParam, onInvalid), revisionRequired)
149
146
  .post("/:id/cancel", zValidator("param", idParam, onInvalid), async (c) => {
150
147
  const { id } = c.req.valid("param");
151
- return withProjectControl(deps.db, id, async () => {
152
- const result = await cancelProject(cancel, id);
153
- if (!result.ok) {
154
- return problem(c, { status: 404, title: titleOf(404), detail: details["no-project"] });
155
- }
156
- // No tick: a canceled project sits until the user retries a stage.
157
- return c.json({ ...view(id), canceled: result.canceled });
158
- });
148
+ const input = await controlInput(c, id);
149
+ if (input instanceof Response)
150
+ return input;
151
+ const result = await cancelProject(cancel, id, input);
152
+ if (!result.ok)
153
+ return controlled(c, id, result);
154
+ return c.json({ ...view(id), canceled: result.canceled });
159
155
  })
160
156
  .post("/:id/stages/:kind/retry", zValidator("param", stageParam, onInvalid), (c) => {
161
- const { id, kind } = c.req.valid("param");
162
- return withProjectControl(deps.db, id, () => started(c, id, retryStage(reruns, id, kind)));
163
- })
164
- .post("/:id/stages/:kind/rerun", zValidator("param", stageParam, onInvalid), (c) => {
165
- const { id, kind } = c.req.valid("param");
166
- return withProjectControl(deps.db, id, () => started(c, id, rerunStage(reruns, id, kind)));
167
- })
168
- .put("/:id/article", zValidator("param", idParam, onInvalid), zValidator("json", articleBody, onInvalid), (c) => {
169
157
  const { id } = c.req.valid("param");
170
- return withProjectControl(deps.db, id, () => started(c, id, editArticle(reruns, id, c.req.valid("json").markdown)));
171
- })
172
- .delete("/:id/images/:outputId", zValidator("param", imageParam, onInvalid), (c) => {
173
- const { id, outputId } = c.req.valid("param");
174
- return withProjectControl(deps.db, id, () => started(c, id, deleteImage(reruns, id, outputId)));
158
+ return controlled(c, id, {
159
+ ok: false,
160
+ reason: projectById(deps.db, id) === undefined ? "no-project" : "rebuild-required",
161
+ });
175
162
  })
176
- .post("/:id/images/:outputId/regenerate", zValidator("param", imageParam, onInvalid), (c) => {
177
- const { id, outputId } = c.req.valid("param");
178
- return withProjectControl(deps.db, id, () => started(c, id, regenerateImage(reruns, id, outputId)));
163
+ .post("/:id/stages/:kind/rerun", zValidator("param", stageParam, onInvalid), revisionRequired)
164
+ .put("/:id/article", zValidator("param", idParam, onInvalid), revisionRequired)
165
+ .delete("/:id/images/:outputId", zValidator("param", imageParam, onInvalid), revisionRequired)
166
+ .post("/:id/images/:outputId/regenerate", zValidator("param", imageParam, onInvalid), revisionRequired);
167
+ }
168
+ function revisionRequired(c) {
169
+ return problem(c, {
170
+ status: 409,
171
+ title: titleOf(409),
172
+ detail: "Open Edit project to save changes, then review the affected rebuild.",
173
+ extensions: { reason: "revision-required" },
179
174
  });
180
175
  }