@vfrey/vfrey 0.0.1 → 1.0.0-beta.1

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 (610) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +78 -3
  3. package/accounts.example.json +61 -0
  4. package/accounts.first-run.json +41 -0
  5. package/bin/vfrey.mjs +36 -0
  6. package/integration-tools/README.md +43 -0
  7. package/integration-tools/image/pillow.md +32 -0
  8. package/integration-tools/image/playwright.md +33 -0
  9. package/integration-tools/image/resvg.md +36 -0
  10. package/integration-tools/image/satori.md +36 -0
  11. package/integration-tools/image/sharp.md +38 -0
  12. package/integration-tools/video/blender.md +34 -0
  13. package/integration-tools/video/ffmpeg.md +41 -0
  14. package/integration-tools/video/manim.md +37 -0
  15. package/integration-tools/video/motion-canvas.md +37 -0
  16. package/integration-tools/video/remotion.md +50 -0
  17. package/integration-tools/video/revideo.md +32 -0
  18. package/package.json +74 -6
  19. package/skills/vfrey-orchestrator/SKILL.md +15 -0
  20. package/skills/vfrey-orchestrator/channel-video/SKILL.md +41 -0
  21. package/skills/vfrey-product/SKILL.md +63 -0
  22. package/skills/vfrey-product/examples/concurrency-vs-parallelism/framey/cook-one.svg +14 -0
  23. package/skills/vfrey-product/examples/concurrency-vs-parallelism/framey/cook-two.svg +22 -0
  24. package/skills/vfrey-product/examples/concurrency-vs-parallelism/framey/index.html +70 -0
  25. package/skills/vfrey-product/examples/concurrency-vs-parallelism/remotion/cook-one.svg +14 -0
  26. package/skills/vfrey-product/examples/concurrency-vs-parallelism/remotion/cook-two.svg +22 -0
  27. package/skills/vfrey-product/examples/concurrency-vs-parallelism/remotion/index.tsx +49 -0
  28. package/skills/vfrey-product/examples/concurrency-vs-parallelism/script.json +15 -0
  29. package/skills/vfrey-product/examples/octopus-facts/index.html +60 -0
  30. package/skills/vfrey-product/examples/octopus-facts/octopus.svg +8 -0
  31. package/skills/vfrey-product/examples/octopus-facts/script.json +14 -0
  32. package/skills/vfrey-product/push/SKILL.md +15 -0
  33. package/skills/vfrey-product/scenes/NOTICE +5 -0
  34. package/skills/vfrey-product/scenes/blocks.md +57 -0
  35. package/skills/vfrey-product/scenes/craft.md +118 -0
  36. package/skills/vfrey-product/scenes/motion.md +88 -0
  37. package/src/analytics/dashboard.ts +109 -0
  38. package/src/analytics/index.ts +260 -0
  39. package/src/api/client.ts +361 -0
  40. package/src/api/cloud-files.ts +197 -0
  41. package/src/api/cloud-runs.ts +75 -0
  42. package/src/api/cloud.ts +413 -0
  43. package/src/api/commands.ts +330 -0
  44. package/src/api/config.ts +34 -0
  45. package/src/api/creators.ts +463 -0
  46. package/src/api/credentials.ts +152 -0
  47. package/src/api/mail.ts +333 -0
  48. package/src/api/topics.ts +207 -0
  49. package/src/api/types.ts +273 -0
  50. package/src/cli.ts +468 -0
  51. package/src/cloud/bundle.ts +70 -0
  52. package/src/cloud/cloudrun-executor.ts +237 -0
  53. package/src/cloud/executor.ts +281 -0
  54. package/src/cloud/gcs-sign.ts +76 -0
  55. package/src/cloud/job.ts +106 -0
  56. package/src/cloud/remote-job.ts +67 -0
  57. package/src/cloud/render-job.ts +191 -0
  58. package/src/config.ts +309 -0
  59. package/src/dashboard-link.ts +180 -0
  60. package/src/dashboard-start.ts +103 -0
  61. package/src/desk/channel.ts +64 -0
  62. package/src/desk/cli.ts +216 -0
  63. package/src/desk/creators-route.ts +129 -0
  64. package/src/desk/fonts/JetBrainsMono-OFL.txt +93 -0
  65. package/src/desk/fonts/google-sans-flex-latin.woff2 +0 -0
  66. package/src/desk/fonts/jetbrains-mono-medium.woff2 +0 -0
  67. package/src/desk/http.ts +460 -0
  68. package/src/desk/mail-route.ts +103 -0
  69. package/src/desk/overlay.ts +112 -0
  70. package/src/desk/preview.ts +422 -0
  71. package/src/desk/screens.ts +149 -0
  72. package/src/desk/secrets.ts +143 -0
  73. package/src/desk/series.ts +127 -0
  74. package/src/desk/state.ts +77 -0
  75. package/src/desk/studio-route.ts +119 -0
  76. package/src/desk/tick-runner.ts +66 -0
  77. package/src/desk/tick.ts +56 -0
  78. package/src/desk/topics-route.ts +41 -0
  79. package/src/desk/ui/app.css +505 -0
  80. package/src/desk/ui/app.js +185 -0
  81. package/src/desk/ui/apple-icon.png +0 -0
  82. package/src/desk/ui/components.js +79 -0
  83. package/src/desk/ui/core.js +156 -0
  84. package/src/desk/ui/favicon.ico +0 -0
  85. package/src/desk/ui/icon.svg +1 -0
  86. package/src/desk/ui/icons.js +49 -0
  87. package/src/desk/ui/index.html +41 -0
  88. package/src/desk/ui/metrics-math.d.ts +22 -0
  89. package/src/desk/ui/metrics-math.js +135 -0
  90. package/src/desk/ui/theme.js +5 -0
  91. package/src/desk/ui/views-creators.js +209 -0
  92. package/src/desk/ui/views-keys.js +114 -0
  93. package/src/desk/ui/views-mail.js +188 -0
  94. package/src/desk/ui/views-misc.js +123 -0
  95. package/src/desk/ui/views-review.js +119 -0
  96. package/src/desk/ui/views-runs.js +350 -0
  97. package/src/desk/ui/views-screens.js +163 -0
  98. package/src/desk/ui/views-series.js +318 -0
  99. package/src/desk/ui/views-topics.js +87 -0
  100. package/src/desk/verbs.ts +361 -0
  101. package/src/doctor.ts +212 -0
  102. package/src/drain.ts +14 -0
  103. package/src/engage/index.ts +217 -0
  104. package/src/generate/index.ts +110 -0
  105. package/src/generate/script.ts +152 -0
  106. package/src/generate/title.ts +78 -0
  107. package/src/help.ts +252 -0
  108. package/src/home.ts +45 -0
  109. package/src/llm/client.ts +177 -0
  110. package/src/log.ts +7 -0
  111. package/src/oauth/tokens.ts +189 -0
  112. package/src/oauth/vault.ts +72 -0
  113. package/src/pipeline/review.ts +642 -0
  114. package/src/pipeline/run.ts +443 -0
  115. package/src/pipeline/series-guard.ts +26 -0
  116. package/src/pipeline/source-fetch.ts +69 -0
  117. package/src/pipeline/stages.ts +728 -0
  118. package/src/pipeline/topics-source.ts +90 -0
  119. package/src/product/commands.ts +296 -0
  120. package/src/product/config-file.ts +38 -0
  121. package/src/product/engine-choice.ts +89 -0
  122. package/src/product/engines.ts +103 -0
  123. package/src/product/insights.ts +50 -0
  124. package/src/product/keys.ts +67 -0
  125. package/src/product/paths.ts +18 -0
  126. package/src/product/probes.ts +88 -0
  127. package/src/product/push-flow.ts +90 -0
  128. package/src/product/render-video.ts +21 -0
  129. package/src/product/short.ts +62 -0
  130. package/src/product/update.ts +32 -0
  131. package/src/project/canonical.ts +14 -0
  132. package/src/project/commands.ts +177 -0
  133. package/src/project/dashboard-status.ts +28 -0
  134. package/src/project/discover.ts +19 -0
  135. package/src/project/index-file.ts +73 -0
  136. package/src/project/marker.ts +92 -0
  137. package/src/project/migrate.ts +201 -0
  138. package/src/project/names.ts +27 -0
  139. package/src/project/notice.ts +16 -0
  140. package/src/project/open.ts +88 -0
  141. package/src/project/scaffold.ts +112 -0
  142. package/src/project/templates.ts +42 -0
  143. package/src/project/videos.ts +64 -0
  144. package/src/publish/facebook.ts +91 -0
  145. package/src/publish/index.ts +12 -0
  146. package/src/publish/instagram.ts +97 -0
  147. package/src/publish/rate.ts +62 -0
  148. package/src/publish/session.ts +242 -0
  149. package/src/publish/tiktok.ts +202 -0
  150. package/src/publish/youtube.ts +177 -0
  151. package/src/queue/desk-ops.ts +1170 -0
  152. package/src/queue/seal.ts +3 -0
  153. package/src/queue/store.ts +1006 -0
  154. package/src/remotion/AiLabel.tsx +26 -0
  155. package/src/remotion/Root.tsx +73 -0
  156. package/src/remotion/defaults.ts +26 -0
  157. package/src/remotion/films/claude/ClaudeFilm.tsx +1014 -0
  158. package/src/remotion/films/engine/EngineMark.tsx +17 -0
  159. package/src/remotion/films/rea/ReaShort.tsx +552 -0
  160. package/src/remotion/films/rea/drawings.tsx +907 -0
  161. package/src/remotion/films/rea/timing.ts +144 -0
  162. package/src/remotion/index.ts +4 -0
  163. package/src/remotion/templates/EditorialSplit.tsx +39 -0
  164. package/src/remotion/templates/KineticType.tsx +39 -0
  165. package/src/remotion/templates/SignalBoard.tsx +50 -0
  166. package/src/remotion/timing.ts +22 -0
  167. package/src/render/chrome.ts +78 -0
  168. package/src/render/engine-mark.ts +29 -0
  169. package/src/render/framey-template.ts +130 -0
  170. package/src/render/html-engine.ts +149 -0
  171. package/src/render/node-crypto-stub.ts +11 -0
  172. package/src/render/probe.ts +29 -0
  173. package/src/render/props.ts +17 -0
  174. package/src/render/vertical-short.ts +35 -0
  175. package/src/render/webpack.ts +33 -0
  176. package/src/render/worker.ts +65 -0
  177. package/src/scene/assets/fonts/TitanOne-OFL.txt +94 -0
  178. package/src/scene/assets/fonts/titan-one.woff2 +0 -0
  179. package/src/scene/assets/gsap/LICENSE.txt +8 -0
  180. package/src/scene/assets/gsap/gsap.min.js +11 -0
  181. package/src/scene/audio.ts +54 -0
  182. package/src/scene/blocks/LICENSE +190 -0
  183. package/src/scene/blocks/NOTICE +5 -0
  184. package/src/scene/blocks/bar-chart-race/bar-chart-race.html +585 -0
  185. package/src/scene/blocks/beat-freeze-cut/beat-freeze-cut.html +874 -0
  186. package/src/scene/blocks/camcorder-hud/camcorder-hud.html +164 -0
  187. package/src/scene/blocks/camera-dolly-zoom/camera-dolly-zoom.html +355 -0
  188. package/src/scene/blocks/code-diff/code-diff.html +817 -0
  189. package/src/scene/blocks/code-snippet-apple-terminal-pro/code-snippet-apple-terminal-pro.html +248 -0
  190. package/src/scene/blocks/code-typing/code-typing.html +764 -0
  191. package/src/scene/blocks/data-chart/data-chart.html +452 -0
  192. package/src/scene/blocks/flowchart-vertical/flowchart-vertical.html +454 -0
  193. package/src/scene/blocks/hw-scribble-transition/hw-scribble-transition.html +153 -0
  194. package/src/scene/blocks/index.json +824 -0
  195. package/src/scene/blocks/lt-accent-underline/lt-accent-underline.html +107 -0
  196. package/src/scene/blocks/lt-bold-block/lt-bold-block.html +109 -0
  197. package/src/scene/blocks/lt-clean-bar/lt-clean-bar.html +112 -0
  198. package/src/scene/blocks/lt-dark-card/lt-dark-card.html +111 -0
  199. package/src/scene/blocks/lt-kicker-name/lt-kicker-name.html +114 -0
  200. package/src/scene/blocks/lt-mask-reveal/lt-mask-reveal.html +120 -0
  201. package/src/scene/blocks/lt-neon-border/lt-neon-border.html +391 -0
  202. package/src/scene/blocks/lt-stack-bars/lt-stack-bars.html +109 -0
  203. package/src/scene/blocks/macos-notification/macos-notification.html +194 -0
  204. package/src/scene/blocks/mk-background/mk-background.html +236 -0
  205. package/src/scene/blocks/mk-callout-highlight/mk-callout-highlight.html +183 -0
  206. package/src/scene/blocks/mk-clone-wall-transition/mk-clone-wall-transition.html +226 -0
  207. package/src/scene/blocks/mk-line-graph/mk-line-graph.html +293 -0
  208. package/src/scene/blocks/mk-progress-stat/mk-progress-stat.html +186 -0
  209. package/src/scene/blocks/mk-specs-list/mk-specs-list.html +223 -0
  210. package/src/scene/blocks/news-ticker/news-ticker.html +409 -0
  211. package/src/scene/blocks/organic-light-leak-overlay/organic-light-leak-overlay.html +126 -0
  212. package/src/scene/blocks/oscilloscope-trace/oscilloscope-trace.html +519 -0
  213. package/src/scene/blocks/snow/snow.html +205 -0
  214. package/src/scene/blocks/split-flap-board/split-flap-board.html +347 -0
  215. package/src/scene/blocks/spotify-card/spotify-card.html +294 -0
  216. package/src/scene/blocks/starfield/starfield.html +188 -0
  217. package/src/scene/blocks/thread-message-stack/thread-message-stack.html +239 -0
  218. package/src/scene/blocks/week-in-merges/week-in-merges.html +470 -0
  219. package/src/scene/blocks/x-post/x-post.html +437 -0
  220. package/src/scene/blocks/yt-comment-card/yt-comment-card.html +319 -0
  221. package/src/scene/blocks/yt-lcd-background/yt-lcd-background.html +307 -0
  222. package/src/scene/blocks/yt-prism-title/yt-prism-title.html +205 -0
  223. package/src/scene/blocks.ts +229 -0
  224. package/src/scene/command.ts +272 -0
  225. package/src/scene/figure.ts +181 -0
  226. package/src/scene/glyphs.ts +107 -0
  227. package/src/scene/init.ts +86 -0
  228. package/src/scene/inspect/css-tree.d.ts +6 -0
  229. package/src/scene/inspect/page-scripts.ts +272 -0
  230. package/src/scene/inspect/report.ts +405 -0
  231. package/src/scene/inspect/run.ts +200 -0
  232. package/src/scene/inspect/source.ts +236 -0
  233. package/src/scene/label.ts +131 -0
  234. package/src/scene/net-guard.ts +74 -0
  235. package/src/scene/render.ts +292 -0
  236. package/src/scene/runtime/browser.ts +199 -0
  237. package/src/scene/runtime/core.ts +181 -0
  238. package/src/scene/runtime/react.tsx +115 -0
  239. package/src/scene/runtime/transitions.ts +133 -0
  240. package/src/scene/sanitize.ts +181 -0
  241. package/src/scene/scene.ts +152 -0
  242. package/src/scene/studio-command.ts +48 -0
  243. package/src/scene/studio-skin.ts +59 -0
  244. package/src/scene/studio.ts +337 -0
  245. package/src/scene/supply.ts +111 -0
  246. package/src/scene/timing.ts +288 -0
  247. package/src/scene/transitions/LICENSE +190 -0
  248. package/src/scene/transitions/NOTICE +5 -0
  249. package/src/scene/transitions/SOURCE.json +24 -0
  250. package/src/scene/transitions/transitions.global.js +38 -0
  251. package/src/scene/transitions.ts +26 -0
  252. package/src/service/all-projects.ts +153 -0
  253. package/src/service/commands.ts +256 -0
  254. package/src/service/due.ts +82 -0
  255. package/src/service/lock.ts +101 -0
  256. package/src/service/log-file.ts +56 -0
  257. package/src/service/state.ts +41 -0
  258. package/src/service/units.ts +90 -0
  259. package/src/service/worker.ts +51 -0
  260. package/src/skill-agents.ts +74 -0
  261. package/src/skill-install.ts +334 -0
  262. package/src/sync-write.ts +21 -0
  263. package/src/tick-report.ts +205 -0
  264. package/src/tts.ts +124 -0
  265. package/vendor/@vfrey/contracts/dist/account.d.ts +97 -0
  266. package/vendor/@vfrey/contracts/dist/account.js +27 -0
  267. package/vendor/@vfrey/contracts/dist/checks.d.ts +8 -0
  268. package/vendor/@vfrey/contracts/dist/checks.js +1 -0
  269. package/vendor/@vfrey/contracts/dist/credentials/crypto.d.ts +4 -0
  270. package/vendor/@vfrey/contracts/dist/credentials/crypto.js +38 -0
  271. package/vendor/@vfrey/contracts/dist/domain/advice-persona.d.ts +10 -0
  272. package/vendor/@vfrey/contracts/dist/domain/advice-persona.js +24 -0
  273. package/vendor/@vfrey/contracts/dist/domain/backoff.d.ts +13 -0
  274. package/vendor/@vfrey/contracts/dist/domain/backoff.js +27 -0
  275. package/vendor/@vfrey/contracts/dist/domain/cadence.d.ts +29 -0
  276. package/vendor/@vfrey/contracts/dist/domain/cadence.js +95 -0
  277. package/vendor/@vfrey/contracts/dist/domain/creators.d.ts +146 -0
  278. package/vendor/@vfrey/contracts/dist/domain/creators.js +2 -0
  279. package/vendor/@vfrey/contracts/dist/domain/disclosure.d.ts +127 -0
  280. package/vendor/@vfrey/contracts/dist/domain/disclosure.js +250 -0
  281. package/vendor/@vfrey/contracts/dist/domain/errors.d.ts +34 -0
  282. package/vendor/@vfrey/contracts/dist/domain/errors.js +68 -0
  283. package/vendor/@vfrey/contracts/dist/domain/redact.d.ts +1 -0
  284. package/vendor/@vfrey/contracts/dist/domain/redact.js +21 -0
  285. package/vendor/@vfrey/contracts/dist/domain/revenue.d.ts +13 -0
  286. package/vendor/@vfrey/contracts/dist/domain/revenue.js +16 -0
  287. package/vendor/@vfrey/contracts/dist/domain/safety.d.ts +20 -0
  288. package/vendor/@vfrey/contracts/dist/domain/safety.js +61 -0
  289. package/vendor/@vfrey/contracts/dist/domain/similarity.d.ts +13 -0
  290. package/vendor/@vfrey/contracts/dist/domain/similarity.js +100 -0
  291. package/vendor/@vfrey/contracts/dist/domain/templates.d.ts +18 -0
  292. package/vendor/@vfrey/contracts/dist/domain/templates.js +67 -0
  293. package/vendor/@vfrey/contracts/dist/domain/tiktok-chunks.d.ts +19 -0
  294. package/vendor/@vfrey/contracts/dist/domain/tiktok-chunks.js +52 -0
  295. package/vendor/@vfrey/contracts/dist/domain/topics.d.ts +78 -0
  296. package/vendor/@vfrey/contracts/dist/domain/topics.js +4 -0
  297. package/vendor/@vfrey/contracts/dist/domain/types.d.ts +216 -0
  298. package/vendor/@vfrey/contracts/dist/domain/types.js +1 -0
  299. package/vendor/@vfrey/contracts/dist/domain/variation.d.ts +57 -0
  300. package/vendor/@vfrey/contracts/dist/domain/variation.js +278 -0
  301. package/vendor/@vfrey/contracts/dist/events.d.ts +20 -0
  302. package/vendor/@vfrey/contracts/dist/events.js +1 -0
  303. package/vendor/@vfrey/contracts/dist/http.d.ts +11 -0
  304. package/vendor/@vfrey/contracts/dist/http.js +37 -0
  305. package/vendor/@vfrey/contracts/dist/index.d.ts +22 -0
  306. package/vendor/@vfrey/contracts/dist/index.js +22 -0
  307. package/vendor/@vfrey/contracts/dist/log.d.ts +2 -0
  308. package/vendor/@vfrey/contracts/dist/log.js +10 -0
  309. package/vendor/@vfrey/contracts/dist/mail.d.ts +55 -0
  310. package/vendor/@vfrey/contracts/dist/mail.js +1 -0
  311. package/vendor/@vfrey/contracts/package.json +18 -0
  312. package/vendor/@vfrey/ingest/dist/index.d.ts +41 -0
  313. package/vendor/@vfrey/ingest/dist/index.js +221 -0
  314. package/vendor/@vfrey/ingest/dist/parse.d.ts +5 -0
  315. package/vendor/@vfrey/ingest/dist/parse.js +165 -0
  316. package/vendor/@vfrey/ingest/package.json +21 -0
  317. package/vendor/framey/LICENSE +190 -0
  318. package/vendor/framey/NOTICE +15 -0
  319. package/vendor/framey/SOURCE.json +24 -0
  320. package/vendor/framey/STUDIO-SOURCE.json +9 -0
  321. package/vendor/framey/bin/framey-localize-fonts.mjs +12 -0
  322. package/vendor/framey/bin/framey.mjs +16 -0
  323. package/vendor/framey/dist/add-VMJD5KBS.js +75 -0
  324. package/vendor/framey/dist/animatedGifPrep-QPKGJ6PM.js +43 -0
  325. package/vendor/framey/dist/api.d.ts +211 -0
  326. package/vendor/framey/dist/api.js +76 -0
  327. package/vendor/framey/dist/appHistory-WW56FZTT.js +31 -0
  328. package/vendor/framey/dist/asset-WQKGOELE.js +26 -0
  329. package/vendor/framey/dist/audio-RSJWVWRA.js +1936 -0
  330. package/vendor/framey/dist/batchRender-3HPICDCT.js +366 -0
  331. package/vendor/framey/dist/beats-HL2BDUZI.js +273 -0
  332. package/vendor/framey/dist/benchmark-RWCWHI5Z.js +230 -0
  333. package/vendor/framey/dist/browser-ECYET3Q2.js +206 -0
  334. package/vendor/framey/dist/bundleWithLocalizedFonts-ALYBQEQT.js +52 -0
  335. package/vendor/framey/dist/captureCompositionFrame-4DA6SEH2.js +94 -0
  336. package/vendor/framey/dist/catalog-5JVDEUWK.js +979 -0
  337. package/vendor/framey/dist/catch-up-NEBCL5NO.js +106 -0
  338. package/vendor/framey/dist/check-F5N544ML.js +111 -0
  339. package/vendor/framey/dist/checkBrowser-LZ66CMJG.js +110 -0
  340. package/vendor/framey/dist/chunk-2K7Z2HVP.js +372 -0
  341. package/vendor/framey/dist/chunk-2UZQHDM7.js +323 -0
  342. package/vendor/framey/dist/chunk-2X4PQEYM.js +34 -0
  343. package/vendor/framey/dist/chunk-3422KMTD.js +168 -0
  344. package/vendor/framey/dist/chunk-3FYD4YEK.js +72 -0
  345. package/vendor/framey/dist/chunk-3GXQPCJC.js +682 -0
  346. package/vendor/framey/dist/chunk-3LM6YPBR.js +179 -0
  347. package/vendor/framey/dist/chunk-442JNXRM.js +20 -0
  348. package/vendor/framey/dist/chunk-4BNN5FGX.js +182 -0
  349. package/vendor/framey/dist/chunk-4P7NX5BM.js +11532 -0
  350. package/vendor/framey/dist/chunk-4UMPIPCQ.js +88 -0
  351. package/vendor/framey/dist/chunk-4YYJOFKS.js +28 -0
  352. package/vendor/framey/dist/chunk-54HJ63Q2.js +29 -0
  353. package/vendor/framey/dist/chunk-5ETJX6WM.js +89 -0
  354. package/vendor/framey/dist/chunk-5SG54JRS.js +68 -0
  355. package/vendor/framey/dist/chunk-634HSJ3J.js +58 -0
  356. package/vendor/framey/dist/chunk-66LREXGS.js +48 -0
  357. package/vendor/framey/dist/chunk-6BEFEWFB.js +25 -0
  358. package/vendor/framey/dist/chunk-6BNTGNGD.js +286 -0
  359. package/vendor/framey/dist/chunk-6DDPOHMK.js +140 -0
  360. package/vendor/framey/dist/chunk-6GV5XSOR.js +214 -0
  361. package/vendor/framey/dist/chunk-6JR2XUEE.js +127 -0
  362. package/vendor/framey/dist/chunk-73QVATIM.js +362 -0
  363. package/vendor/framey/dist/chunk-7FMUTGOH.js +279 -0
  364. package/vendor/framey/dist/chunk-7JQFJ6JJ.js +291 -0
  365. package/vendor/framey/dist/chunk-AHPGAESX.js +113 -0
  366. package/vendor/framey/dist/chunk-AHX34EFP.js +15 -0
  367. package/vendor/framey/dist/chunk-AJ5CSAEZ.js +487 -0
  368. package/vendor/framey/dist/chunk-B7RJV74O.js +5047 -0
  369. package/vendor/framey/dist/chunk-BL4U6U52.js +254 -0
  370. package/vendor/framey/dist/chunk-BSQ7XANM.js +210 -0
  371. package/vendor/framey/dist/chunk-BZ56MTDX.js +6 -0
  372. package/vendor/framey/dist/chunk-C5BO7PMT.js +282 -0
  373. package/vendor/framey/dist/chunk-CFUCHO4T.js +358 -0
  374. package/vendor/framey/dist/chunk-CZGHFATT.js +31 -0
  375. package/vendor/framey/dist/chunk-DLA44CMS.js +5649 -0
  376. package/vendor/framey/dist/chunk-DPVXBVLL.js +31292 -0
  377. package/vendor/framey/dist/chunk-DQBY7FRR.js +397 -0
  378. package/vendor/framey/dist/chunk-EAMLBAHA.js +154 -0
  379. package/vendor/framey/dist/chunk-EOVYQ3IG.js +143 -0
  380. package/vendor/framey/dist/chunk-EVMY7IPL.js +612 -0
  381. package/vendor/framey/dist/chunk-FG4BBFZI.js +122 -0
  382. package/vendor/framey/dist/chunk-FI4I7CME.js +56 -0
  383. package/vendor/framey/dist/chunk-FLREUEHW.js +2081 -0
  384. package/vendor/framey/dist/chunk-FUFP7A7S.js +1126 -0
  385. package/vendor/framey/dist/chunk-FV3PIKGH.js +56 -0
  386. package/vendor/framey/dist/chunk-FXMKHHPO.js +123 -0
  387. package/vendor/framey/dist/chunk-GGZAM5VH.js +1187 -0
  388. package/vendor/framey/dist/chunk-GKZEVJIB.js +39 -0
  389. package/vendor/framey/dist/chunk-GMECXFAN.js +57 -0
  390. package/vendor/framey/dist/chunk-GNEFDLFB.js +621 -0
  391. package/vendor/framey/dist/chunk-GPMLSOEG.js +19274 -0
  392. package/vendor/framey/dist/chunk-GPYVT5EE.js +59 -0
  393. package/vendor/framey/dist/chunk-H6IEMZEV.js +40 -0
  394. package/vendor/framey/dist/chunk-HEFQCIXR.js +170 -0
  395. package/vendor/framey/dist/chunk-HFQ4CWHA.js +118 -0
  396. package/vendor/framey/dist/chunk-HPDTCOKE.js +304 -0
  397. package/vendor/framey/dist/chunk-HTUHQKDB.js +548 -0
  398. package/vendor/framey/dist/chunk-HU2SPFNG.js +103 -0
  399. package/vendor/framey/dist/chunk-HVW7ABXD.js +19 -0
  400. package/vendor/framey/dist/chunk-IG7NDKXO.js +26 -0
  401. package/vendor/framey/dist/chunk-II3J3WXK.js +246 -0
  402. package/vendor/framey/dist/chunk-IR35BCLK.js +13 -0
  403. package/vendor/framey/dist/chunk-ISN5GRYI.js +20 -0
  404. package/vendor/framey/dist/chunk-IUONB55J.js +669 -0
  405. package/vendor/framey/dist/chunk-IVSUQR2B.js +161 -0
  406. package/vendor/framey/dist/chunk-IXU6PQSW.js +259 -0
  407. package/vendor/framey/dist/chunk-IZCW2NKT.js +29 -0
  408. package/vendor/framey/dist/chunk-JGXJU5PF.js +68 -0
  409. package/vendor/framey/dist/chunk-JHOCK3DG.js +27 -0
  410. package/vendor/framey/dist/chunk-JVBRJMOP.js +121 -0
  411. package/vendor/framey/dist/chunk-K4MJX7SR.js +17 -0
  412. package/vendor/framey/dist/chunk-K4WKGXRC.js +18 -0
  413. package/vendor/framey/dist/chunk-KGPLIH7B.js +127 -0
  414. package/vendor/framey/dist/chunk-KLIKCX6Q.js +63 -0
  415. package/vendor/framey/dist/chunk-KXTKKBOC.js +61 -0
  416. package/vendor/framey/dist/chunk-KZI44OPA.js +185 -0
  417. package/vendor/framey/dist/chunk-LEKZNRKI.js +45 -0
  418. package/vendor/framey/dist/chunk-LFGJWOVA.js +61 -0
  419. package/vendor/framey/dist/chunk-LLMSX6V6.js +17 -0
  420. package/vendor/framey/dist/chunk-LTGTN2TH.js +195 -0
  421. package/vendor/framey/dist/chunk-M2WBOQ2Q.js +54 -0
  422. package/vendor/framey/dist/chunk-MI5FTLYK.js +399 -0
  423. package/vendor/framey/dist/chunk-MLQP2QUF.js +1809 -0
  424. package/vendor/framey/dist/chunk-MMQ52NIZ.js +956 -0
  425. package/vendor/framey/dist/chunk-ND46XKK3.js +742 -0
  426. package/vendor/framey/dist/chunk-NYEXNSHM.js +504 -0
  427. package/vendor/framey/dist/chunk-O4YRB27L.js +57 -0
  428. package/vendor/framey/dist/chunk-O7BGSQ5F.js +92 -0
  429. package/vendor/framey/dist/chunk-OFQWTKPW.js +117 -0
  430. package/vendor/framey/dist/chunk-P5ZBLXJ6.js +91 -0
  431. package/vendor/framey/dist/chunk-P6LJ4Y32.js +4662 -0
  432. package/vendor/framey/dist/chunk-PKZ7RK5N.js +281 -0
  433. package/vendor/framey/dist/chunk-PURHAXFL.js +15 -0
  434. package/vendor/framey/dist/chunk-Q5X2B5WT.js +29 -0
  435. package/vendor/framey/dist/chunk-QAMKOG67.js +248 -0
  436. package/vendor/framey/dist/chunk-R6YBK4XX.js +514 -0
  437. package/vendor/framey/dist/chunk-RGLQURIX.js +2997 -0
  438. package/vendor/framey/dist/chunk-RNV3DM4S.js +3573 -0
  439. package/vendor/framey/dist/chunk-ROEW726S.js +4638 -0
  440. package/vendor/framey/dist/chunk-RQVDTPGD.js +6 -0
  441. package/vendor/framey/dist/chunk-RVZHGOGR.js +26 -0
  442. package/vendor/framey/dist/chunk-RXATE6FZ.js +385 -0
  443. package/vendor/framey/dist/chunk-S3S4TGEH.js +424 -0
  444. package/vendor/framey/dist/chunk-SDYDNHEE.js +11202 -0
  445. package/vendor/framey/dist/chunk-SSWBU67I.js +173 -0
  446. package/vendor/framey/dist/chunk-ST35C4K6.js +28 -0
  447. package/vendor/framey/dist/chunk-SZNTYTIV.js +990 -0
  448. package/vendor/framey/dist/chunk-T2EUWCJE.js +29 -0
  449. package/vendor/framey/dist/chunk-T6AGUTZJ.js +444 -0
  450. package/vendor/framey/dist/chunk-THJALHFJ.js +11131 -0
  451. package/vendor/framey/dist/chunk-TIQB63MX.js +18128 -0
  452. package/vendor/framey/dist/chunk-U242RTCT.js +134 -0
  453. package/vendor/framey/dist/chunk-UJZREAV6.js +3901 -0
  454. package/vendor/framey/dist/chunk-UMD3V7CU.js +264 -0
  455. package/vendor/framey/dist/chunk-UOFAE5RF.js +42 -0
  456. package/vendor/framey/dist/chunk-UTEIUGAZ.js +29 -0
  457. package/vendor/framey/dist/chunk-VAS6HQX3.js +138 -0
  458. package/vendor/framey/dist/chunk-VC76R6W2.js +27 -0
  459. package/vendor/framey/dist/chunk-VFZ7W5W7.js +226 -0
  460. package/vendor/framey/dist/chunk-VHNLZAE2.js +348 -0
  461. package/vendor/framey/dist/chunk-VMC4WSH5.js +278 -0
  462. package/vendor/framey/dist/chunk-VORQIH64.js +108 -0
  463. package/vendor/framey/dist/chunk-VTXFRIHO.js +186 -0
  464. package/vendor/framey/dist/chunk-W2LGJSEN.js +182 -0
  465. package/vendor/framey/dist/chunk-WKBM7NDG.js +32 -0
  466. package/vendor/framey/dist/chunk-WMQNIQDJ.js +2057 -0
  467. package/vendor/framey/dist/chunk-WN4XZC5F.js +504 -0
  468. package/vendor/framey/dist/chunk-WQ6ZXHFQ.js +133 -0
  469. package/vendor/framey/dist/chunk-X7HBSTCZ.js +53 -0
  470. package/vendor/framey/dist/chunk-X7MTZ4T2.js +6 -0
  471. package/vendor/framey/dist/chunk-XPTOJZJF.js +308 -0
  472. package/vendor/framey/dist/chunk-XT6MXC7A.js +6 -0
  473. package/vendor/framey/dist/chunk-XT7X6QCP.js +6 -0
  474. package/vendor/framey/dist/chunk-XTMDWNEC.js +2820 -0
  475. package/vendor/framey/dist/chunk-XUHOIDKG.js +6 -0
  476. package/vendor/framey/dist/chunk-XX7LTW5Y.js +138 -0
  477. package/vendor/framey/dist/chunk-Y3YGTZEG.js +382 -0
  478. package/vendor/framey/dist/chunk-Y5SBSGN7.js +346 -0
  479. package/vendor/framey/dist/chunk-YCMVI4MZ.js +625 -0
  480. package/vendor/framey/dist/chunk-YOR4CTCE.js +261 -0
  481. package/vendor/framey/dist/chunk-YPRBGITP.js +2748 -0
  482. package/vendor/framey/dist/chunk-ZKDMAOFU.js +32 -0
  483. package/vendor/framey/dist/chunk-ZW6DURPF.js +1318 -0
  484. package/vendor/framey/dist/clean-ZJOGDKDM.js +336 -0
  485. package/vendor/framey/dist/cli.js +439 -0
  486. package/vendor/framey/dist/cloudrun-HGJ3B6ZV.js +767 -0
  487. package/vendor/framey/dist/commands/contrast-audit.browser.js +643 -0
  488. package/vendor/framey/dist/commands/layout-audit.browser.js +2914 -0
  489. package/vendor/framey/dist/commands/motion-sample.browser.js +74 -0
  490. package/vendor/framey/dist/commands/motion-signature.browser.js +475 -0
  491. package/vendor/framey/dist/compare-W7UY3BAZ.js +463 -0
  492. package/vendor/framey/dist/compiler-TM3W6GYF.js +136 -0
  493. package/vendor/framey/dist/component-QF5VGYMU.js +176 -0
  494. package/vendor/framey/dist/compositions-XT3PMNDF.js +238 -0
  495. package/vendor/framey/dist/contactSheet-7W6GYHBQ.js +24 -0
  496. package/vendor/framey/dist/deploy-6FJPWKF2.js +299 -0
  497. package/vendor/framey/dist/destroy-HE2RIPKZ.js +57 -0
  498. package/vendor/framey/dist/deterministicFonts-RADD3BSX.js +50 -0
  499. package/vendor/framey/dist/dist-4S2FGGVY.js +422 -0
  500. package/vendor/framey/dist/dist-BE4B2EZE.js +32 -0
  501. package/vendor/framey/dist/dist-EWXJGN63.js +129 -0
  502. package/vendor/framey/dist/docs-VKIRFEEX.js +131 -0
  503. package/vendor/framey/dist/doctor-2UNJ2G7U.js +74 -0
  504. package/vendor/framey/dist/dom-37K6YOH4.js +14 -0
  505. package/vendor/framey/dist/dotEnv-7PZQTVIM.js +54 -0
  506. package/vendor/framey/dist/events-ACY3P2PA.js +76 -0
  507. package/vendor/framey/dist/events-IHB3B2MK.js +69 -0
  508. package/vendor/framey/dist/ffmpeg-DR5LGGSK.js +63 -0
  509. package/vendor/framey/dist/figma-BODVUPHB.js +63 -0
  510. package/vendor/framey/dist/fontData.generated-NXAOI4GH.js +13 -0
  511. package/vendor/framey/dist/fontLocalizeCli.js +142 -0
  512. package/vendor/framey/dist/gpuPolicy-XLEXORZY.js +49 -0
  513. package/vendor/framey/dist/grade-compare-GAXFQ74C.js +661 -0
  514. package/vendor/framey/dist/gsapParser-IEUSLYDJ.js +2885 -0
  515. package/vendor/framey/dist/gsapParserAcorn-LZJKCFQI.js +30 -0
  516. package/vendor/framey/dist/help-VCB5X2HS.js +184 -0
  517. package/vendor/framey/dist/history-ENGZEVFT.js +471 -0
  518. package/vendor/framey/dist/hyperframe.manifest.json +22 -0
  519. package/vendor/framey/dist/hyperframe.runtime.iife.js +1132 -0
  520. package/vendor/framey/dist/hyperframes-player.global.js +478 -0
  521. package/vendor/framey/dist/hyperframes-slideshow.global.js +148 -0
  522. package/vendor/framey/dist/info-WPCJHBMU.js +182 -0
  523. package/vendor/framey/dist/init-G4WGLYGD.js +1176 -0
  524. package/vendor/framey/dist/inspect-5IQ6CNEQ.js +106 -0
  525. package/vendor/framey/dist/installer-ML4HWUV7.js +40 -0
  526. package/vendor/framey/dist/keyframes-Q6XJO7VW.js +2755 -0
  527. package/vendor/framey/dist/lambda-A7JC74JF.js +468 -0
  528. package/vendor/framey/dist/layout-YET6XNXA.js +95 -0
  529. package/vendor/framey/dist/lint-I7FZN2VJ.js +162 -0
  530. package/vendor/framey/dist/lintProject-PDB3UN72.js +30 -0
  531. package/vendor/framey/dist/manager-CD6LJ7ZN.js +45 -0
  532. package/vendor/framey/dist/manager-WA4F2GST.js +67 -0
  533. package/vendor/framey/dist/media-treatment-BXYS4WVA.js +1030 -0
  534. package/vendor/framey/dist/media-use-LWH3MANK.js +113 -0
  535. package/vendor/framey/dist/models-K363FYM4.js +195 -0
  536. package/vendor/framey/dist/motionShot-GPAVWXLH.js +640 -0
  537. package/vendor/framey/dist/normalize-FASIPBUM.js +24 -0
  538. package/vendor/framey/dist/normalize-audio-VDWYRHN6.js +268 -0
  539. package/vendor/framey/dist/open-E3TB6P6J.js +128 -0
  540. package/vendor/framey/dist/parakeetRunner-UP34UOI2.js +58 -0
  541. package/vendor/framey/dist/pipeline-VXIUYEBU.js +64 -0
  542. package/vendor/framey/dist/play-BUTDQ4DV.js +353 -0
  543. package/vendor/framey/dist/policies-OLDYGFK5.js +309 -0
  544. package/vendor/framey/dist/preflight-2KZ2GWAZ.js +55 -0
  545. package/vendor/framey/dist/present-GXYSM7NX.js +353 -0
  546. package/vendor/framey/dist/preview-EKZ33JOJ.js +1910 -0
  547. package/vendor/framey/dist/producer-SKU5EV3J.js +13 -0
  548. package/vendor/framey/dist/progress-ZWB4Z4ET.js +76 -0
  549. package/vendor/framey/dist/projectConfig-3MZAYVTS.js +60 -0
  550. package/vendor/framey/dist/publication-DTKWNF74.js +33 -0
  551. package/vendor/framey/dist/remove-background-OY5L7XN2.js +265 -0
  552. package/vendor/framey/dist/render-CDV4GDFX.js +187 -0
  553. package/vendor/framey/dist/render-YE5YAMOD.js +2620 -0
  554. package/vendor/framey/dist/render-batch-ZAMFPCLL.js +274 -0
  555. package/vendor/framey/dist/renderSetupWorker.js +120 -0
  556. package/vendor/framey/dist/resolver-D5BIPSEW.js +42 -0
  557. package/vendor/framey/dist/runtimeVersion.js +18 -0
  558. package/vendor/framey/dist/sam-WFZXI4F7.js +23 -0
  559. package/vendor/framey/dist/shaderTransitionWorker.js +44 -0
  560. package/vendor/framey/dist/sherpa-RQWLV7I3.js +79 -0
  561. package/vendor/framey/dist/sherpaWorker.js +84 -0
  562. package/vendor/framey/dist/sites-AMOVABTK.js +54 -0
  563. package/vendor/framey/dist/skill-ZHVLH7KX.js +15 -0
  564. package/vendor/framey/dist/slideshow-SJGK4RQS.js +24 -0
  565. package/vendor/framey/dist/snapshot-TUXL6LBQ.js +1515 -0
  566. package/vendor/framey/dist/src-JHQGB2S6.js +215 -0
  567. package/vendor/framey/dist/src-Z5PGEMXB.js +532 -0
  568. package/vendor/framey/dist/state-B6H3WHCF.js +28 -0
  569. package/vendor/framey/dist/staticProjectServer-DFVZOUGI.js +63 -0
  570. package/vendor/framey/dist/studio/assets/hyperframes-player-DLaKjw0L.js +478 -0
  571. package/vendor/framey/dist/studio/assets/index-BX3KHhGX.js +71 -0
  572. package/vendor/framey/dist/studio/assets/index-CYbvOVp-.css +1 -0
  573. package/vendor/framey/dist/studio/assets/index-Dp3QEUGL.js +402 -0
  574. package/vendor/framey/dist/studio/assets/index-qrTAXbqc.js +531 -0
  575. package/vendor/framey/dist/studio/favicon.svg +14 -0
  576. package/vendor/framey/dist/studio/icons/timeline/audio.svg +7 -0
  577. package/vendor/framey/dist/studio/icons/timeline/captions.svg +5 -0
  578. package/vendor/framey/dist/studio/icons/timeline/composition.svg +12 -0
  579. package/vendor/framey/dist/studio/icons/timeline/image.svg +18 -0
  580. package/vendor/framey/dist/studio/icons/timeline/music.svg +10 -0
  581. package/vendor/framey/dist/studio/icons/timeline/text.svg +3 -0
  582. package/vendor/framey/dist/studio/index.html +23 -0
  583. package/vendor/framey/dist/studio-api-2NECGRMB.js +179 -0
  584. package/vendor/framey/dist/studioSelectionClient-IUUPKUHZ.js +25 -0
  585. package/vendor/framey/dist/studioServer-N2OPUMOQ.js +1569 -0
  586. package/vendor/framey/dist/synthesize-PKTB5ATG.js +183 -0
  587. package/vendor/framey/dist/system-7F3UEHQT.js +27 -0
  588. package/vendor/framey/dist/telemetry-DQGPEKM6.js +145 -0
  589. package/vendor/framey/dist/telemetry-UN55N6U6.js +77 -0
  590. package/vendor/framey/dist/timeline-LHUUMK63.js +1295 -0
  591. package/vendor/framey/dist/tokens-OLECWDFK.js +92 -0
  592. package/vendor/framey/dist/transcribe-O7HQKMIV.js +331 -0
  593. package/vendor/framey/dist/transcribe-UJESPBT5.js +76 -0
  594. package/vendor/framey/dist/tts-YCCQHZD4.js +234 -0
  595. package/vendor/framey/dist/urlDownloader-H2LXLG4U.js +48 -0
  596. package/vendor/framey/dist/usage-HG4WMM7A.js +2167 -0
  597. package/vendor/framey/dist/validate-F2XMN6EV.js +87 -0
  598. package/vendor/framey/dist/vendor/npm/gsap@3.15.0/GSAP-LICENSE.txt +8 -0
  599. package/vendor/framey/dist/vendor/npm/gsap@3.15.0/dist/CustomEase.min.js +11 -0
  600. package/vendor/framey/dist/vendor/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js +11 -0
  601. package/vendor/framey/dist/vendor/npm/gsap@3.15.0/dist/gsap.min.js +11 -0
  602. package/vendor/framey/dist/warmCheck.js +220 -0
  603. package/vendor/framey/dist/warmCheckClient.js +139 -0
  604. package/vendor/framey/dist/workspaceBuildCheck-XYD2YTFW.js +133 -0
  605. package/vendor/framey-player/LICENSE +190 -0
  606. package/vendor/framey-player/NOTICE +16 -0
  607. package/vendor/framey-player/SOURCE.json +6 -0
  608. package/vendor/framey-player/hyperframes-player.global.js +478 -0
  609. package/vendor/framey.pin +1 -0
  610. package/bin/index.js +0 -3
@@ -0,0 +1,1170 @@
1
+ import os from "node:os";
2
+ import { randomUUID } from "node:crypto";
3
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import type { DatabaseSync } from "node:sqlite";
7
+ import { credentialsKey, encryptSecret } from "@vfrey/contracts";
8
+ import { secretFieldError } from "../desk/secrets.js";
9
+ import { cadenceHealth } from "../analytics/dashboard.js";
10
+ import { mp4For } from "../project/videos.js";
11
+ import { redact } from "./seal.js";
12
+
13
+ export const CREDENTIALS_KEY_REFUSAL = "CREDENTIALS_KEY must be 32 bytes, base64.";
14
+
15
+ export type DeskResult = { ok: true } | { ok: false; refusal: string };
16
+
17
+ export interface JobEventRow {
18
+ id: number;
19
+ jobId: string;
20
+ at: string;
21
+ stage: string;
22
+ status: string;
23
+ attempt: number | null;
24
+ code: string | null;
25
+ message: string | null;
26
+ durationMs: number | null;
27
+ }
28
+
29
+ export interface EventInput {
30
+ stage: string;
31
+ status: string;
32
+ code: string | null;
33
+ message: string | null;
34
+ durationMs: number | null;
35
+ attempt?: number | null;
36
+ }
37
+
38
+ const refused = (refusal: string): DeskResult => ({ ok: false, refusal });
39
+ const ok = (): DeskResult => ({ ok: true });
40
+
41
+ function redactDeep(value: unknown): unknown {
42
+ if (typeof value === "string") return redact(value);
43
+ if (Array.isArray(value)) return value.map((item) => redactDeep(item));
44
+ if (!value || typeof value !== "object") return value;
45
+ const out: Record<string, unknown> = {};
46
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
47
+ if (/token|secret|key|authorization|ciphertext/i.test(key)) continue;
48
+ out[key] = redactDeep(item);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function recordEvent(db: DatabaseSync, jobId: string, event: EventInput): void {
54
+ db.prepare(
55
+ `INSERT INTO job_events (job_id, at, stage, status, code, message, duration_ms, attempt) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
56
+ ).run(
57
+ jobId,
58
+ new Date().toISOString(),
59
+ event.stage,
60
+ event.status,
61
+ event.code,
62
+ event.message ? redact(event.message) : null,
63
+ event.durationMs,
64
+ event.attempt ?? null,
65
+ );
66
+ }
67
+
68
+ /** Records the terminal event of a stage, timed from that stage's latest `running` event. */
69
+ export function recordTerminal(db: DatabaseSync, jobId: string, event: Omit<EventInput, "durationMs">): void {
70
+ const started = db
71
+ .prepare(`SELECT at FROM job_events WHERE job_id = ? AND stage = ? AND status = 'running' ORDER BY id DESC LIMIT 1`)
72
+ .get(jobId, event.stage) as { at: string } | undefined;
73
+ const durationMs = started ? Math.max(0, Date.now() - Date.parse(started.at)) : null;
74
+ recordEvent(db, jobId, { ...event, durationMs });
75
+ }
76
+
77
+ export function eventsFor(db: DatabaseSync, jobId: string): JobEventRow[] {
78
+ const rows = db
79
+ .prepare(`SELECT * FROM job_events WHERE job_id = ? ORDER BY id ASC`)
80
+ .all(jobId) as Array<Record<string, unknown>>;
81
+ return rows.map((row) => ({
82
+ id: Number(row.id),
83
+ jobId: String(row.job_id),
84
+ at: String(row.at),
85
+ stage: String(row.stage),
86
+ status: String(row.status),
87
+ attempt: row.attempt == null ? null : Number(row.attempt),
88
+ code: row.code ? String(row.code) : null,
89
+ message: row.message ? redact(String(row.message)) : null,
90
+ durationMs: row.duration_ms == null ? null : Number(row.duration_ms),
91
+ }));
92
+ }
93
+
94
+ export function createAccount(db: DatabaseSync, input: { id: string; name: string; ownerId?: string | null }): void {
95
+ db.prepare(`INSERT INTO desk_accounts (id, name, owner_id, paused) VALUES (?, ?, ?, 0)`).run(
96
+ input.id,
97
+ input.name,
98
+ input.ownerId ?? null,
99
+ );
100
+ }
101
+
102
+ export function renameAccount(db: DatabaseSync, id: string, name: string): DeskResult {
103
+ const changed = db.prepare(`UPDATE desk_accounts SET name = ? WHERE id = ?`).run(name, id);
104
+ return changed.changes ? ok() : refused(`Account ${id} was not found.`);
105
+ }
106
+
107
+ export function deleteAccount(db: DatabaseSync, id: string): DeskResult {
108
+ const open = db
109
+ .prepare(
110
+ `SELECT id FROM jobs WHERE status IN ('pending', 'leased', 'awaiting_review')
111
+ AND (account_id = ? OR account_id IN (SELECT id FROM desk_series WHERE account_id = ?)) LIMIT 1`,
112
+ )
113
+ .get(id, id);
114
+ if (open) return refused("Cancel or finish the open job before deleting this account.");
115
+ db.prepare(`DELETE FROM desk_secrets WHERE owner_kind = 'account' AND owner_id = ?`).run(id);
116
+ const series = db.prepare(`SELECT id FROM desk_series WHERE account_id = ?`).all(id) as Array<{ id: string }>;
117
+ for (const row of series) deleteSeriesRows(db, row.id);
118
+ db.prepare(`DELETE FROM desk_accounts WHERE id = ?`).run(id);
119
+ return ok();
120
+ }
121
+
122
+ function deleteSeriesRows(db: DatabaseSync, id: string): void {
123
+ db.prepare(`DELETE FROM desk_secrets WHERE owner_kind = 'series' AND owner_id = ?`).run(id);
124
+ db.prepare(`DELETE FROM desk_sources WHERE series_id = ?`).run(id);
125
+ db.prepare(`DELETE FROM desk_platforms WHERE series_id = ?`).run(id);
126
+ db.prepare(`DELETE FROM desk_series WHERE id = ?`).run(id);
127
+ }
128
+
129
+ export function listAccounts(db: DatabaseSync): Array<Record<string, unknown>> {
130
+ return db.prepare(`SELECT id, name, owner_id FROM desk_accounts ORDER BY id`).all() as Array<Record<string, unknown>>;
131
+ }
132
+
133
+ export function setSecret(
134
+ db: DatabaseSync,
135
+ input: { ownerKind: "account" | "series"; ownerId: string; field: string; plaintext: string; key: string },
136
+ ): { present: boolean } | DeskResult {
137
+ let key: Buffer;
138
+ try {
139
+ key = credentialsKey(input.key);
140
+ } catch {
141
+ return refused(CREDENTIALS_KEY_REFUSAL);
142
+ }
143
+ const bad = secretFieldError(input.ownerKind, input.field);
144
+ if (bad) return refused(bad.refusal);
145
+ if (!input.plaintext.trim()) {
146
+ return { present: secretPresent(db, input.ownerKind, input.ownerId, input.field) };
147
+ }
148
+ const ciphertext = encryptSecret(input.plaintext.trim(), key);
149
+ db.prepare(
150
+ `INSERT INTO desk_secrets (owner_kind, owner_id, field, ciphertext, set_at) VALUES (?, ?, ?, ?, ?)
151
+ ON CONFLICT (owner_kind, owner_id, field) DO UPDATE SET ciphertext = excluded.ciphertext, set_at = excluded.set_at`,
152
+ ).run(input.ownerKind, input.ownerId, input.field, ciphertext, new Date().toISOString());
153
+ return { present: true };
154
+ }
155
+
156
+ function secretPresent(db: DatabaseSync, ownerKind: string, ownerId: string, field: string): boolean {
157
+ return Boolean(
158
+ db.prepare(`SELECT field FROM desk_secrets WHERE owner_kind = ? AND owner_id = ? AND field = ?`).get(ownerKind, ownerId, field),
159
+ );
160
+ }
161
+
162
+ export function clearSecret(
163
+ db: DatabaseSync,
164
+ input: { ownerKind: "account" | "series"; ownerId: string; field: string },
165
+ ): { present: false } {
166
+ db.prepare(`DELETE FROM desk_secrets WHERE owner_kind = ? AND owner_id = ? AND field = ?`).run(
167
+ input.ownerKind,
168
+ input.ownerId,
169
+ input.field,
170
+ );
171
+ return { present: false };
172
+ }
173
+
174
+ export function secretPresence(db: DatabaseSync, ownerKind: string, ownerId: string): Record<string, boolean> {
175
+ const rows = db
176
+ .prepare(`SELECT field FROM desk_secrets WHERE owner_kind = ? AND owner_id = ?`)
177
+ .all(ownerKind, ownerId) as Array<{ field: string }>;
178
+ const out: Record<string, boolean> = {};
179
+ for (const row of rows) out[row.field] = true;
180
+ return out;
181
+ }
182
+
183
+ export interface SeriesInput {
184
+ id: string;
185
+ accountId: string;
186
+ niche: string;
187
+ language?: string;
188
+ timezone?: string;
189
+ autopilot?: boolean;
190
+ minHoursBetweenPosts?: number;
191
+ maxPostsPerDay?: number;
192
+ windows?: string[];
193
+ voices?: string[];
194
+ hashtagPool?: string[];
195
+ engageEnabled?: boolean;
196
+ allowSpicy?: boolean;
197
+ maxRepliesPerDay?: number;
198
+ minSecondsBetween?: number;
199
+ youtubeDailyUploadCap?: number;
200
+ stylePath?: string;
201
+ /** A render engine id, or "" to follow the machine default. */
202
+ engine?: string;
203
+ /** New series are dry-run. Only a test or an import of a proven series passes true. The verbs go live with a typed channel id. */
204
+ live?: boolean;
205
+ /** Off: the video is made and kept, nothing uploads. Default on. */
206
+ publishEnabled?: boolean;
207
+ /** On: a job waits for approval before render (slice D). Default off. */
208
+ reviewEnabled?: boolean;
209
+ }
210
+
211
+ export function createSeries(db: DatabaseSync, input: SeriesInput): void {
212
+ db.prepare(
213
+ `INSERT INTO desk_series (
214
+ id, account_id, niche, language, autopilot, timezone, min_hours, max_posts, windows_json, voices_json,
215
+ hashtags_json, engage_enabled, allow_spicy, max_replies, min_seconds, upload_cap, style_path, paused
216
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`,
217
+ ).run(
218
+ input.id,
219
+ input.accountId,
220
+ input.niche,
221
+ input.language ?? "en",
222
+ input.autopilot === false ? 0 : 1,
223
+ input.timezone ?? "UTC",
224
+ input.minHoursBetweenPosts ?? 18,
225
+ input.maxPostsPerDay ?? 1,
226
+ JSON.stringify(input.windows ?? []),
227
+ JSON.stringify(input.voices ?? []),
228
+ JSON.stringify(input.hashtagPool ?? []),
229
+ input.engageEnabled === false ? 0 : 1,
230
+ input.allowSpicy ? 1 : 0,
231
+ input.maxRepliesPerDay ?? 20,
232
+ input.minSecondsBetween ?? 45,
233
+ input.youtubeDailyUploadCap ?? 90,
234
+ input.stylePath ?? "",
235
+ );
236
+ if (input.engine) db.prepare(`UPDATE desk_series SET engine = ? WHERE id = ?`).run(input.engine, input.id);
237
+ db.prepare(`UPDATE desk_series SET live = ?, publish_on = ?, review_on = ? WHERE id = ?`).run(
238
+ input.live ? 1 : 0,
239
+ input.publishEnabled === false ? 0 : 1,
240
+ input.reviewEnabled ? 1 : 0,
241
+ input.id,
242
+ );
243
+ }
244
+
245
+ export interface SeriesGates {
246
+ live: boolean;
247
+ publishEnabled: boolean;
248
+ reviewEnabled: boolean;
249
+ }
250
+
251
+ export function seriesGates(db: DatabaseSync, seriesId: string): SeriesGates {
252
+ const row = db.prepare(`SELECT live, publish_on, review_on FROM desk_series WHERE id = ?`).get(seriesId) as
253
+ | { live: number; publish_on: number; review_on: number }
254
+ | undefined;
255
+ if (!row) return { live: false, publishEnabled: true, reviewEnabled: false };
256
+ return { live: Number(row.live) === 1, publishEnabled: Number(row.publish_on) === 1, reviewEnabled: Number(row.review_on) === 1 };
257
+ }
258
+
259
+ export function recordSeriesEvent(db: DatabaseSync, seriesId: string, code: string, message: string): void {
260
+ db.prepare(`INSERT INTO series_events (series_id, at, code, message) VALUES (?, ?, ?, ?)`).run(seriesId, new Date().toISOString(), code, redact(message));
261
+ }
262
+
263
+ export function seriesEvents(db: DatabaseSync, seriesId: string, limit: number): Array<{ id: number; at: string; code: string; message: string }> {
264
+ const rows = db.prepare(`SELECT id, at, code, message FROM series_events WHERE series_id = ? ORDER BY id DESC LIMIT ?`).all(seriesId, limit) as Array<Record<string, unknown>>;
265
+ return rows.map((row) => ({ id: Number(row.id), at: String(row.at), code: String(row.code), message: String(row.message) }));
266
+ }
267
+
268
+ export function setLive(db: DatabaseSync, seriesId: string, live: boolean, message: string): DeskResult {
269
+ const changed = db.prepare(`UPDATE desk_series SET live = ? WHERE id = ?`).run(live ? 1 : 0, seriesId);
270
+ if (!changed.changes) return refused(`Series ${seriesId} was not found.`);
271
+ recordSeriesEvent(db, seriesId, live ? "went_live" : "went_dry_run", message);
272
+ return ok();
273
+ }
274
+
275
+ /**
276
+ * Series that existed before the live flag keep their behaviour only when they already made a real upload
277
+ * (a publication with an external id other than dry-run). Every other series starts in dry-run.
278
+ */
279
+ export function backfillLive(db: DatabaseSync): void {
280
+ const rows = db
281
+ .prepare(
282
+ `SELECT id FROM desk_series WHERE id IN (
283
+ SELECT account_id FROM publications WHERE external_id IS NOT NULL AND external_id != 'dry-run'
284
+ )`,
285
+ )
286
+ .all() as Array<{ id: string }>;
287
+ for (const row of rows) {
288
+ db.prepare(`UPDATE desk_series SET live = 1 WHERE id = ?`).run(row.id);
289
+ recordSeriesEvent(db, row.id, "migrated_live", "Kept live: this series already had a real upload before the live flag existed.");
290
+ }
291
+ }
292
+
293
+ export function updateSeries(db: DatabaseSync, id: string, patch: Partial<SeriesInput>): DeskResult {
294
+ const current = db.prepare(`SELECT id FROM desk_series WHERE id = ?`).get(id);
295
+ if (!current) return refused(`Series ${id} was not found.`);
296
+ const sets: string[] = [];
297
+ const values: Array<string | number> = [];
298
+ const assign = (column: string, value: string | number) => {
299
+ sets.push(`${column} = ?`);
300
+ values.push(value);
301
+ };
302
+ if (patch.niche !== undefined) assign("niche", patch.niche);
303
+ if (patch.language !== undefined) assign("language", patch.language);
304
+ if (patch.timezone !== undefined) assign("timezone", patch.timezone);
305
+ if (patch.autopilot !== undefined) assign("autopilot", patch.autopilot ? 1 : 0);
306
+ if (patch.minHoursBetweenPosts !== undefined) assign("min_hours", patch.minHoursBetweenPosts);
307
+ if (patch.maxPostsPerDay !== undefined) assign("max_posts", patch.maxPostsPerDay);
308
+ if (patch.windows !== undefined) assign("windows_json", JSON.stringify(patch.windows));
309
+ if (patch.voices !== undefined) assign("voices_json", JSON.stringify(patch.voices));
310
+ if (patch.hashtagPool !== undefined) assign("hashtags_json", JSON.stringify(patch.hashtagPool));
311
+ if (patch.engageEnabled !== undefined) assign("engage_enabled", patch.engageEnabled ? 1 : 0);
312
+ if (patch.allowSpicy !== undefined) assign("allow_spicy", patch.allowSpicy ? 1 : 0);
313
+ if (patch.maxRepliesPerDay !== undefined) assign("max_replies", patch.maxRepliesPerDay);
314
+ if (patch.minSecondsBetween !== undefined) assign("min_seconds", patch.minSecondsBetween);
315
+ if (patch.youtubeDailyUploadCap !== undefined) assign("upload_cap", patch.youtubeDailyUploadCap);
316
+ if (patch.stylePath !== undefined) assign("style_path", patch.stylePath);
317
+ const before = seriesGates(db, id);
318
+ if (patch.publishEnabled !== undefined) assign("publish_on", patch.publishEnabled ? 1 : 0);
319
+ if (patch.reviewEnabled !== undefined) assign("review_on", patch.reviewEnabled ? 1 : 0);
320
+ if (patch.engine !== undefined) {
321
+ sets.push("engine = ?");
322
+ values.push(patch.engine === "" ? (null as unknown as string) : patch.engine);
323
+ }
324
+ if (sets.length === 0) return ok();
325
+ values.push(id);
326
+ db.prepare(`UPDATE desk_series SET ${sets.join(", ")} WHERE id = ?`).run(...values);
327
+ if (patch.publishEnabled !== undefined && patch.publishEnabled !== before.publishEnabled) {
328
+ recordSeriesEvent(db, id, patch.publishEnabled ? "publish_on" : "publish_off", patch.publishEnabled ? "Publish switched on." : "Publish switched off. Videos are made and kept; nothing uploads.");
329
+ }
330
+ if (patch.reviewEnabled !== undefined && patch.reviewEnabled !== before.reviewEnabled) {
331
+ recordSeriesEvent(db, id, patch.reviewEnabled ? "review_on" : "review_off", patch.reviewEnabled ? "Review switched on." : "Review switched off.");
332
+ }
333
+ return ok();
334
+ }
335
+
336
+ export function deleteSeries(db: DatabaseSync, id: string): DeskResult {
337
+ const row = db.prepare(`SELECT account_id FROM desk_series WHERE id = ?`).get(id) as { account_id: string } | undefined;
338
+ if (!row) return refused(`Series ${id} was not found.`);
339
+ // jobs.account_id stores the series id
340
+ const open = db.prepare(`SELECT id FROM jobs WHERE account_id = ? AND status IN ('pending', 'leased', 'awaiting_review') LIMIT 1`).get(id);
341
+ if (open) return refused("Cancel or finish the open job before deleting this series.");
342
+ deleteSeriesRows(db, id);
343
+ return ok();
344
+ }
345
+
346
+ export function pauseSeries(db: DatabaseSync, id: string): DeskResult {
347
+ const changed = db.prepare(`UPDATE desk_series SET paused = 1 WHERE id = ?`).run(id);
348
+ return changed.changes ? ok() : refused(`Series ${id} was not found.`);
349
+ }
350
+
351
+ export function resumeSeries(db: DatabaseSync, id: string): DeskResult {
352
+ const changed = db.prepare(`UPDATE desk_series SET paused = 0 WHERE id = ?`).run(id);
353
+ return changed.changes ? ok() : refused(`Series ${id} was not found.`);
354
+ }
355
+
356
+ export function seriesPaused(db: DatabaseSync, seriesId: string): boolean {
357
+ const row = db
358
+ .prepare(
359
+ `SELECT s.paused AS series_paused, a.paused AS account_paused
360
+ FROM desk_series s LEFT JOIN desk_accounts a ON a.id = s.account_id WHERE s.id = ?`,
361
+ )
362
+ .get(seriesId) as { series_paused: number; account_paused: number | null } | undefined;
363
+ if (row) return Number(row.series_paused) === 1 || Number(row.account_paused ?? 0) === 1;
364
+ return Boolean(db.prepare(`SELECT id FROM desk_accounts WHERE paused = 1 AND id = ?`).get(seriesId));
365
+ }
366
+
367
+ const secretKey = /token|secret|key/i;
368
+
369
+ export function setPlatform(
370
+ db: DatabaseSync,
371
+ input: { seriesId: string; platform: string; enabled: boolean; config?: Record<string, unknown> },
372
+ ): DeskResult {
373
+ const config = input.config ?? {};
374
+ for (const key of Object.keys(config)) {
375
+ if (secretKey.test(key)) return refused(`Platform config cannot store ${key}. Use secret set.`);
376
+ }
377
+ db.prepare(
378
+ `INSERT INTO desk_platforms (series_id, platform, enabled, config_json) VALUES (?, ?, ?, ?)
379
+ ON CONFLICT (series_id, platform) DO UPDATE SET enabled = excluded.enabled, config_json = excluded.config_json`,
380
+ ).run(input.seriesId, input.platform, input.enabled ? 1 : 0, JSON.stringify(config));
381
+ return ok();
382
+ }
383
+
384
+ export function disablePlatform(db: DatabaseSync, seriesId: string, platform: string): DeskResult {
385
+ const changed = db.prepare(`UPDATE desk_platforms SET enabled = 0 WHERE series_id = ? AND platform = ?`).run(seriesId, platform);
386
+ return changed.changes ? ok() : refused(`Platform ${platform} was not found on ${seriesId}.`);
387
+ }
388
+
389
+ export function removePlatform(db: DatabaseSync, seriesId: string, platform: string): DeskResult {
390
+ const series = db.prepare(`SELECT account_id FROM desk_series WHERE id = ?`).get(seriesId) as { account_id: string } | undefined;
391
+ if (!series) return refused(`Series ${seriesId} was not found.`);
392
+ const published = db
393
+ .prepare(`SELECT id FROM publications WHERE account_id = ? AND platform = ? LIMIT 1`)
394
+ .get(seriesId, platform);
395
+ if (published) return refused("A publication exists for this platform. Disable it instead of deleting the config.");
396
+ db.prepare(`DELETE FROM desk_platforms WHERE series_id = ? AND platform = ?`).run(seriesId, platform);
397
+ return ok();
398
+ }
399
+
400
+ export function addSource(db: DatabaseSync, input: { seriesId: string; kind: string; value: string }): { id: string } {
401
+ const id = randomUUID();
402
+ db.prepare(`INSERT INTO desk_sources (id, series_id, kind, value, last_status) VALUES (?, ?, ?, ?, NULL)`).run(
403
+ id,
404
+ input.seriesId,
405
+ input.kind,
406
+ input.value,
407
+ );
408
+ return { id };
409
+ }
410
+
411
+ export function updateSource(db: DatabaseSync, id: string, value: string): DeskResult {
412
+ const changed = db.prepare(`UPDATE desk_sources SET value = ?, last_status = NULL, last_fetch_at = NULL, last_count = NULL, last_code = NULL WHERE id = ?`).run(value, id);
413
+ return changed.changes ? ok() : refused(`Source ${id} was not found.`);
414
+ }
415
+
416
+ export function removeSource(db: DatabaseSync, id: string): DeskResult {
417
+ const changed = db.prepare(`DELETE FROM desk_sources WHERE id = ?`).run(id);
418
+ return changed.changes ? ok() : refused(`Source ${id} was not found.`);
419
+ }
420
+
421
+ export function setMode(db: DatabaseSync, userId: string, mode: string, llmKeyPresent: boolean): DeskResult {
422
+ const value = mode === "api" ? "api" : mode === "agent" ? "agent" : "";
423
+ if (!value) return refused("Mode is agent or api.");
424
+ if (value === "api" && !llmKeyPresent) return refused("LLM_API_KEY is not set.");
425
+ const key = `${userId}:orchestrator.mode`;
426
+ db.prepare(
427
+ `INSERT INTO desk_settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
428
+ ).run(key, value);
429
+ return ok();
430
+ }
431
+
432
+ export function clearMode(db: DatabaseSync, userId: string): DeskResult {
433
+ db.prepare(`DELETE FROM desk_settings WHERE key = ?`).run(`${userId}:orchestrator.mode`);
434
+ return ok();
435
+ }
436
+
437
+ const ENGINE_DEFAULT_KEY = "engine.default";
438
+
439
+ /** The machine's default engine id, or null when the person never chose one. */
440
+ export function readEngineDefault(db: DatabaseSync): string | null {
441
+ const row = db.prepare(`SELECT value FROM desk_settings WHERE key = ?`).get(ENGINE_DEFAULT_KEY) as { value: string } | undefined;
442
+ return row?.value ?? null;
443
+ }
444
+
445
+ export function writeEngineDefault(db: DatabaseSync, engine: string): void {
446
+ db.prepare(
447
+ `INSERT INTO desk_settings (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
448
+ ).run(ENGINE_DEFAULT_KEY, engine);
449
+ }
450
+
451
+ /** The engine a series names, or null when it follows the machine default. */
452
+ export function readSeriesEngine(db: DatabaseSync, seriesId: string): string | null {
453
+ const row = db.prepare(`SELECT engine FROM desk_series WHERE id = ?`).get(seriesId) as { engine: string | null } | undefined;
454
+ return row?.engine || null;
455
+ }
456
+
457
+ export function readMode(db: DatabaseSync, userId: string): "agent" | "api" {
458
+ const row = db.prepare(`SELECT value FROM desk_settings WHERE key = ?`).get(`${userId}:orchestrator.mode`) as
459
+ | { value: string }
460
+ | undefined;
461
+ return row?.value === "api" ? "api" : "agent";
462
+ }
463
+
464
+ export function setCadence(
465
+ db: DatabaseSync,
466
+ seriesId: string,
467
+ input: { timezone: string; minHoursBetweenPosts: number; maxPostsPerDay: number; windows: string[] },
468
+ ): DeskResult {
469
+ const changed = db
470
+ .prepare(`UPDATE desk_series SET timezone = ?, min_hours = ?, max_posts = ?, windows_json = ? WHERE id = ?`)
471
+ .run(input.timezone, input.minHoursBetweenPosts, input.maxPostsPerDay, JSON.stringify(input.windows), seriesId);
472
+ return changed.changes ? ok() : refused(`Series ${seriesId} was not found.`);
473
+ }
474
+
475
+ export function clearCadence(): DeskResult {
476
+ return refused("A series always has a cadence. Use cadence set to change it.");
477
+ }
478
+
479
+ export function setQuota(
480
+ db: DatabaseSync,
481
+ seriesId: string,
482
+ input: { youtubeDailyUploadCap: number; maxRepliesPerDay: number; minSecondsBetween: number },
483
+ ): DeskResult {
484
+ const changed = db
485
+ .prepare(`UPDATE desk_series SET upload_cap = ?, max_replies = ?, min_seconds = ? WHERE id = ?`)
486
+ .run(input.youtubeDailyUploadCap, input.maxRepliesPerDay, input.minSecondsBetween, seriesId);
487
+ return changed.changes ? ok() : refused(`Series ${seriesId} was not found.`);
488
+ }
489
+
490
+ export function clearQuota(): DeskResult {
491
+ return refused("Clearing a cap is refused. Set the cap to 0 to stop posts or replies.");
492
+ }
493
+
494
+ export function insertWarning(
495
+ db: DatabaseSync,
496
+ input: { accountId: string | null; jobId: string | null; code: string; message: string },
497
+ ): { id: string } {
498
+ const id = randomUUID();
499
+ db.prepare(
500
+ `INSERT INTO desk_warnings (id, account_id, job_id, code, message, created_at, acked_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`,
501
+ ).run(id, input.accountId, input.jobId, input.code, redact(input.message), new Date().toISOString());
502
+ return { id };
503
+ }
504
+
505
+ export function insertWarningOnce(
506
+ db: DatabaseSync,
507
+ input: { accountId: string | null; jobId: string | null; code: string; message: string },
508
+ ): { id: string } {
509
+ const existing = db
510
+ .prepare(
511
+ `SELECT id FROM desk_warnings WHERE code = ? AND ifnull(account_id, '') = ? AND acked_at IS NULL LIMIT 1`,
512
+ )
513
+ .get(input.code, input.accountId ?? "") as { id: string } | undefined;
514
+ if (existing) return { id: existing.id };
515
+ return insertWarning(db, input);
516
+ }
517
+
518
+ export interface DeskOverlay {
519
+ id: string;
520
+ accountId: string;
521
+ niche: string;
522
+ language: string;
523
+ voices: string[];
524
+ hashtagPool: string[];
525
+ timezone: string;
526
+ minHoursBetweenPosts: number;
527
+ maxPostsPerDay: number;
528
+ windows: string[];
529
+ engageEnabled: boolean;
530
+ allowSpicy: boolean;
531
+ maxRepliesPerDay: number;
532
+ minSecondsBetween: number;
533
+ youtubeDailyUploadCap: number;
534
+ platforms: Array<{ platform: string; enabled: boolean; config: Record<string, unknown> }>;
535
+ }
536
+
537
+ export function deskOverlay(db: DatabaseSync, accountId: string): DeskOverlay | null {
538
+ const row = db
539
+ .prepare(
540
+ `SELECT * FROM desk_series WHERE id = ?`,
541
+ )
542
+ .get(accountId) as Record<string, unknown> | undefined;
543
+ if (!row) return null;
544
+ const platforms = db.prepare(`SELECT * FROM desk_platforms WHERE series_id = ?`).all(String(row.id)) as Array<
545
+ Record<string, unknown>
546
+ >;
547
+ return {
548
+ id: String(row.id),
549
+ accountId: String(row.account_id),
550
+ niche: String(row.niche),
551
+ language: String(row.language),
552
+ voices: JSON.parse(String(row.voices_json)) as string[],
553
+ hashtagPool: JSON.parse(String(row.hashtags_json)) as string[],
554
+ timezone: String(row.timezone),
555
+ minHoursBetweenPosts: Number(row.min_hours),
556
+ maxPostsPerDay: Number(row.max_posts),
557
+ windows: JSON.parse(String(row.windows_json)) as string[],
558
+ engageEnabled: Number(row.engage_enabled) === 1,
559
+ allowSpicy: Number(row.allow_spicy) === 1,
560
+ maxRepliesPerDay: Number(row.max_replies),
561
+ minSecondsBetween: Number(row.min_seconds),
562
+ youtubeDailyUploadCap: Number(row.upload_cap),
563
+ platforms: platforms.map((item) => ({
564
+ platform: String(item.platform),
565
+ enabled: Number(item.enabled) === 1,
566
+ config: JSON.parse(String(item.config_json)) as Record<string, unknown>,
567
+ })),
568
+ };
569
+ }
570
+
571
+ export function ackWarning(db: DatabaseSync, id: string): DeskResult {
572
+ const changed = db.prepare(`UPDATE desk_warnings SET acked_at = ? WHERE id = ?`).run(new Date().toISOString(), id);
573
+ return changed.changes ? ok() : refused(`Warning ${id} was not found.`);
574
+ }
575
+
576
+ export function deleteWarning(): DeskResult {
577
+ return refused("Warnings are acknowledged, not deleted.");
578
+ }
579
+
580
+ export function deleteJob(): DeskResult {
581
+ return refused("Jobs are cancelled, not deleted.");
582
+ }
583
+
584
+ export function deletePublication(): DeskResult {
585
+ return refused("Publications are the disclosure proof and cannot be deleted.");
586
+ }
587
+
588
+ export function deleteMetric(): DeskResult {
589
+ return refused("Metric snapshots cannot be deleted.");
590
+ }
591
+
592
+ export function deleteComment(): DeskResult {
593
+ return refused("The comment ledger cannot be deleted.");
594
+ }
595
+
596
+ function ownerAlive(owner: string): boolean {
597
+ const at = owner.lastIndexOf(":");
598
+ const pid = Number(owner.slice(at + 1));
599
+ if (owner.slice(0, at) !== os.hostname() || !Number.isInteger(pid) || pid <= 0) return false;
600
+ try {
601
+ process.kill(pid, 0);
602
+ return true;
603
+ } catch (error) {
604
+ return (error as NodeJS.ErrnoException).code === "EPERM";
605
+ }
606
+ }
607
+
608
+ export function retryJob(db: DatabaseSync, id: string, now: Date): DeskResult {
609
+ const row = db.prepare(`SELECT status, stage FROM jobs WHERE id = ?`).get(id) as { status: string; stage: string } | undefined;
610
+ if (!row) return refused(`Job ${id} was not found.`);
611
+ if (row.status === "awaiting_review") return refused("This job waits for review. Approve or reject it instead of retrying it.");
612
+ if (row.status === "leased" || row.status === "succeeded") {
613
+ return refused(`A ${row.status} job cannot be retried. Cancel a leased job first.`);
614
+ }
615
+ // A cancelled job whose worker is still running may be mid-upload. Starting it again now could post the video twice.
616
+ const owner = db.prepare(`SELECT lease_owner, last_error FROM jobs WHERE id = ?`).get(id) as { lease_owner: string | null; last_error: string | null } | undefined;
617
+ if (row.status === "blocked" && owner?.last_error?.startsWith("operator_cancel") && owner.lease_owner && ownerAlive(owner.lease_owner)) {
618
+ return refused("The worker that held this job is still running and may be uploading. Wait for it to stop, then retry.");
619
+ }
620
+ db.prepare(
621
+ `UPDATE jobs SET status = 'pending', attempt = 0, lease_until = NULL, lease_owner = NULL, lease_token = NULL, run_after = ?, last_error = NULL, updated_at = ? WHERE id = ?`,
622
+ ).run(now.toISOString(), now.toISOString(), id);
623
+ recordEvent(db, id, { stage: row.stage, status: "pending", code: "retry", message: null, durationMs: null, attempt: 0 });
624
+ return ok();
625
+ }
626
+
627
+ export function cancelJob(db: DatabaseSync, id: string, now: Date): DeskResult {
628
+ const row = db.prepare(`SELECT status, stage, attempt FROM jobs WHERE id = ?`).get(id) as
629
+ | { status: string; stage: string; attempt: number }
630
+ | undefined;
631
+ if (!row) return refused(`Job ${id} was not found.`);
632
+ if (row.status !== "leased") return refused("Only a leased job can be cancelled.");
633
+ const message = "operator_cancel: the operator cancelled the leased job";
634
+ // The claim token is cleared so the running worker's next write is refused. lease_owner stays until that worker says it stopped.
635
+ db.prepare(`UPDATE jobs SET status = 'blocked', lease_until = NULL, lease_token = NULL, last_error = ?, updated_at = ? WHERE id = ?`).run(
636
+ message,
637
+ now.toISOString(),
638
+ id,
639
+ );
640
+ recordTerminal(db, id, { stage: row.stage, status: "blocked", code: "operator_cancel", message, attempt: Number(row.attempt) });
641
+ return ok();
642
+ }
643
+
644
+ /** The code on a job that a person rejected in review. It lives in `last_error` and in the `render` stage's event. */
645
+ export const REVIEW_REJECTED = "review_rejected";
646
+
647
+ /** The review note a job keeps in its payload: who is waiting, and how the person decided. */
648
+ interface ReviewNote {
649
+ state: "waiting" | "approved" | "rejected";
650
+ at: string;
651
+ reason?: string;
652
+ }
653
+
654
+ function readPayload(raw: unknown): Record<string, unknown> {
655
+ try {
656
+ const parsed: unknown = JSON.parse(String(raw));
657
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
658
+ } catch {
659
+ return {};
660
+ }
661
+ }
662
+
663
+ /**
664
+ * A person approves a job that waits for review. The job goes back to `pending` and the next tick renders it from where
665
+ * it stopped. The update only matches a job that is still `awaiting_review`, so two approvals (two tabs, two workers)
666
+ * release it once and the second is refused.
667
+ */
668
+ export function approveJob(db: DatabaseSync, id: string, now: Date): DeskResult {
669
+ db.exec("BEGIN IMMEDIATE");
670
+ try {
671
+ const row = db.prepare(`SELECT status, stage, payload FROM jobs WHERE id = ?`).get(id) as { status: string; stage: string; payload: string } | undefined;
672
+ if (!row) {
673
+ db.exec("ROLLBACK");
674
+ return refused(`Job ${id} was not found.`);
675
+ }
676
+ if (row.status !== "awaiting_review") {
677
+ db.exec("ROLLBACK");
678
+ return refused(`Job ${id} is ${row.status}, not waiting for review.`);
679
+ }
680
+ const payload = readPayload(row.payload);
681
+ const note: ReviewNote = { state: "approved", at: now.toISOString() };
682
+ payload.review = note;
683
+ const changed = db
684
+ .prepare(
685
+ `UPDATE jobs SET status = 'pending', payload = ?, run_after = ?, lease_until = NULL, lease_owner = NULL, lease_token = NULL, last_error = NULL, updated_at = ?
686
+ WHERE id = ? AND status = 'awaiting_review'`,
687
+ )
688
+ .run(JSON.stringify(payload), now.toISOString(), now.toISOString(), id);
689
+ if (Number(changed.changes) === 0) {
690
+ db.exec("ROLLBACK");
691
+ return refused(`Job ${id} is no longer waiting for review.`);
692
+ }
693
+ recordEvent(db, id, { stage: row.stage, status: "approved", code: "review_approved", message: "Approved in review. The next tick renders it.", durationMs: null, attempt: 0 });
694
+ db.exec("COMMIT");
695
+ return ok();
696
+ } catch (error) {
697
+ db.exec("ROLLBACK");
698
+ throw error;
699
+ }
700
+ }
701
+
702
+ /** A person rejects a job that waits for review. It ends `rejected` with `review_rejected: <reason>`; it never renders. */
703
+ export function rejectJob(db: DatabaseSync, id: string, reason: string | null, now: Date): DeskResult {
704
+ const text = (reason ?? "").trim() || "Rejected in review.";
705
+ db.exec("BEGIN IMMEDIATE");
706
+ try {
707
+ const row = db.prepare(`SELECT status, stage, payload FROM jobs WHERE id = ?`).get(id) as { status: string; stage: string; payload: string } | undefined;
708
+ if (!row) {
709
+ db.exec("ROLLBACK");
710
+ return refused(`Job ${id} was not found.`);
711
+ }
712
+ if (row.status !== "awaiting_review") {
713
+ db.exec("ROLLBACK");
714
+ return refused(`Job ${id} is ${row.status}, not waiting for review.`);
715
+ }
716
+ const payload = readPayload(row.payload);
717
+ const note: ReviewNote = { state: "rejected", at: now.toISOString(), reason: text };
718
+ payload.review = note;
719
+ const message = `${REVIEW_REJECTED}: ${text}`;
720
+ const changed = db
721
+ .prepare(
722
+ `UPDATE jobs SET status = 'rejected', payload = ?, lease_until = NULL, lease_owner = NULL, lease_token = NULL, last_error = ?, finished_at = ?, updated_at = ?
723
+ WHERE id = ? AND status = 'awaiting_review'`,
724
+ )
725
+ .run(JSON.stringify(payload), message, now.toISOString(), now.toISOString(), id);
726
+ if (Number(changed.changes) === 0) {
727
+ db.exec("ROLLBACK");
728
+ return refused(`Job ${id} is no longer waiting for review.`);
729
+ }
730
+ recordTerminal(db, id, { stage: row.stage, status: "rejected", code: REVIEW_REJECTED, message: text, attempt: 0 });
731
+ db.exec("COMMIT");
732
+ return ok();
733
+ } catch (error) {
734
+ db.exec("ROLLBACK");
735
+ throw error;
736
+ }
737
+ }
738
+
739
+ export function awaitingReviewCount(db: DatabaseSync, seriesId?: string): number {
740
+ const row = (seriesId
741
+ ? db.prepare(`SELECT COUNT(*) AS n FROM jobs WHERE status = 'awaiting_review' AND account_id = ?`).get(seriesId)
742
+ : db.prepare(`SELECT COUNT(*) AS n FROM jobs WHERE status = 'awaiting_review'`).get()) as { n: number };
743
+ return Number(row.n);
744
+ }
745
+
746
+ type Row = Record<string, unknown>;
747
+
748
+ const str = (value: unknown): string => String(value);
749
+ const strOrNull = (value: unknown): string | null => (value ? String(value) : null);
750
+ const numOrNull = (value: unknown): number | null => (value == null ? null : Number(value));
751
+
752
+ function eventView(row: JobEventRow) {
753
+ return {
754
+ id: row.id,
755
+ at: row.at,
756
+ stage: row.stage,
757
+ attempt: row.attempt ?? 1,
758
+ status: row.status,
759
+ code: row.code,
760
+ message: row.message,
761
+ durationMs: row.durationMs,
762
+ };
763
+ }
764
+
765
+ interface RenderedFacts {
766
+ durationSec: number | null;
767
+ width: number | null;
768
+ height: number | null;
769
+ }
770
+
771
+ function outputFor(dataDir: string | null, jobId: string, rendered: RenderedFacts = { durationSec: null, width: null, height: null }, stored: unknown = null) {
772
+ if (!dataDir) return { mp4: false, transcript: false, bytes: null, ...rendered };
773
+ const mp4 = mp4For(dataDir, jobId, stored);
774
+ const hasMp4 = existsSync(mp4);
775
+ return {
776
+ mp4: hasMp4,
777
+ transcript: existsSync(mp4.replace(/\.mp4$/, ".txt")),
778
+ bytes: hasMp4 ? statSync(mp4).size : null,
779
+ ...rendered,
780
+ };
781
+ }
782
+
783
+ /** One quality-gate result, as the run page shows it. */
784
+ export interface CheckView {
785
+ id: string;
786
+ phase: "before" | "after";
787
+ severity: "quality" | "compliance";
788
+ status: "pass" | "fail" | "skipped";
789
+ message: string;
790
+ fix: string | null;
791
+ attempt: number;
792
+ }
793
+
794
+ function checksView(raw: unknown): CheckView[] {
795
+ if (!Array.isArray(raw)) return [];
796
+ const out: CheckView[] = [];
797
+ for (const item of raw as Array<Record<string, unknown>>) {
798
+ if (!item || typeof item.id !== "string" || typeof item.message !== "string") continue;
799
+ out.push({
800
+ id: item.id,
801
+ phase: item.phase === "after" ? "after" : "before",
802
+ severity: item.severity === "compliance" ? "compliance" : "quality",
803
+ status: item.status === "fail" || item.status === "skipped" ? item.status : "pass",
804
+ message: redact(item.message),
805
+ fix: typeof item.fix === "string" ? redact(item.fix) : null,
806
+ attempt: typeof item.attempt === "number" ? item.attempt : 1,
807
+ });
808
+ }
809
+ return out;
810
+ }
811
+
812
+ function jobSummary(db: DatabaseSync, row: Row, dataDir: string | null) {
813
+ const id = str(row.id);
814
+ let title: string | null = null;
815
+ let storedPath: unknown = null;
816
+ let engine: { id: string; label: string } | null = null;
817
+ let rendered: RenderedFacts = { durationSec: null, width: null, height: null };
818
+ let scene: { source: "agent" | "default"; dir: string | null; kind: "html" | "tsx" | null } | null = null;
819
+ let checks: CheckView[] = [];
820
+ try {
821
+ const payload = JSON.parse(str(row.payload)) as {
822
+ content?: { title?: unknown; outputPath?: unknown } | null;
823
+ engine?: { id?: unknown; label?: unknown } | null;
824
+ rendered?: { durationSec?: unknown; width?: unknown; height?: unknown } | null;
825
+ scene?: { source?: unknown; dir?: unknown; kind?: unknown } | null;
826
+ checks?: unknown;
827
+ };
828
+ checks = checksView(payload.checks);
829
+ if (payload.scene && (payload.scene.source === "agent" || payload.scene.source === "default")) {
830
+ scene = {
831
+ source: payload.scene.source,
832
+ dir: typeof payload.scene.dir === "string" ? payload.scene.dir : null,
833
+ kind: payload.scene.kind === "html" || payload.scene.kind === "tsx" ? payload.scene.kind : null,
834
+ };
835
+ }
836
+ const facts = payload.rendered;
837
+ if (facts && typeof facts.durationSec === "number" && typeof facts.width === "number" && typeof facts.height === "number") {
838
+ rendered = { durationSec: facts.durationSec, width: facts.width, height: facts.height };
839
+ }
840
+ if (typeof payload.content?.title === "string") title = redact(payload.content.title);
841
+ storedPath = payload.content?.outputPath ?? null;
842
+ if (typeof payload.engine?.id === "string" && typeof payload.engine.label === "string") {
843
+ engine = { id: payload.engine.id, label: payload.engine.label };
844
+ }
845
+ } catch {
846
+ title = null;
847
+ }
848
+ return {
849
+ id,
850
+ seriesId: str(row.account_id),
851
+ accountId: str(row.account_id),
852
+ kind: str(row.kind),
853
+ stage: str(row.stage),
854
+ status: str(row.status),
855
+ attempt: Number(row.attempt),
856
+ maxAttempts: Number(row.max_attempts),
857
+ createdAt: str(row.created_at),
858
+ updatedAt: str(row.updated_at),
859
+ runAfter: str(row.run_after),
860
+ leaseUntil: strOrNull(row.lease_until),
861
+ lastError: row.last_error ? redact(str(row.last_error)) : null,
862
+ title,
863
+ engine,
864
+ scene,
865
+ checks,
866
+ events: eventsFor(db, id).map(eventView),
867
+ output: outputFor(dataDir, id, rendered, storedPath),
868
+ };
869
+ }
870
+
871
+ function publicationView(row: Row) {
872
+ return {
873
+ id: str(row.id),
874
+ jobId: str(row.job_id),
875
+ accountId: str(row.account_id),
876
+ platform: str(row.platform),
877
+ externalId: strOrNull(row.external_id),
878
+ disclosure: redactDeep(JSON.parse(str(row.disclosure))),
879
+ title: redact(str(row.title)),
880
+ hook: redact(str(row.hook)),
881
+ hashtags: redactDeep(JSON.parse(str(row.hashtags))),
882
+ publishedAt: str(row.published_at),
883
+ };
884
+ }
885
+
886
+ export function buildRunDetail(db: DatabaseSync, id: string, dataDir: string | null) {
887
+ const row = db.prepare(`SELECT * FROM jobs WHERE id = ?`).get(id) as Row | undefined;
888
+ if (!row) return null;
889
+ let data: {
890
+ content?: { title?: string; hook?: string; voiceoverScript?: string; hashtags?: string[] } | null;
891
+ platforms?: Record<string, { phase?: string; errorCode?: string | null; errorMessage?: string | null; externalId?: string | null }>;
892
+ } = {};
893
+ try {
894
+ data = JSON.parse(str(row.payload));
895
+ } catch {
896
+ data = {};
897
+ }
898
+ const content = data.content
899
+ ? {
900
+ title: redact(data.content.title ?? ""),
901
+ hook: redact(data.content.hook ?? ""),
902
+ script: redact(data.content.voiceoverScript ?? ""),
903
+ hashtags: (data.content.hashtags ?? []).map((item) => redact(String(item))),
904
+ }
905
+ : null;
906
+ const publish = Object.entries(data.platforms ?? {}).map(([platform, session]) => ({
907
+ platform,
908
+ phase: session?.phase ?? "idle",
909
+ errorCode: session?.errorCode ?? null,
910
+ errorMessage: session?.errorMessage ? redact(session.errorMessage) : null,
911
+ externalId: session?.externalId ?? null,
912
+ }));
913
+ const publications = (
914
+ db.prepare(`SELECT * FROM publications WHERE job_id = ? ORDER BY published_at`).all(id) as Row[]
915
+ ).map(publicationView);
916
+ return { ...jobSummary(db, row, dataDir), content, publish, publications };
917
+ }
918
+
919
+ /** What the Review screen shows for each job that waits: the script, the scene, and every check that ran. */
920
+ export function reviewQueue(db: DatabaseSync, dataDir: string | null) {
921
+ const rows = db.prepare(`SELECT id FROM jobs WHERE status = 'awaiting_review' ORDER BY updated_at ASC, rowid ASC`).all() as Array<{ id: string }>;
922
+ const items = [];
923
+ for (const row of rows) {
924
+ const detail = buildRunDetail(db, row.id, dataDir);
925
+ if (!detail) continue;
926
+ items.push({
927
+ id: detail.id,
928
+ seriesId: detail.seriesId,
929
+ title: detail.title,
930
+ waitingSince: detail.updatedAt,
931
+ content: detail.content,
932
+ scene: detail.scene,
933
+ engine: detail.engine,
934
+ checks: detail.checks,
935
+ approve: `npx vfrey desk job approve --id ${detail.id}`,
936
+ reject: `npx vfrey desk job reject --id ${detail.id} --reason "<why>"`,
937
+ });
938
+ }
939
+ return items;
940
+ }
941
+
942
+ const DAY_MS = 24 * 60 * 60 * 1000;
943
+
944
+ function seriesView(db: DatabaseSync, row: Row, now: Date) {
945
+ const id = str(row.id);
946
+ const weekAgo = new Date(now.getTime() - 7 * DAY_MS).toISOString();
947
+ const posts = db
948
+ .prepare(`SELECT COUNT(DISTINCT job_id) AS n FROM publications WHERE account_id = ? AND published_at >= ?`)
949
+ .get(id, weekAgo) as { n: number };
950
+ const postsLast7d = Number(posts.n);
951
+ const target = Number(row.max_posts) * 7;
952
+ const day = now.toISOString().slice(0, 10);
953
+ const uploads = db
954
+ .prepare(`SELECT COUNT(*) AS n FROM rate_events WHERE bucket LIKE 'youtube:uploads:%' AND bucket LIKE ?`)
955
+ .get(`%:${day}`) as { n: number };
956
+ const replies = db
957
+ .prepare(`SELECT COUNT(*) AS n FROM comments_handled WHERE account_id = ? AND action = 'replied' AND created_at >= ?`)
958
+ .get(id, `${day}T00:00:00.000Z`) as { n: number };
959
+ const lastReply = db
960
+ .prepare(`SELECT created_at FROM comments_handled WHERE account_id = ? AND action = 'replied' ORDER BY created_at DESC LIMIT 1`)
961
+ .get(id) as { created_at: string } | undefined;
962
+ return {
963
+ id,
964
+ accountId: str(row.account_id),
965
+ niche: str(row.niche),
966
+ language: str(row.language),
967
+ autopilot: Number(row.autopilot) === 1,
968
+ paused: Number(row.paused) === 1,
969
+ timezone: str(row.timezone),
970
+ minHoursBetweenPosts: Number(row.min_hours),
971
+ maxPostsPerDay: Number(row.max_posts),
972
+ windows: JSON.parse(str(row.windows_json)) as string[],
973
+ voices: JSON.parse(str(row.voices_json)) as string[],
974
+ hashtagPool: JSON.parse(str(row.hashtags_json)) as string[],
975
+ engageEnabled: Number(row.engage_enabled) === 1,
976
+ allowSpicy: Number(row.allow_spicy) === 1,
977
+ maxRepliesPerDay: Number(row.max_replies),
978
+ minSecondsBetween: Number(row.min_seconds),
979
+ youtubeDailyUploadCap: Number(row.upload_cap),
980
+ stylePath: str(row.style_path),
981
+ engine: strOrNull(row.engine),
982
+ live: Number(row.live) === 1,
983
+ publishEnabled: Number(row.publish_on) === 1,
984
+ reviewEnabled: Number(row.review_on) === 1,
985
+ events: seriesEvents(db, id, 8),
986
+ cadence: { label: cadenceHealth(postsLast7d, target), postsLast7d, target },
987
+ quota: {
988
+ uploadsToday: Number(uploads.n),
989
+ repliesToday: Number(replies.n),
990
+ lastReplyAt: lastReply?.created_at ?? null,
991
+ },
992
+ };
993
+ }
994
+
995
+ /** The database-derived fields of `State` in docs/spec/contracts/desk-api.md. */
996
+ export function buildSnapshot(db: DatabaseSync, userId: string, dataDir: string | null = null): Record<string, unknown> {
997
+ const now = new Date();
998
+ const accounts = (db.prepare(`SELECT id, name, owner_id FROM desk_accounts ORDER BY id`).all() as Row[]).map((row) => ({
999
+ id: str(row.id),
1000
+ name: str(row.name),
1001
+ ownerId: strOrNull(row.owner_id),
1002
+ seriesCount: Number(
1003
+ (db.prepare(`SELECT COUNT(*) AS n FROM desk_series WHERE account_id = ?`).get(str(row.id)) as { n: number }).n,
1004
+ ),
1005
+ }));
1006
+ const credentials = (
1007
+ db.prepare(`SELECT owner_kind, owner_id, field, set_at FROM desk_secrets ORDER BY owner_kind, owner_id, field`).all() as Row[]
1008
+ ).map((row) => ({ ownerKind: str(row.owner_kind), ownerId: str(row.owner_id), field: str(row.field), present: true, setAt: strOrNull(row.set_at) }));
1009
+ const series = (db.prepare(`SELECT * FROM desk_series ORDER BY id`).all() as Row[]).map((row) => seriesView(db, row, now));
1010
+ const platforms = (db.prepare(`SELECT * FROM desk_platforms ORDER BY series_id, platform`).all() as Row[]).map((row) => ({
1011
+ seriesId: str(row.series_id),
1012
+ platform: str(row.platform),
1013
+ enabled: Number(row.enabled) === 1,
1014
+ config: redactDeep(JSON.parse(str(row.config_json))),
1015
+ }));
1016
+ const sources = (db.prepare(`SELECT * FROM desk_sources ORDER BY id`).all() as Row[]).map((row) => ({
1017
+ id: str(row.id),
1018
+ seriesId: str(row.series_id),
1019
+ kind: str(row.kind),
1020
+ value: str(row.value),
1021
+ lastStatus: strOrNull(row.last_status),
1022
+ lastFetchAt: strOrNull(row.last_fetch_at),
1023
+ lastCount: numOrNull(row.last_count),
1024
+ lastCode: strOrNull(row.last_code),
1025
+ }));
1026
+ const jobs = (db.prepare(`SELECT * FROM jobs ORDER BY created_at DESC, rowid DESC LIMIT 200`).all() as Row[]).map((row) =>
1027
+ jobSummary(db, row, dataDir),
1028
+ );
1029
+ const publications = (db.prepare(`SELECT * FROM publications ORDER BY published_at`).all() as Row[]).map(publicationView);
1030
+ const seenPosts = new Set<string>();
1031
+ const metrics: Array<Record<string, unknown>> = [];
1032
+ for (const row of db.prepare(`SELECT * FROM metrics ORDER BY captured_at DESC, rowid DESC`).all() as Row[]) {
1033
+ const post = `${str(row.platform)}:${str(row.external_id)}`;
1034
+ if (seenPosts.has(post)) continue;
1035
+ seenPosts.add(post);
1036
+ metrics.push({
1037
+ id: str(row.id),
1038
+ accountId: str(row.account_id),
1039
+ platform: str(row.platform),
1040
+ externalId: str(row.external_id),
1041
+ capturedAt: str(row.captured_at),
1042
+ views: numOrNull(row.views),
1043
+ watchTimeSec: numOrNull(row.watch_time_sec),
1044
+ retention: numOrNull(row.retention),
1045
+ estimatedRevenue: str(row.revenue_status) === "present" ? numOrNull(row.estimated_revenue) : null,
1046
+ revenueStatus: str(row.revenue_status),
1047
+ });
1048
+ }
1049
+ const comments = (db.prepare(`SELECT * FROM comments_handled ORDER BY created_at`).all() as Row[]).map((row) => ({
1050
+ platform: str(row.platform),
1051
+ commentId: str(row.comment_id),
1052
+ accountId: str(row.account_id),
1053
+ action: str(row.action),
1054
+ tone: strOrNull(row.tone),
1055
+ replyText: row.reply_text ? redact(str(row.reply_text)) : null,
1056
+ createdAt: str(row.created_at),
1057
+ }));
1058
+ const warnings = (
1059
+ db.prepare(`SELECT * FROM desk_warnings ORDER BY (acked_at IS NOT NULL), created_at DESC, rowid DESC`).all() as Row[]
1060
+ ).map((row) => ({
1061
+ id: str(row.id),
1062
+ accountId: strOrNull(row.account_id),
1063
+ jobId: strOrNull(row.job_id),
1064
+ code: str(row.code),
1065
+ message: redact(str(row.message)),
1066
+ createdAt: str(row.created_at),
1067
+ ackedAt: strOrNull(row.acked_at),
1068
+ }));
1069
+ return {
1070
+ accounts,
1071
+ credentials,
1072
+ series,
1073
+ platforms,
1074
+ sources,
1075
+ mode: readMode(db, userId),
1076
+ jobs,
1077
+ publications,
1078
+ metrics,
1079
+ comments,
1080
+ warnings,
1081
+ };
1082
+ }
1083
+
1084
+ /** Tables copied from a legacy per-folder database. Every one has a primary key, so OR IGNORE keeps existing rows. */
1085
+ const IMPORT_TABLES = [
1086
+ "jobs",
1087
+ "publications",
1088
+ "metrics",
1089
+ "comments_handled",
1090
+ "source_seen",
1091
+ "account_health",
1092
+ "desk_warnings",
1093
+ "desk_accounts",
1094
+ "desk_series",
1095
+ "desk_platforms",
1096
+ "desk_sources",
1097
+ "desk_settings",
1098
+ ];
1099
+
1100
+ function sharedColumns(db: DatabaseSync, table: string, skip: string[] = []): string[] {
1101
+ const names = (schema: string) =>
1102
+ (db.prepare(`PRAGMA ${schema}.table_info(${table})`).all() as Array<{ name: string }>).map((row) => row.name);
1103
+ const legacy = new Set(names("legacy"));
1104
+ return names("main").filter((name) => legacy.has(name) && !skip.includes(name));
1105
+ }
1106
+
1107
+ /**
1108
+ * Merges a legacy `<dir>/pipeline.sqlite` into this database. Existing rows are kept, so a second run adds nothing.
1109
+ * `rate_events` and `desk_secrets` are not copied: rate windows are short, and old secrets use a cipher nothing reads.
1110
+ */
1111
+ export function importLegacy(
1112
+ db: DatabaseSync,
1113
+ targetDir: string | null,
1114
+ fromDir: string,
1115
+ ): { imported: Record<string, number>; files: number } {
1116
+ const file = path.join(fromDir, "pipeline.sqlite");
1117
+ db.prepare(`ATTACH DATABASE ? AS legacy`).run(`${pathToFileURL(file).href}?mode=ro`);
1118
+ const imported: Record<string, number> = {};
1119
+ try {
1120
+ const present = new Set(
1121
+ (db.prepare(`SELECT name FROM legacy.sqlite_master WHERE type = 'table'`).all() as Array<{ name: string }>).map((row) => row.name),
1122
+ );
1123
+ db.exec("BEGIN");
1124
+ try {
1125
+ for (const table of IMPORT_TABLES) {
1126
+ if (!present.has(table)) continue;
1127
+ const columns = sharedColumns(db, table);
1128
+ const list = columns.join(", ");
1129
+ imported[table] = Number(
1130
+ db.prepare(`INSERT OR IGNORE INTO main.${table} (${list}) SELECT ${list} FROM legacy.${table}`).run().changes,
1131
+ );
1132
+ }
1133
+ if (present.has("job_events")) {
1134
+ const columns = sharedColumns(db, "job_events", ["id"]);
1135
+ const list = columns.join(", ");
1136
+ imported.job_events = Number(
1137
+ db
1138
+ .prepare(
1139
+ `INSERT INTO main.job_events (${list}) SELECT ${list} FROM legacy.job_events l
1140
+ WHERE NOT EXISTS (
1141
+ SELECT 1 FROM main.job_events m
1142
+ WHERE m.job_id = l.job_id AND m.at = l.at AND m.stage = l.stage AND m.status = l.status
1143
+ )`,
1144
+ )
1145
+ .run().changes,
1146
+ );
1147
+ }
1148
+ db.exec("COMMIT");
1149
+ } catch (error) {
1150
+ db.exec("ROLLBACK");
1151
+ throw error;
1152
+ }
1153
+ } finally {
1154
+ db.exec("DETACH DATABASE legacy");
1155
+ }
1156
+ let files = 0;
1157
+ const rendersFrom = path.join(fromDir, "renders");
1158
+ if (targetDir && existsSync(rendersFrom)) {
1159
+ const rendersTo = path.join(targetDir, "renders");
1160
+ for (const name of readdirSync(rendersFrom)) {
1161
+ if (!/\.(mp4|txt)$/.test(name)) continue;
1162
+ const to = path.join(rendersTo, name);
1163
+ if (existsSync(to)) continue;
1164
+ mkdirSync(rendersTo, { recursive: true });
1165
+ copyFileSync(path.join(rendersFrom, name), to);
1166
+ files += 1;
1167
+ }
1168
+ }
1169
+ return { imported, files };
1170
+ }