@gentbajko/slopify 0.1.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/dist/adapter-registry.js +68 -0
  4. package/dist/adapters/image/bytes.js +64 -0
  5. package/dist/adapters/image/fal.js +164 -0
  6. package/dist/adapters/image/openai.js +155 -0
  7. package/dist/adapters/image/replicate.js +183 -0
  8. package/dist/adapters/llm/claude-code.js +158 -0
  9. package/dist/adapters/llm/codex.js +126 -0
  10. package/dist/adapters/llm/openrouter.js +193 -0
  11. package/dist/adapters/llm/run-cli.js +134 -0
  12. package/dist/adapters/llm/sse-lines.js +46 -0
  13. package/dist/adapters/retry-after.js +21 -0
  14. package/dist/adapters/tts/cartesia.js +105 -0
  15. package/dist/adapters/tts/elevenlabs.js +107 -0
  16. package/dist/adapters/tts/openai.js +95 -0
  17. package/dist/edge/cli.js +36 -0
  18. package/dist/edge/events/hub.js +76 -0
  19. package/dist/edge/http/actions.js +125 -0
  20. package/dist/edge/http/app.js +72 -0
  21. package/dist/edge/http/entries.js +41 -0
  22. package/dist/edge/http/files.js +67 -0
  23. package/dist/edge/http/problem.js +65 -0
  24. package/dist/edge/http/projects.js +128 -0
  25. package/dist/edge/http/prompts.js +73 -0
  26. package/dist/edge/http/providers.js +62 -0
  27. package/dist/edge/http/settings.js +90 -0
  28. package/dist/edge/http/staging.js +104 -0
  29. package/dist/edge/http/telemetry.js +29 -0
  30. package/dist/edge/http/usage.js +19 -0
  31. package/dist/edge/open-browser.js +20 -0
  32. package/dist/kernel/clock.js +18 -0
  33. package/dist/kernel/config/index.js +32 -0
  34. package/dist/kernel/db/index.js +24 -0
  35. package/dist/kernel/db/migrate.js +59 -0
  36. package/dist/kernel/db/migrations/0001-init.sql +17 -0
  37. package/dist/kernel/db/tx.js +53 -0
  38. package/dist/kernel/events.js +1 -0
  39. package/dist/kernel/ids.js +4 -0
  40. package/dist/kernel/lock.js +73 -0
  41. package/dist/kernel/log.js +34 -0
  42. package/dist/kernel/paths.js +21 -0
  43. package/dist/kernel/pipeline.js +22 -0
  44. package/dist/kernel/ports/image.js +1 -0
  45. package/dist/kernel/ports/llm.js +1 -0
  46. package/dist/kernel/ports/model.js +34 -0
  47. package/dist/kernel/ports/registry.js +1 -0
  48. package/dist/kernel/ports/tts.js +1 -0
  49. package/dist/kernel/runner/attempt-repo.js +50 -0
  50. package/dist/kernel/runner/attempt.js +146 -0
  51. package/dist/kernel/runner/graph.js +82 -0
  52. package/dist/kernel/runner/index.js +205 -0
  53. package/dist/kernel/runner/piece-repo.js +58 -0
  54. package/dist/kernel/runner/providers.js +92 -0
  55. package/dist/kernel/version.js +14 -0
  56. package/dist/main.js +212 -0
  57. package/dist/slices/admission/model.js +6 -0
  58. package/dist/slices/admission/repo.js +178 -0
  59. package/dist/slices/admission/rules.js +201 -0
  60. package/dist/slices/admission/start.js +103 -0
  61. package/dist/slices/admission/substitute.js +80 -0
  62. package/dist/slices/article/continuation.js +81 -0
  63. package/dist/slices/article/plain.js +14 -0
  64. package/dist/slices/article/run.js +166 -0
  65. package/dist/slices/article/split.js +51 -0
  66. package/dist/slices/article/store.js +21 -0
  67. package/dist/slices/cancel/index.js +61 -0
  68. package/dist/slices/images/run.js +221 -0
  69. package/dist/slices/library/lint.js +53 -0
  70. package/dist/slices/library/model.js +13 -0
  71. package/dist/slices/library/repo.js +111 -0
  72. package/dist/slices/library/save.js +87 -0
  73. package/dist/slices/library/slots.js +86 -0
  74. package/dist/slices/narration/chunk.js +71 -0
  75. package/dist/slices/narration/concat.js +79 -0
  76. package/dist/slices/narration/run.js +280 -0
  77. package/dist/slices/reruns/cascade.js +66 -0
  78. package/dist/slices/reruns/index.js +269 -0
  79. package/dist/slices/research/planner.js +81 -0
  80. package/dist/slices/research/run.js +192 -0
  81. package/dist/slices/research/synthesis.js +48 -0
  82. package/dist/slices/settings/cli-status.js +37 -0
  83. package/dist/slices/settings/keys.js +55 -0
  84. package/dist/slices/settings/model.js +54 -0
  85. package/dist/slices/settings/playback.js +60 -0
  86. package/dist/slices/settings/readiness.js +21 -0
  87. package/dist/slices/settings/repo.js +59 -0
  88. package/dist/slices/settings/voices.js +48 -0
  89. package/dist/slices/storage/asset-name.js +16 -0
  90. package/dist/slices/storage/delete-project.js +35 -0
  91. package/dist/slices/storage/downloads.js +100 -0
  92. package/dist/slices/storage/layout.js +59 -0
  93. package/dist/slices/storage/model.js +20 -0
  94. package/dist/slices/storage/reconcile.js +102 -0
  95. package/dist/slices/storage/repo.js +104 -0
  96. package/dist/slices/storage/staging.js +224 -0
  97. package/dist/slices/telemetry/collector-client.js +51 -0
  98. package/dist/slices/telemetry/flush.js +111 -0
  99. package/dist/slices/telemetry/machine.js +33 -0
  100. package/dist/slices/telemetry/model.js +39 -0
  101. package/dist/slices/telemetry/record.js +41 -0
  102. package/dist/slices/telemetry/repo.js +73 -0
  103. package/dist/slices/telemetry/usage.js +82 -0
  104. package/dist/slices/thumbnail/by-llm.js +30 -0
  105. package/dist/slices/thumbnail/run.js +200 -0
  106. package/dist/slices/video/ffmpeg.js +230 -0
  107. package/dist/slices/video/plan.js +71 -0
  108. package/dist/slices/video/run.js +179 -0
  109. package/dist/web/app-icon.svg +1 -0
  110. package/dist/web/assets/barlow-condensed-latin-600-normal-BFJEwTuo.woff +0 -0
  111. package/dist/web/assets/barlow-condensed-latin-600-normal-DepVgxBB.woff2 +0 -0
  112. package/dist/web/assets/barlow-condensed-latin-700-normal-Dmwat-ge.woff +0 -0
  113. package/dist/web/assets/barlow-condensed-latin-700-normal-v1xN8_Wq.woff2 +0 -0
  114. package/dist/web/assets/barlow-condensed-latin-ext-600-normal-18ESti3H.woff2 +0 -0
  115. package/dist/web/assets/barlow-condensed-latin-ext-600-normal-Clv9cIcR.woff +0 -0
  116. package/dist/web/assets/barlow-condensed-latin-ext-700-normal-BIHFfxf0.woff +0 -0
  117. package/dist/web/assets/barlow-condensed-latin-ext-700-normal-CwuXbfVR.woff2 +0 -0
  118. package/dist/web/assets/barlow-condensed-vietnamese-600-normal-A5AYRdjN.woff2 +0 -0
  119. package/dist/web/assets/barlow-condensed-vietnamese-600-normal-CNlPk46_.woff +0 -0
  120. package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DYeBwlKR.woff2 +0 -0
  121. package/dist/web/assets/barlow-condensed-vietnamese-700-normal-DhIzd8Tb.woff +0 -0
  122. package/dist/web/assets/barlow-latin-400-normal-fsAxiSwU.woff +0 -0
  123. package/dist/web/assets/barlow-latin-400-normal-qiz4-Cze.woff2 +0 -0
  124. package/dist/web/assets/barlow-latin-500-normal-BPAOfeC8.woff2 +0 -0
  125. package/dist/web/assets/barlow-latin-500-normal-C1h8hMer.woff +0 -0
  126. package/dist/web/assets/barlow-latin-600-normal-CNwfPWQD.woff +0 -0
  127. package/dist/web/assets/barlow-latin-600-normal-DILqtrty.woff2 +0 -0
  128. package/dist/web/assets/barlow-latin-700-normal-A9pxMQ4z.woff2 +0 -0
  129. package/dist/web/assets/barlow-latin-700-normal-__SGTsZ1.woff +0 -0
  130. package/dist/web/assets/barlow-latin-800-normal-BdVooDN4.woff +0 -0
  131. package/dist/web/assets/barlow-latin-800-normal-s1sAMnoV.woff2 +0 -0
  132. package/dist/web/assets/barlow-latin-ext-400-normal-CvBsJvxq.woff +0 -0
  133. package/dist/web/assets/barlow-latin-ext-400-normal-HxX4XjxC.woff2 +0 -0
  134. package/dist/web/assets/barlow-latin-ext-500-normal-CJPcKP2Q.woff +0 -0
  135. package/dist/web/assets/barlow-latin-ext-500-normal-DOaysfXq.woff2 +0 -0
  136. package/dist/web/assets/barlow-latin-ext-600-normal-B8NK_A3D.woff2 +0 -0
  137. package/dist/web/assets/barlow-latin-ext-600-normal-DMVRjfRT.woff +0 -0
  138. package/dist/web/assets/barlow-latin-ext-700-normal-BLuWmldJ.woff2 +0 -0
  139. package/dist/web/assets/barlow-latin-ext-700-normal-CctuGmmz.woff +0 -0
  140. package/dist/web/assets/barlow-latin-ext-800-normal-BiucknKG.woff2 +0 -0
  141. package/dist/web/assets/barlow-latin-ext-800-normal-D7I3yvUw.woff +0 -0
  142. package/dist/web/assets/barlow-vietnamese-400-normal-BFeobeCK.woff +0 -0
  143. package/dist/web/assets/barlow-vietnamese-400-normal-Dpl4UHAZ.woff2 +0 -0
  144. package/dist/web/assets/barlow-vietnamese-500-normal-GNfB7rCE.woff +0 -0
  145. package/dist/web/assets/barlow-vietnamese-500-normal-zTViEIzf.woff2 +0 -0
  146. package/dist/web/assets/barlow-vietnamese-600-normal-CA_GiK2e.woff +0 -0
  147. package/dist/web/assets/barlow-vietnamese-600-normal-DcjprdFV.woff2 +0 -0
  148. package/dist/web/assets/barlow-vietnamese-700-normal-4Jt4k04K.woff +0 -0
  149. package/dist/web/assets/barlow-vietnamese-700-normal-D6euyNzi.woff2 +0 -0
  150. package/dist/web/assets/barlow-vietnamese-800-normal-Cl1Mc_Dv.woff2 +0 -0
  151. package/dist/web/assets/barlow-vietnamese-800-normal-D0VWpbij.woff +0 -0
  152. package/dist/web/assets/index-BPqQnrpy.css +1 -0
  153. package/dist/web/assets/index-D_sWbKQi.js +81 -0
  154. package/dist/web/favicon.svg +1 -0
  155. package/dist/web/index.html +15 -0
  156. package/package.json +41 -0
@@ -0,0 +1,125 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { stageKinds } from "../../kernel/pipeline.js";
5
+ import { derive } from "../../kernel/runner/graph.js";
6
+ import { projectById, stagesOf } from "../../slices/admission/repo.js";
7
+ import { cancelProject } from "../../slices/cancel/index.js";
8
+ import { deleteImage, editArticle, regenerateImage, rerunStage, retryStage, } from "../../slices/reruns/index.js";
9
+ import { outputsOf } from "../../slices/storage/repo.js";
10
+ import { onInvalid, problem, titleOf } from "./problem.js";
11
+ // The actions `mockup/08-project.md` puts on the project header and on each stage: Cancel,
12
+ // Retry, Re-run, Save & re-run, Regenerate one image, Delete one image. Every one of them
13
+ // changes rows and files through a slice and then ticks the runner; the route itself
14
+ // decides nothing (`logic/12`, `logic/13`).
15
+ const idParam = z.object({
16
+ id: z
17
+ .string()
18
+ .min(1)
19
+ .max(64)
20
+ .regex(/^[0-9A-Za-z_-]+$/),
21
+ });
22
+ const stageParam = idParam.extend({ kind: z.enum(stageKinds) });
23
+ const imageParam = idParam.extend({
24
+ outputId: z
25
+ .string()
26
+ .min(1)
27
+ .max(64)
28
+ .regex(/^[0-9A-Za-z_-]+$/),
29
+ });
30
+ // ceiling: half a megabyte of markdown, which is far past any narratable article and
31
+ // keeps a paste from filling the process's memory. A longer one needs a streamed edit.
32
+ const articleBody = z.object({ markdown: z.string().min(1).max(500_000) });
33
+ const status = {
34
+ "no-project": 404,
35
+ "unknown-image": 404,
36
+ "empty-article": 400,
37
+ running: 409,
38
+ "not-rerunnable": 409,
39
+ "not-retryable": 409,
40
+ "no-article": 409,
41
+ "last-image": 409,
42
+ };
43
+ const details = {
44
+ "no-project": "No project has that id.",
45
+ "unknown-image": "This project has no image with that id.",
46
+ "empty-article": "An article cannot be saved empty.",
47
+ // logic/12 preconditions: "no stage of the project is `running` (§Q106)".
48
+ running: "This project is still running. Cancel it or wait for it to finish.",
49
+ "not-rerunnable": "Only a stage that has finished, failed, or been canceled can be re-run.",
50
+ "not-retryable": "Only a failed or canceled stage can be retried.",
51
+ "no-article": "This project has no article to edit yet.",
52
+ // logic/09 §Q75 and logic/12's invariant: "at least one image always remains".
53
+ "last-image": "At least one image must remain, so the last one cannot be deleted.",
54
+ };
55
+ // The return type is inferred so Hono keeps the route types the SPA's client is
56
+ // generated from; see stagingRoutes.
57
+ export function actionRoutes(deps) {
58
+ const reruns = {
59
+ db: deps.db,
60
+ paths: deps.paths,
61
+ ids: deps.ids,
62
+ clock: deps.clock,
63
+ log: deps.log,
64
+ };
65
+ const cancel = {
66
+ db: deps.db,
67
+ clock: deps.clock,
68
+ log: deps.log,
69
+ abort: (projectId) => deps.runner.abortProject(projectId),
70
+ emit: (projectId, event) => {
71
+ deps.hub.emit(projectId, event);
72
+ },
73
+ };
74
+ const view = (projectId) => {
75
+ const stages = stagesOf(deps.db, projectId);
76
+ return {
77
+ project: { ...projectById(deps.db, projectId), status: derive(stages) },
78
+ stages,
79
+ outputs: outputsOf(deps.db, projectId),
80
+ };
81
+ };
82
+ // Every re-run action ends the same way: the rows are written, then the runner is
83
+ // asked to look at the project. `logic/12` step 9 does the rest by itself.
84
+ const started = (c, projectId, result) => {
85
+ if (!result.ok) {
86
+ return problem(c, {
87
+ status: status[result.reason],
88
+ title: titleOf(status[result.reason]),
89
+ detail: details[result.reason],
90
+ });
91
+ }
92
+ deps.runner.tick(projectId);
93
+ return c.json({ ...view(projectId), redone: result.redone });
94
+ };
95
+ return new Hono()
96
+ .post("/:id/cancel", zValidator("param", idParam, onInvalid), async (c) => {
97
+ const { id } = c.req.valid("param");
98
+ const result = await cancelProject(cancel, id);
99
+ if (!result.ok) {
100
+ return problem(c, { status: 404, title: titleOf(404), detail: details["no-project"] });
101
+ }
102
+ // No tick: §Q111 leaves a canceled project sitting until the user retries a stage.
103
+ return c.json({ ...view(id), canceled: result.canceled });
104
+ })
105
+ .post("/:id/stages/:kind/retry", zValidator("param", stageParam, onInvalid), (c) => {
106
+ const { id, kind } = c.req.valid("param");
107
+ return started(c, id, retryStage(reruns, id, kind));
108
+ })
109
+ .post("/:id/stages/:kind/rerun", zValidator("param", stageParam, onInvalid), (c) => {
110
+ const { id, kind } = c.req.valid("param");
111
+ return started(c, id, rerunStage(reruns, id, kind));
112
+ })
113
+ .put("/:id/article", zValidator("param", idParam, onInvalid), zValidator("json", articleBody, onInvalid), (c) => {
114
+ const { id } = c.req.valid("param");
115
+ return started(c, id, editArticle(reruns, id, c.req.valid("json").markdown));
116
+ })
117
+ .delete("/:id/images/:outputId", zValidator("param", imageParam, onInvalid), (c) => {
118
+ const { id, outputId } = c.req.valid("param");
119
+ return started(c, id, deleteImage(reruns, id, outputId));
120
+ })
121
+ .post("/:id/images/:outputId/regenerate", zValidator("param", imageParam, onInvalid), (c) => {
122
+ const { id, outputId } = c.req.valid("param");
123
+ return started(c, id, regenerateImage(reruns, id, outputId));
124
+ });
125
+ }
@@ -0,0 +1,72 @@
1
+ import { existsSync } from "node:fs";
2
+ import { serveStatic } from "@hono/node-server/serve-static";
3
+ import { Hono } from "hono";
4
+ import { streamSSE } from "hono/streaming";
5
+ import { actionRoutes } from "./actions.js";
6
+ import { entryRoutes } from "./entries.js";
7
+ import { fileRoutes } from "./files.js";
8
+ import { problem, problemFromError, titleOf } from "./problem.js";
9
+ import { projectRoutes } from "./projects.js";
10
+ import { promptRoutes } from "./prompts.js";
11
+ import { providerRoutes } from "./providers.js";
12
+ import { settingsRoutes } from "./settings.js";
13
+ import { stagingRoutes } from "./staging.js";
14
+ import { telemetryRoutes } from "./telemetry.js";
15
+ import { usageRoutes } from "./usage.js";
16
+ const notBuilt = "The Slopify UI is not built yet. The API is running: try /api/health.\n" +
17
+ "Build the SPA with `npm run build` at the repository root.\n";
18
+ function apiRoutes(deps, startedAt) {
19
+ return (new Hono()
20
+ .get("/health", (c) => c.json({
21
+ status: "ok",
22
+ version: deps.version,
23
+ uptimeMs: deps.clock.now().getTime() - startedAt,
24
+ }))
25
+ .route("/staging", stagingRoutes(deps))
26
+ .route("/projects", projectRoutes(deps))
27
+ // The actions of `logic/12` and `logic/13` sit on the same prefix as the project
28
+ // itself; they are their own router because they are their own concern.
29
+ .route("/projects", actionRoutes(deps))
30
+ .route("/prompts", promptRoutes(deps))
31
+ .route("/entries", entryRoutes(deps))
32
+ .route("/telemetry", telemetryRoutes(deps))
33
+ .route("/usage", usageRoutes(deps))
34
+ .route("/settings", settingsRoutes(deps))
35
+ .route("/providers", providerRoutes(deps)));
36
+ }
37
+ export function createApp(deps) {
38
+ const startedAt = deps.clock.now().getTime();
39
+ const app = new Hono()
40
+ .use("*", async (c, next) => {
41
+ c.header("X-Slopify-Version", deps.version);
42
+ await next();
43
+ })
44
+ .onError((error, c) => problemFromError(c, error, deps))
45
+ .notFound(missing)
46
+ .route("/api", apiRoutes(deps, startedAt))
47
+ .get("/api/events/global", (c) => streamSSE(c, (stream) => deps.hub.subscribeGlobal(stream, c.req.raw.signal)))
48
+ .get("/api/events/projects/:id", (c) => streamSSE(c, (stream) => deps.hub.subscribe(c.req.param("id"), stream, c.req.raw.signal)))
49
+ // Files are served by URL, not through the API (02-models Boundaries).
50
+ .route("/", fileRoutes(deps))
51
+ // The API answers for its whole prefix, so an unknown endpoint is a problem+json 404
52
+ // rather than the SPA's index.html with a 200.
53
+ .all("/api/*", missing);
54
+ // ceiling: the SPA directory is looked up once, at boot. The build that fills it runs
55
+ // before the server starts; a server started before its own build needs a restart.
56
+ if (existsSync(deps.webDist)) {
57
+ app.use("/*", serveStatic({ root: deps.webDist }));
58
+ // Client-side routes are not files: anything the SPA owns falls back to its shell.
59
+ app.get("/*", serveStatic({ root: deps.webDist, path: "index.html" }));
60
+ }
61
+ else {
62
+ app.get("/*", (c) => c.text(notBuilt));
63
+ }
64
+ return app;
65
+ }
66
+ function missing(c) {
67
+ return problem(c, {
68
+ status: 404,
69
+ title: titleOf(404),
70
+ detail: `${c.req.method} ${c.req.path} is not a route of this API.`,
71
+ });
72
+ }
@@ -0,0 +1,41 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { entryCategories, entryModes } from "../../slices/library/model.js";
5
+ import { listEntries } from "../../slices/library/repo.js";
6
+ import { createEntry, removeEntry, updateEntry } from "../../slices/library/save.js";
7
+ import { onInvalid } from "./problem.js";
8
+ // `logic/15` §Q121: one rule set for prompts and entries, so one refusal mapping too.
9
+ import { refused } from "./prompts.js";
10
+ const idParam = z.object({
11
+ id: z
12
+ .string()
13
+ .min(1)
14
+ .max(64)
15
+ .regex(/^[0-9A-Za-z_-]+$/),
16
+ });
17
+ const entryBody = z.object({
18
+ category: z.enum(entryCategories),
19
+ mode: z.enum(entryModes),
20
+ name: z.string(),
21
+ body: z.string(),
22
+ });
23
+ // The return type is inferred so Hono keeps the route types the SPA's client is
24
+ // generated from; see stagingRoutes.
25
+ export function entryRoutes(deps) {
26
+ const library = { db: deps.db, ids: deps.ids, clock: deps.clock };
27
+ return new Hono()
28
+ .get("/", (c) => c.json({ entries: listEntries(deps.db) }))
29
+ .post("/", zValidator("json", entryBody, onInvalid), (c) => {
30
+ const result = createEntry(library, c.req.valid("json"));
31
+ return result.ok ? c.json(result.value, 201) : refused(c, result, "entry");
32
+ })
33
+ .put("/:id", zValidator("param", idParam, onInvalid), zValidator("json", entryBody, onInvalid), (c) => {
34
+ const result = updateEntry(library, c.req.valid("param").id, c.req.valid("json"));
35
+ return result.ok ? c.json(result.value) : refused(c, result, "entry");
36
+ })
37
+ .delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
38
+ const result = removeEntry(library, c.req.valid("param").id);
39
+ return result.ok ? c.body(null, 204) : refused(c, result, "entry");
40
+ });
41
+ }
@@ -0,0 +1,67 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { Readable } from "node:stream";
3
+ import { zValidator } from "@hono/zod-validator";
4
+ import { Hono } from "hono";
5
+ import { z } from "zod";
6
+ import { findDownload, imagesZip } from "../../slices/storage/downloads.js";
7
+ import { onInvalid, problem, titleOf } from "./problem.js";
8
+ const projectParam = z.object({
9
+ projectId: z
10
+ .string()
11
+ .min(1)
12
+ .max(64)
13
+ .regex(/^[0-9A-Za-z_-]+$/),
14
+ });
15
+ const assetParam = projectParam.extend({
16
+ asset: z
17
+ .string()
18
+ .min(1)
19
+ .max(64)
20
+ .regex(/^[a-z0-9-]+$/),
21
+ });
22
+ // The return type is inferred so Hono keeps the route types; see stagingRoutes.
23
+ export function fileRoutes(deps) {
24
+ const storage = { db: deps.db, paths: deps.paths };
25
+ return (new Hono()
26
+ // Declared before :asset so the zip is never read as an asset name.
27
+ .get("/files/:projectId/images.zip", zValidator("param", projectParam, onInvalid), (c) => {
28
+ const result = imagesZip(storage, c.req.valid("param").projectId);
29
+ if (!result.ok) {
30
+ return missing(c, result.reason);
31
+ }
32
+ return c.body(result.bytes, 200, {
33
+ "content-type": "application/zip",
34
+ "content-length": String(result.bytes.byteLength),
35
+ "content-disposition": disposition(result.filename),
36
+ });
37
+ })
38
+ .get("/files/:projectId/:asset", zValidator("param", assetParam, onInvalid), (c) => {
39
+ const { projectId, asset } = c.req.valid("param");
40
+ const result = findDownload(storage, projectId, asset);
41
+ if (!result.ok) {
42
+ return missing(c, result.reason);
43
+ }
44
+ const { download } = result;
45
+ // ceiling: whole-file responses only. Seeking inside the finished video would
46
+ // need a Range handler answering 206 with the requested slice.
47
+ return c.body(Readable.toWeb(createReadStream(download.path)), 200, {
48
+ "content-type": download.contentType,
49
+ "content-length": String(download.bytes),
50
+ "content-disposition": disposition(download.filename),
51
+ });
52
+ }));
53
+ }
54
+ // A download name is built from a slug, a role, and an extension, so it is always
55
+ // `[a-z0-9.-]` and needs neither quoting nor the RFC 5987 encoded form.
56
+ function disposition(filename) {
57
+ return `attachment; filename="${filename}"`;
58
+ }
59
+ function missing(c, reason) {
60
+ const details = {
61
+ "unknown-project": "No project has that id.",
62
+ "unknown-asset": "This project has no such file.",
63
+ "missing-file": "This file is recorded but is no longer on disk; re-run the stage.",
64
+ "no-images": "This project has no images or thumbnail yet.",
65
+ };
66
+ return problem(c, { status: 404, title: titleOf(404), detail: details[reason] });
67
+ }
@@ -0,0 +1,65 @@
1
+ import { HTTPException } from "hono/http-exception";
2
+ const titles = {
3
+ 400: "Bad Request",
4
+ 404: "Not Found",
5
+ 409: "Conflict",
6
+ 415: "Unsupported Media Type",
7
+ 500: "Internal Server Error",
8
+ };
9
+ export function problem(c, init) {
10
+ const body = {
11
+ // Extensions go first so a route cannot shadow a member RFC 9457 reserves.
12
+ ...init.extensions,
13
+ type: init.type ?? "about:blank",
14
+ title: init.title,
15
+ status: init.status,
16
+ ...(init.detail === undefined ? {} : { detail: init.detail }),
17
+ instance: c.req.path,
18
+ };
19
+ return c.body(JSON.stringify(body), init.status, {
20
+ "content-type": "application/problem+json",
21
+ });
22
+ }
23
+ export function problemFromError(c, error, deps) {
24
+ if (error instanceof HTTPException) {
25
+ return problem(c, {
26
+ status: error.status,
27
+ title: titleOf(error.status),
28
+ detail: error.message === "" ? undefined : error.message,
29
+ });
30
+ }
31
+ // An unexpected exception can carry a provider's response text, and that text can
32
+ // carry the key that was sent. Only the correlation id crosses to the client; the
33
+ // message goes to the log, which redacts key-shaped strings.
34
+ const correlationId = deps.ids.next();
35
+ deps.log.write("error", "http.error", {
36
+ detail: `${correlationId} ${c.req.method} ${c.req.path}: ${error.message}`,
37
+ });
38
+ return problem(c, {
39
+ status: 500,
40
+ title: titleOf(500),
41
+ detail: `The server failed to handle this request. Correlation id ${correlationId} is in the log.`,
42
+ extensions: { correlationId },
43
+ });
44
+ }
45
+ export function titleOf(status) {
46
+ return titles[status] ?? "Error";
47
+ }
48
+ // The hook shape @hono/zod-validator calls, described structurally so this module
49
+ // carries none of the validator's or the schema's generics.
50
+ export function onInvalid(result, c) {
51
+ if (result.success) {
52
+ return undefined;
53
+ }
54
+ return problem(c, {
55
+ status: 400,
56
+ title: titleOf(400),
57
+ detail: "The request does not match this endpoint's schema.",
58
+ extensions: {
59
+ errors: result.error.issues.map((issue) => ({
60
+ path: issue.path.join("."),
61
+ message: issue.message,
62
+ })),
63
+ },
64
+ });
65
+ }
@@ -0,0 +1,128 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { derive, progressOf } from "../../kernel/runner/graph.js";
5
+ import { listProjects, projectById, runDraftSchema, stageStandingsByProject, stagesOf, } from "../../slices/admission/repo.js";
6
+ import { admit } from "../../slices/admission/rules.js";
7
+ import { startRun } from "../../slices/admission/start.js";
8
+ import { pickTemplates, renderPicked } from "../../slices/library/slots.js";
9
+ import { deleteProject } from "../../slices/storage/delete-project.js";
10
+ import { outputsOf, stagedFiles } from "../../slices/storage/repo.js";
11
+ import { record } from "../../slices/telemetry/record.js";
12
+ import { onInvalid, problem, titleOf } from "./problem.js";
13
+ const idParam = z.object({
14
+ id: z
15
+ .string()
16
+ .min(1)
17
+ .max(64)
18
+ .regex(/^[0-9A-Za-z_-]+$/),
19
+ });
20
+ // The return type is inferred so Hono keeps the route types the SPA's client is
21
+ // generated from; see stagingRoutes.
22
+ export function projectRoutes(deps) {
23
+ const storage = {
24
+ db: deps.db,
25
+ paths: deps.paths,
26
+ ids: deps.ids,
27
+ clock: deps.clock,
28
+ log: deps.log,
29
+ emit: (event) => {
30
+ deps.hub.emitGlobal(event);
31
+ },
32
+ };
33
+ const telemetry = {
34
+ db: deps.db,
35
+ ids: deps.ids,
36
+ clock: deps.clock,
37
+ log: deps.log,
38
+ appVersion: deps.version,
39
+ };
40
+ const storageForDelete = { db: deps.db, paths: deps.paths, log: deps.log };
41
+ const summarise = (project) => ({
42
+ ...project,
43
+ status: derive(stagesOf(deps.db, project.id)),
44
+ });
45
+ return (new Hono()
46
+ .post("/", zValidator("json", runDraftSchema, onInvalid), (c) => {
47
+ // logic/04 §Q34: the bodies are read here, at the click, so an edit made since the
48
+ // prompt was selected is the one that runs.
49
+ const picked = pickTemplates(deps.db, c.req.valid("json"));
50
+ const admitted = admit({
51
+ draft: picked.draft,
52
+ staged: stagedFiles(deps.db),
53
+ requiredSlots: picked.requiredSlots,
54
+ });
55
+ if (!admitted.ok || picked.missing.length > 0) {
56
+ return problem(c, {
57
+ status: 400,
58
+ title: titleOf(400),
59
+ detail: "This run cannot start yet; the listed fields need attention.",
60
+ extensions: {
61
+ fields: [...picked.missing, ...(admitted.ok ? [] : admitted.fields)],
62
+ },
63
+ });
64
+ }
65
+ // Rendered from the values admit() has trimmed, so the stored text carries no
66
+ // padding the user did not intend (logic/03 step 4 and step 5).
67
+ const { project } = startRun(storage, admitted.draft, renderPicked(picked, admitted.draft.values));
68
+ // logic/16 step 2: one event per project created. record() swallows its own
69
+ // failures, so a broken telemetry write cannot cost the user the run.
70
+ record(telemetry, "project.created", {});
71
+ deps.flushSoon();
72
+ // logic/04 step 6: the run starts only once the project is committed.
73
+ deps.runner.tick(project.id);
74
+ // Read back after the tick, not from the rows startRun built: the runner has
75
+ // already claimed every eligible stage, and a body that paired status "running"
76
+ // with "video: pending" would contradict itself.
77
+ return c.json({ project: summarise(project), stages: stagesOf(deps.db, project.id) }, 201);
78
+ })
79
+ // One statement for every project's stage standings, not one per row: the list needs
80
+ // a status word and a meter, and both come out of the same five columns.
81
+ .get("/", (c) => {
82
+ const standings = stageStandingsByProject(deps.db);
83
+ const projects = listProjects(deps.db).map((project) => {
84
+ const stages = standings.get(project.id) ?? [];
85
+ return { ...project, status: derive(stages), progress: progressOf(stages) };
86
+ });
87
+ return c.json({ projects });
88
+ })
89
+ .get("/:id", zValidator("param", idParam, onInvalid), (c) => {
90
+ const project = projectById(deps.db, c.req.valid("param").id);
91
+ if (project === undefined) {
92
+ return problem(c, {
93
+ status: 404,
94
+ title: titleOf(404),
95
+ detail: "No project has that id.",
96
+ });
97
+ }
98
+ return c.json({
99
+ project: summarise(project),
100
+ stages: stagesOf(deps.db, project.id),
101
+ outputs: outputsOf(deps.db, project.id),
102
+ });
103
+ })
104
+ // `logic/14` step 4. Irreversible, and only from the app: 07 Projects puts the
105
+ // confirmation dialog in front of it.
106
+ .delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
107
+ const result = deleteProject(storageForDelete, c.req.valid("param").id);
108
+ if (result.ok) {
109
+ return c.body(null, 204);
110
+ }
111
+ return problem(c, {
112
+ status: deleteStatus[result.reason],
113
+ title: titleOf(deleteStatus[result.reason]),
114
+ detail: result.detail ?? deleteDetails[result.reason],
115
+ });
116
+ }));
117
+ }
118
+ const deleteStatus = {
119
+ "no-project": 404,
120
+ running: 409,
121
+ files: 500,
122
+ };
123
+ const deleteDetails = {
124
+ "no-project": "No project has that id.",
125
+ // §Q117: the run has to be stopped before its files can go.
126
+ running: "This project is still running. Cancel the run first.",
127
+ files: "Some of this project's files could not be removed.",
128
+ };
@@ -0,0 +1,73 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { promptKinds } from "../../slices/library/model.js";
5
+ import { listPrompts } from "../../slices/library/repo.js";
6
+ import { createPrompt, removePrompt, updatePrompt } from "../../slices/library/save.js";
7
+ import { onInvalid, problem, titleOf } from "./problem.js";
8
+ const idParam = z.object({
9
+ id: z
10
+ .string()
11
+ .min(1)
12
+ .max(64)
13
+ .regex(/^[0-9A-Za-z_-]+$/),
14
+ });
15
+ // Shape only. The rules - trimming, emptiness, length, the slot lint - are the slice's,
16
+ // so one set of messages reaches the editor (03-conventions).
17
+ const promptBody = z.object({
18
+ kind: z.enum(promptKinds),
19
+ name: z.string(),
20
+ body: z.string(),
21
+ });
22
+ // The return type is inferred so Hono keeps the route types the SPA's client is
23
+ // generated from; see stagingRoutes.
24
+ export function promptRoutes(deps) {
25
+ const library = { db: deps.db, ids: deps.ids, clock: deps.clock };
26
+ return (new Hono()
27
+ // Every kind in one list: 04 Prompts filters by tab, and Duplicate needs the body it
28
+ // is copying (`logic/15` step 3).
29
+ .get("/", (c) => c.json({ prompts: listPrompts(deps.db) }))
30
+ .post("/", zValidator("json", promptBody, onInvalid), (c) => {
31
+ const result = createPrompt(library, c.req.valid("json"));
32
+ return result.ok ? c.json(result.value, 201) : refused(c, result, "prompt");
33
+ })
34
+ .put("/:id", zValidator("param", idParam, onInvalid), zValidator("json", promptBody, onInvalid), (c) => {
35
+ const result = updatePrompt(library, c.req.valid("param").id, c.req.valid("json"));
36
+ return result.ok ? c.json(result.value) : refused(c, result, "prompt");
37
+ })
38
+ // `logic/15` §Q123: a project holds its own rendered text, so nothing cascades and a
39
+ // template used by past projects is deleted like any other.
40
+ .delete("/:id", zValidator("param", idParam, onInvalid), (c) => {
41
+ const result = removePrompt(library, c.req.valid("param").id);
42
+ return result.ok ? c.body(null, 204) : refused(c, result, "prompt");
43
+ }));
44
+ }
45
+ // One refusal mapping for both libraries: `logic/15` §Q121 puts one rule set over
46
+ // prompts and entries, so entryRoutes shares this rather than mirroring it.
47
+ export function refused(c, failure, noun) {
48
+ switch (failure.reason) {
49
+ case "invalid":
50
+ return problem(c, {
51
+ status: 400,
52
+ title: titleOf(400),
53
+ detail: `This ${noun} cannot be saved; the listed fields need attention.`,
54
+ extensions: { fields: failure.fields },
55
+ });
56
+ // §Q122: the name is refused against a row that exists, and the form marks the field.
57
+ case "duplicate-name":
58
+ return problem(c, {
59
+ status: 409,
60
+ title: titleOf(409),
61
+ detail: `Another ${noun} already has this name.`,
62
+ extensions: {
63
+ fields: [{ field: "name", message: `Another ${noun} already has this name.` }],
64
+ },
65
+ });
66
+ case "not-found":
67
+ return problem(c, {
68
+ status: 404,
69
+ title: titleOf(404),
70
+ detail: `No ${noun} has that id.`,
71
+ });
72
+ }
73
+ }
@@ -0,0 +1,62 @@
1
+ import { zValidator } from "@hono/zod-validator";
2
+ import { Hono } from "hono";
3
+ import { z } from "zod";
4
+ import { keyStatus, removeProviderKey, saveProviderKey } from "../../slices/settings/keys.js";
5
+ import { providerById, providerIds } from "../../slices/settings/model.js";
6
+ import { providerStatuses } from "../../slices/settings/readiness.js";
7
+ import { onInvalid, problem, titleOf } from "./problem.js";
8
+ const providerParam = z.object({ id: z.enum(providerIds) });
9
+ // ceiling: `logic/02` §Q11 forbids any format check, so the only thing said about the
10
+ // value is that it is a string of a length a key could plausibly have. Raise the bound
11
+ // if a provider ever issues something longer.
12
+ const keyBody = z.object({ key: z.string().min(1).max(4096) });
13
+ // The return type is inferred so Hono keeps the route types the SPA's client is
14
+ // generated from; see stagingRoutes.
15
+ export function providerRoutes(deps) {
16
+ const keys = { db: deps.db, clock: deps.clock };
17
+ const readiness = { db: deps.db, probe: deps.probe };
18
+ return (new Hono()
19
+ // What Settings draws its rails from and Play its dropdowns: every provider, with
20
+ // the one fact that decides whether it is selectable (`logic/02` step 5, §Q135).
21
+ .get("/", async (c) => c.json({ providers: await providerStatuses(readiness) }))
22
+ .put("/:id/key", zValidator("param", providerParam, onInvalid), zValidator("json", keyBody, onInvalid), (c) => {
23
+ const { id } = c.req.valid("param");
24
+ const result = saveProviderKey(keys, id, c.req.valid("json").key);
25
+ if (!result.ok) {
26
+ return refusal(c, id, result.reason);
27
+ }
28
+ // The response says a key is stored and shows the mask, never the value.
29
+ return c.json(keyStatus(keys, id));
30
+ })
31
+ .delete("/:id/key", zValidator("param", providerParam, onInvalid), (c) => {
32
+ const { id } = c.req.valid("param");
33
+ const result = removeProviderKey(keys, id);
34
+ if (!result.ok) {
35
+ return refusal(c, id, result.reason);
36
+ }
37
+ return c.body(null, 204);
38
+ }));
39
+ }
40
+ function refusal(c, id, reason) {
41
+ const name = providerById(id).displayName;
42
+ if (reason === "cli-provider") {
43
+ return problem(c, {
44
+ status: 400,
45
+ title: titleOf(400),
46
+ detail: `${name} signs in through its own CLI, so there is no key to store here.`,
47
+ });
48
+ }
49
+ if (reason === "absent") {
50
+ return problem(c, {
51
+ status: 404,
52
+ title: titleOf(404),
53
+ detail: `No key is stored for ${name}.`,
54
+ });
55
+ }
56
+ return problem(c, {
57
+ status: 400,
58
+ title: titleOf(400),
59
+ detail: "This key cannot be saved; the listed fields need attention.",
60
+ extensions: { fields: [{ field: "key", message: "An API key is required." }] },
61
+ });
62
+ }