@storyshelf/app 0.6.2 → 0.7.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 (361) hide show
  1. package/README.md +42 -4
  2. package/dist/app-types.d.mts +1 -3
  3. package/dist/asset-manifest.mjs +13 -5
  4. package/dist/asset-manifest.mjs.map +1 -1
  5. package/dist/assets/{htmx.min.js → htmx.mjs} +10 -1
  6. package/dist/assets/htmx.mjs.map +1 -0
  7. package/dist/assets/mark.mjs +10 -0
  8. package/dist/assets/mark.mjs.map +1 -0
  9. package/dist/boot-tasks.mjs +80 -0
  10. package/dist/boot-tasks.mjs.map +1 -0
  11. package/dist/build-state-flags.mjs +18 -0
  12. package/dist/build-state-flags.mjs.map +1 -0
  13. package/dist/build-state-setup.mjs +75 -0
  14. package/dist/build-state-setup.mjs.map +1 -0
  15. package/dist/capture-alerts.mjs +46 -0
  16. package/dist/capture-alerts.mjs.map +1 -0
  17. package/dist/capture-process.mjs +59 -0
  18. package/dist/capture-process.mjs.map +1 -0
  19. package/dist/capture-setup.mjs +95 -117
  20. package/dist/capture-setup.mjs.map +1 -1
  21. package/dist/connections.mjs +34 -0
  22. package/dist/connections.mjs.map +1 -0
  23. package/dist/consumer-setup.mjs +58 -0
  24. package/dist/consumer-setup.mjs.map +1 -0
  25. package/dist/git-status.mjs +46 -0
  26. package/dist/git-status.mjs.map +1 -0
  27. package/dist/hidden-data.mjs +24 -0
  28. package/dist/hidden-data.mjs.map +1 -0
  29. package/dist/index.d.mts +3 -3
  30. package/dist/index.mjs +203 -125
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/insights/budget.mjs +49 -0
  33. package/dist/insights/budget.mjs.map +1 -0
  34. package/dist/insights/deps.mjs +21 -0
  35. package/dist/insights/deps.mjs.map +1 -0
  36. package/dist/insights/errors.mjs +22 -0
  37. package/dist/insights/errors.mjs.map +1 -0
  38. package/dist/insights/health-panel.mjs +40 -0
  39. package/dist/insights/health-panel.mjs.map +1 -0
  40. package/dist/insights/job.mjs +103 -0
  41. package/dist/insights/job.mjs.map +1 -0
  42. package/dist/insights/load-health.mjs +59 -0
  43. package/dist/insights/load-health.mjs.map +1 -0
  44. package/dist/insights/load-triage.mjs +98 -0
  45. package/dist/insights/load-triage.mjs.map +1 -0
  46. package/dist/insights/panel.mjs +28 -0
  47. package/dist/insights/panel.mjs.map +1 -0
  48. package/dist/insights/request.mjs +113 -0
  49. package/dist/insights/request.mjs.map +1 -0
  50. package/dist/insights/stale.mjs +21 -0
  51. package/dist/insights/stale.mjs.map +1 -0
  52. package/dist/insights/usage-report.mjs +60 -0
  53. package/dist/insights/usage-report.mjs.map +1 -0
  54. package/dist/insights/view.mjs +48 -0
  55. package/dist/insights/view.mjs.map +1 -0
  56. package/dist/job-canceller.mjs +14 -0
  57. package/dist/job-canceller.mjs.map +1 -0
  58. package/dist/job-queue.mjs +51 -0
  59. package/dist/job-queue.mjs.map +1 -0
  60. package/dist/jobs-setup.mjs +132 -0
  61. package/dist/jobs-setup.mjs.map +1 -0
  62. package/dist/lifecycle.mjs +91 -19
  63. package/dist/lifecycle.mjs.map +1 -1
  64. package/dist/middleware/auth-gate.mjs +4 -2
  65. package/dist/middleware/auth-gate.mjs.map +1 -1
  66. package/dist/middleware/rate-limit.mjs +41 -33
  67. package/dist/middleware/rate-limit.mjs.map +1 -1
  68. package/dist/middleware/request-log.mjs +3 -2
  69. package/dist/middleware/request-log.mjs.map +1 -1
  70. package/dist/middleware/require-review.mjs +51 -0
  71. package/dist/middleware/require-review.mjs.map +1 -0
  72. package/dist/middleware/require-testing.mjs +41 -0
  73. package/dist/middleware/require-testing.mjs.map +1 -0
  74. package/dist/middleware/setup-gate.mjs.map +1 -1
  75. package/dist/middleware/store-scope.mjs +12 -2
  76. package/dist/middleware/store-scope.mjs.map +1 -1
  77. package/dist/notifications-pause.mjs +33 -0
  78. package/dist/notifications-pause.mjs.map +1 -0
  79. package/dist/notify.d.mts +2 -1
  80. package/dist/notify.mjs +78 -39
  81. package/dist/notify.mjs.map +1 -1
  82. package/dist/pages/a11y-violations.mjs +116 -0
  83. package/dist/pages/a11y-violations.mjs.map +1 -0
  84. package/dist/pages/admin-ai.mjs +78 -0
  85. package/dist/pages/admin-ai.mjs.map +1 -0
  86. package/dist/pages/admin-system-origin.mjs +123 -0
  87. package/dist/pages/admin-system-origin.mjs.map +1 -0
  88. package/dist/pages/admin-system-targets.mjs +53 -0
  89. package/dist/pages/admin-system-targets.mjs.map +1 -0
  90. package/dist/pages/admin-system.mjs +63 -13
  91. package/dist/pages/admin-system.mjs.map +1 -1
  92. package/dist/pages/build-detail.mjs +104 -76
  93. package/dist/pages/build-detail.mjs.map +1 -1
  94. package/dist/pages/build-diff-actions.mjs +87 -33
  95. package/dist/pages/build-diff-actions.mjs.map +1 -1
  96. package/dist/pages/build-diff-comments.mjs +16 -14
  97. package/dist/pages/build-diff-comments.mjs.map +1 -1
  98. package/dist/pages/build-diff-environment.mjs +82 -0
  99. package/dist/pages/build-diff-environment.mjs.map +1 -0
  100. package/dist/pages/build-diff-header.mjs +22 -4
  101. package/dist/pages/build-diff-header.mjs.map +1 -1
  102. package/dist/pages/build-diff-insight.mjs +113 -0
  103. package/dist/pages/build-diff-insight.mjs.map +1 -0
  104. package/dist/pages/build-diff-model.mjs +55 -10
  105. package/dist/pages/build-diff-model.mjs.map +1 -1
  106. package/dist/pages/build-diff-nav.mjs +28 -12
  107. package/dist/pages/build-diff-nav.mjs.map +1 -1
  108. package/dist/pages/build-diff-shortcuts.mjs +5 -1
  109. package/dist/pages/build-diff-shortcuts.mjs.map +1 -1
  110. package/dist/pages/build-diff-usage.mjs +63 -0
  111. package/dist/pages/build-diff-usage.mjs.map +1 -0
  112. package/dist/pages/build-diff-viewer.mjs +62 -5
  113. package/dist/pages/build-diff-viewer.mjs.map +1 -1
  114. package/dist/pages/build-diff.mjs +34 -13
  115. package/dist/pages/build-diff.mjs.map +1 -1
  116. package/dist/pages/build-tests.mjs +74 -0
  117. package/dist/pages/build-tests.mjs.map +1 -0
  118. package/dist/pages/component-detail.mjs +249 -0
  119. package/dist/pages/component-detail.mjs.map +1 -0
  120. package/dist/pages/component-stories.mjs +168 -0
  121. package/dist/pages/component-stories.mjs.map +1 -0
  122. package/dist/pages/compute-jobs.mjs +13 -26
  123. package/dist/pages/compute-jobs.mjs.map +1 -1
  124. package/dist/pages/design-links.mjs +182 -0
  125. package/dist/pages/design-links.mjs.map +1 -0
  126. package/dist/pages/environment-change.mjs +53 -0
  127. package/dist/pages/environment-change.mjs.map +1 -0
  128. package/dist/pages/health-panel.mjs +126 -0
  129. package/dist/pages/health-panel.mjs.map +1 -0
  130. package/dist/pages/library-grid.mjs +105 -0
  131. package/dist/pages/library-grid.mjs.map +1 -0
  132. package/dist/pages/library-model.mjs +47 -0
  133. package/dist/pages/library-model.mjs.map +1 -0
  134. package/dist/pages/library-snapshots.mjs +130 -0
  135. package/dist/pages/library-snapshots.mjs.map +1 -0
  136. package/dist/pages/library.mjs +112 -218
  137. package/dist/pages/library.mjs.map +1 -1
  138. package/dist/pages/notifications-paused.mjs +22 -0
  139. package/dist/pages/notifications-paused.mjs.map +1 -0
  140. package/dist/pages/profile.mjs +53 -48
  141. package/dist/pages/profile.mjs.map +1 -1
  142. package/dist/pages/project-builds-table.mjs +2 -2
  143. package/dist/pages/project-builds-table.mjs.map +1 -1
  144. package/dist/pages/project-builds.mjs +51 -21
  145. package/dist/pages/project-builds.mjs.map +1 -1
  146. package/dist/pages/project-settings.mjs +51 -12
  147. package/dist/pages/project-settings.mjs.map +1 -1
  148. package/dist/pages/projects-card.mjs +14 -1
  149. package/dist/pages/projects-card.mjs.map +1 -1
  150. package/dist/pages/projects.mjs +6 -2
  151. package/dist/pages/projects.mjs.map +1 -1
  152. package/dist/pages/review-changeset.mjs +116 -0
  153. package/dist/pages/review-changeset.mjs.map +1 -0
  154. package/dist/pages/review-comments.mjs +103 -0
  155. package/dist/pages/review-comments.mjs.map +1 -0
  156. package/dist/pages/review-verdict.mjs +102 -0
  157. package/dist/pages/review-verdict.mjs.map +1 -0
  158. package/dist/pages/settings-ai.mjs +69 -0
  159. package/dist/pages/settings-ai.mjs.map +1 -0
  160. package/dist/pages/settings-capture.mjs +124 -0
  161. package/dist/pages/settings-capture.mjs.map +1 -0
  162. package/dist/pages/settings-design-credential.mjs +99 -0
  163. package/dist/pages/settings-design-credential.mjs.map +1 -0
  164. package/dist/pages/settings-design-links.mjs +179 -0
  165. package/dist/pages/settings-design-links.mjs.map +1 -0
  166. package/dist/pages/settings-design.mjs +48 -0
  167. package/dist/pages/settings-design.mjs.map +1 -0
  168. package/dist/pages/settings-general.mjs +46 -21
  169. package/dist/pages/settings-general.mjs.map +1 -1
  170. package/dist/pages/settings-labels.mjs +43 -2
  171. package/dist/pages/settings-labels.mjs.map +1 -1
  172. package/dist/pages/settings-links.mjs +112 -0
  173. package/dist/pages/settings-links.mjs.map +1 -0
  174. package/dist/pages/settings-notifications-git.mjs +128 -0
  175. package/dist/pages/settings-notifications-git.mjs.map +1 -0
  176. package/dist/pages/settings-notifications.mjs +79 -42
  177. package/dist/pages/settings-notifications.mjs.map +1 -1
  178. package/dist/pages/settings-tests.mjs +33 -2
  179. package/dist/pages/settings-tests.mjs.map +1 -1
  180. package/dist/pages/target-choices.mjs +39 -0
  181. package/dist/pages/target-choices.mjs.map +1 -0
  182. package/dist/publish-setup.mjs +89 -0
  183. package/dist/publish-setup.mjs.map +1 -0
  184. package/dist/retention-timer.mjs +2 -1
  185. package/dist/retention-timer.mjs.map +1 -1
  186. package/dist/review-events.mjs +48 -0
  187. package/dist/review-events.mjs.map +1 -0
  188. package/dist/routers/admin-pages.mjs +45 -3
  189. package/dist/routers/admin-pages.mjs.map +1 -1
  190. package/dist/routers/admin-public-origin.mjs +85 -0
  191. package/dist/routers/admin-public-origin.mjs.map +1 -0
  192. package/dist/routers/assets.mjs +9 -2
  193. package/dist/routers/assets.mjs.map +1 -1
  194. package/dist/routers/auth.mjs +1 -1
  195. package/dist/routers/baseline-guard.mjs +2 -2
  196. package/dist/routers/baseline-guard.mjs.map +1 -1
  197. package/dist/routers/baselines.handlers.mjs +64 -0
  198. package/dist/routers/baselines.handlers.mjs.map +1 -0
  199. package/dist/routers/baselines.mjs +125 -0
  200. package/dist/routers/baselines.mjs.map +1 -0
  201. package/dist/routers/build-cancel.mjs +68 -0
  202. package/dist/routers/build-cancel.mjs.map +1 -0
  203. package/dist/routers/build-changeset.mjs +70 -0
  204. package/dist/routers/build-changeset.mjs.map +1 -0
  205. package/dist/routers/build-reviews.handlers.mjs +86 -0
  206. package/dist/routers/build-reviews.handlers.mjs.map +1 -0
  207. package/dist/routers/build-reviews.mjs +74 -0
  208. package/dist/routers/build-reviews.mjs.map +1 -0
  209. package/dist/routers/builds.handlers.mjs +46 -55
  210. package/dist/routers/builds.handlers.mjs.map +1 -1
  211. package/dist/routers/builds.mjs +71 -17
  212. package/dist/routers/builds.mjs.map +1 -1
  213. package/dist/routers/capture-config.mjs +35 -0
  214. package/dist/routers/capture-config.mjs.map +1 -0
  215. package/dist/routers/comments.mjs +41 -15
  216. package/dist/routers/comments.mjs.map +1 -1
  217. package/dist/routers/component-catalog.handlers.mjs +71 -0
  218. package/dist/routers/component-catalog.handlers.mjs.map +1 -0
  219. package/dist/routers/component.handlers.mjs +121 -0
  220. package/dist/routers/component.handlers.mjs.map +1 -0
  221. package/dist/routers/connections.mjs +118 -0
  222. package/dist/routers/connections.mjs.map +1 -0
  223. package/dist/routers/design-connect.handlers.mjs +44 -0
  224. package/dist/routers/design-connect.handlers.mjs.map +1 -0
  225. package/dist/routers/design.handlers.mjs +114 -0
  226. package/dist/routers/design.handlers.mjs.map +1 -0
  227. package/dist/routers/health-report.mjs +2 -3
  228. package/dist/routers/health-report.mjs.map +1 -1
  229. package/dist/routers/health.mjs +4 -3
  230. package/dist/routers/health.mjs.map +1 -1
  231. package/dist/routers/helpers.mjs +19 -3
  232. package/dist/routers/helpers.mjs.map +1 -1
  233. package/dist/routers/insights-ui.mjs +64 -0
  234. package/dist/routers/insights-ui.mjs.map +1 -0
  235. package/dist/routers/insights.handlers.mjs +45 -0
  236. package/dist/routers/insights.handlers.mjs.map +1 -0
  237. package/dist/routers/insights.mjs +189 -0
  238. package/dist/routers/insights.mjs.map +1 -0
  239. package/dist/routers/links.handlers.mjs +80 -0
  240. package/dist/routers/links.handlers.mjs.map +1 -0
  241. package/dist/routers/links.mjs +139 -0
  242. package/dist/routers/links.mjs.map +1 -0
  243. package/dist/routers/media.mjs +18 -4
  244. package/dist/routers/media.mjs.map +1 -1
  245. package/dist/routers/members.mjs +2 -1
  246. package/dist/routers/members.mjs.map +1 -1
  247. package/dist/routers/notification-channels.mjs +17 -23
  248. package/dist/routers/notification-channels.mjs.map +1 -1
  249. package/dist/routers/oauth-state.mjs +45 -0
  250. package/dist/routers/oauth-state.mjs.map +1 -0
  251. package/dist/routers/preview-base.mjs +38 -0
  252. package/dist/routers/preview-base.mjs.map +1 -0
  253. package/dist/routers/preview-token.mjs +51 -0
  254. package/dist/routers/preview-token.mjs.map +1 -0
  255. package/dist/routers/profile.mjs +3 -1
  256. package/dist/routers/profile.mjs.map +1 -1
  257. package/dist/routers/projects.mjs +17 -1
  258. package/dist/routers/projects.mjs.map +1 -1
  259. package/dist/routers/publish-upload.mjs +78 -0
  260. package/dist/routers/publish-upload.mjs.map +1 -0
  261. package/dist/routers/published-access.mjs +52 -0
  262. package/dist/routers/published-access.mjs.map +1 -0
  263. package/dist/routers/published-files.mjs +52 -0
  264. package/dist/routers/published-files.mjs.map +1 -0
  265. package/dist/routers/review-forms.mjs +72 -0
  266. package/dist/routers/review-forms.mjs.map +1 -0
  267. package/dist/routers/review-section.handlers.mjs +85 -0
  268. package/dist/routers/review-section.handlers.mjs.map +1 -0
  269. package/dist/routers/review-switch-off.mjs +29 -0
  270. package/dist/routers/review-switch-off.mjs.map +1 -0
  271. package/dist/routers/schemas.mjs +140 -22
  272. package/dist/routers/schemas.mjs.map +1 -1
  273. package/dist/routers/scoped-models.mjs +22 -0
  274. package/dist/routers/scoped-models.mjs.map +1 -0
  275. package/dist/routers/settings-ai.mjs +35 -0
  276. package/dist/routers/settings-ai.mjs.map +1 -0
  277. package/dist/routers/settings-capture.mjs +77 -0
  278. package/dist/routers/settings-capture.mjs.map +1 -0
  279. package/dist/routers/settings-design.mjs +103 -0
  280. package/dist/routers/settings-design.mjs.map +1 -0
  281. package/dist/routers/settings-general.mjs +55 -10
  282. package/dist/routers/settings-general.mjs.map +1 -1
  283. package/dist/routers/settings-git-channel.mjs +77 -0
  284. package/dist/routers/settings-git-channel.mjs.map +1 -0
  285. package/dist/routers/settings-labels.mjs +27 -1
  286. package/dist/routers/settings-labels.mjs.map +1 -1
  287. package/dist/routers/settings-links.mjs +63 -0
  288. package/dist/routers/settings-links.mjs.map +1 -0
  289. package/dist/routers/settings-members.mjs +4 -7
  290. package/dist/routers/settings-members.mjs.map +1 -1
  291. package/dist/routers/settings-notifications.mjs +15 -10
  292. package/dist/routers/settings-notifications.mjs.map +1 -1
  293. package/dist/routers/settings-tokens.mjs +2 -5
  294. package/dist/routers/settings-tokens.mjs.map +1 -1
  295. package/dist/routers/settings-webhooks.mjs +7 -24
  296. package/dist/routers/settings-webhooks.mjs.map +1 -1
  297. package/dist/routers/settings.handlers.mjs +107 -26
  298. package/dist/routers/settings.handlers.mjs.map +1 -1
  299. package/dist/routers/settings.mjs +16 -4
  300. package/dist/routers/settings.mjs.map +1 -1
  301. package/dist/routers/signed-storybook.mjs +53 -0
  302. package/dist/routers/signed-storybook.mjs.map +1 -0
  303. package/dist/routers/snapshot-groups.mjs +63 -0
  304. package/dist/routers/snapshot-groups.mjs.map +1 -0
  305. package/dist/routers/snapshots.mjs +103 -5
  306. package/dist/routers/snapshots.mjs.map +1 -1
  307. package/dist/routers/storybook.mjs +37 -88
  308. package/dist/routers/storybook.mjs.map +1 -1
  309. package/dist/routers/tokens.mjs +2 -5
  310. package/dist/routers/tokens.mjs.map +1 -1
  311. package/dist/routers/ui.mjs +34 -5
  312. package/dist/routers/ui.mjs.map +1 -1
  313. package/dist/routers/webhooks.mjs +5 -11
  314. package/dist/routers/webhooks.mjs.map +1 -1
  315. package/dist/routers/well-known.mjs +11 -4
  316. package/dist/routers/well-known.mjs.map +1 -1
  317. package/dist/routes/api-routes.mjs +93 -0
  318. package/dist/routes/api-routes.mjs.map +1 -0
  319. package/dist/routes/classification.mjs +38 -0
  320. package/dist/routes/classification.mjs.map +1 -0
  321. package/dist/routes/openapi-classes.mjs +22 -0
  322. package/dist/routes/openapi-classes.mjs.map +1 -0
  323. package/dist/routes/page-routes.mjs +104 -0
  324. package/dist/routes/page-routes.mjs.map +1 -0
  325. package/dist/runtime.mjs +18 -13
  326. package/dist/runtime.mjs.map +1 -1
  327. package/dist/serverless.mjs +5 -0
  328. package/dist/store.mjs +19 -1
  329. package/dist/store.mjs.map +1 -1
  330. package/dist/targets.mjs +46 -0
  331. package/dist/targets.mjs.map +1 -0
  332. package/dist/ui/buttons.mjs +16 -3
  333. package/dist/ui/buttons.mjs.map +1 -1
  334. package/dist/ui/design-reference.mjs +97 -0
  335. package/dist/ui/design-reference.mjs.map +1 -0
  336. package/dist/ui/document.mjs +1 -2
  337. package/dist/ui/document.mjs.map +1 -1
  338. package/dist/ui/forms.mjs +6 -5
  339. package/dist/ui/forms.mjs.map +1 -1
  340. package/dist/ui/icons/paths.mjs +1 -0
  341. package/dist/ui/icons/paths.mjs.map +1 -1
  342. package/dist/ui/preview-embed.mjs +82 -0
  343. package/dist/ui/preview-embed.mjs.map +1 -0
  344. package/dist/ui/shell/client-script.mjs +85 -0
  345. package/dist/ui/shell/client-script.mjs.map +1 -1
  346. package/dist/ui/shell/nav.mjs +12 -8
  347. package/dist/ui/shell/nav.mjs.map +1 -1
  348. package/dist/ui/shell/review-script.mjs +8 -0
  349. package/dist/ui/shell/review-script.mjs.map +1 -1
  350. package/dist/ui/shell/sidebar.mjs +2 -2
  351. package/dist/ui/shell/sidebar.mjs.map +1 -1
  352. package/package.json +11 -9
  353. package/dist/asset-paths.mjs +0 -23
  354. package/dist/asset-paths.mjs.map +0 -1
  355. package/dist/assets/mark.svg +0 -1
  356. package/dist/pages/settings-status.mjs +0 -137
  357. package/dist/pages/settings-status.mjs.map +0 -1
  358. package/dist/routers/settings-status.mjs +0 -77
  359. package/dist/routers/settings-status.mjs.map +0 -1
  360. package/dist/routers/status-configs.mjs +0 -108
  361. package/dist/routers/status-configs.mjs.map +0 -1
@@ -0,0 +1,49 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { notifySystemWith } from "../notify.mjs";
5
+ import { failWith } from "./errors.mjs";
6
+ import { AiBudgetAlertModel, AiUsageModel } from "@storyshelf/core/models";
7
+ import { countedTokens, crossedThresholds, utcDay, utcDayStart } from "@storyshelf/core/insights";
8
+ //#region src/insights/budget.ts
9
+ /** DB-counted budget: pre-flight limits, usage rows and threshold alerts. */
10
+ const HOUR_MS = 36e5;
11
+ /** Tokens counted so far today (UTC), summed across all instances from rows. */
12
+ async function dayTokens(deps, now = /* @__PURE__ */ new Date()) {
13
+ const rows = await new AiUsageModel(deps.db).listSince(utcDayStart(now));
14
+ const { visionWeight } = deps.ai.budget();
15
+ return rows.reduce((sum, row) => sum + countedTokens(row, row.images, { visionWeight }), 0);
16
+ }
17
+ /** Reject (429 + Retry-After) when the daily cap or the project's hourly limit is hit. */
18
+ async function assertWithinBudget(deps, projectId, now = /* @__PURE__ */ new Date()) {
19
+ const budget = deps.ai.budget();
20
+ if (await new AiUsageModel(deps.db).countCalls(projectId, (/* @__PURE__ */ new Date(now.getTime() - HOUR_MS)).toISOString()) >= budget.perProjectCallsPerHour) failWith(429, "ai-hourly-limit", 600);
21
+ if (budget.dailyTokens !== void 0 && await dayTokens(deps, now) >= budget.dailyTokens) {
22
+ const midnight = (/* @__PURE__ */ new Date(`${utcDay(now)}T00:00:00.000Z`)).getTime() + 864e5;
23
+ failWith(429, "ai-daily-budget", Math.max(1, Math.ceil((midnight - now.getTime()) / 1e3)));
24
+ }
25
+ }
26
+ async function fireAlerts(deps, thresholds, day, usedTokens, dailyTokens) {
27
+ const alerts = new AiBudgetAlertModel(deps.db);
28
+ for (const threshold of thresholds) if (await alerts.claim(day, threshold)) await notifySystemWith(deps.notify, "sys:ai-budget", {
29
+ threshold,
30
+ day,
31
+ usedTokens,
32
+ dailyTokens
33
+ });
34
+ }
35
+ /** Record one call's usage and fire `sys:ai-budget` for each newly crossed threshold. */
36
+ async function recordUsage(deps, input) {
37
+ const { dailyTokens, visionWeight } = deps.ai.budget();
38
+ const now = /* @__PURE__ */ new Date();
39
+ const before = dailyTokens === void 0 ? 0 : await dayTokens(deps, now);
40
+ await new AiUsageModel(deps.db).record(input);
41
+ if (dailyTokens !== void 0) {
42
+ const after = before + countedTokens(input, input.images, { visionWeight });
43
+ await fireAlerts(deps, crossedThresholds(before, after, dailyTokens), utcDay(now), after, dailyTokens);
44
+ }
45
+ }
46
+ //#endregion
47
+ export { assertWithinBudget, dayTokens, recordUsage };
48
+
49
+ //# sourceMappingURL=budget.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"budget.mjs","names":[],"sources":["../../src/insights/budget.ts"],"sourcesContent":["/** DB-counted budget: pre-flight limits, usage rows and threshold alerts. */\nimport { countedTokens, crossedThresholds, utcDay, utcDayStart } from \"@storyshelf/core/insights\";\nimport { AiBudgetAlertModel, AiUsageModel } from \"@storyshelf/core/models\";\nimport type { AiUsageInput } from \"@storyshelf/core/models\";\nimport { notifySystemWith } from \"../notify.ts\";\nimport type { InsightDeps } from \"./deps.ts\";\nimport { failWith } from \"./errors.ts\";\n\nconst HOUR_MS = 3_600_000;\n\n/** Tokens counted so far today (UTC), summed across all instances from rows. */\nexport async function dayTokens(deps: InsightDeps, now = new Date()): Promise<number> {\n const rows = await new AiUsageModel(deps.db).listSince(utcDayStart(now));\n const { visionWeight } = deps.ai.budget();\n return rows.reduce((sum, row) => sum + countedTokens(row, row.images, { visionWeight }), 0);\n}\n\n/** Reject (429 + Retry-After) when the daily cap or the project's hourly limit is hit. */\nexport async function assertWithinBudget(\n deps: InsightDeps,\n projectId: string,\n now = new Date(),\n): Promise<void> {\n const budget = deps.ai.budget();\n const calls = await new AiUsageModel(deps.db).countCalls(\n projectId,\n new Date(now.getTime() - HOUR_MS).toISOString(),\n );\n if (calls >= budget.perProjectCallsPerHour) {\n failWith(429, \"ai-hourly-limit\", 600);\n }\n if (budget.dailyTokens !== undefined && (await dayTokens(deps, now)) >= budget.dailyTokens) {\n const midnight = new Date(`${utcDay(now)}T00:00:00.000Z`).getTime() + 86_400_000;\n failWith(429, \"ai-daily-budget\", Math.max(1, Math.ceil((midnight - now.getTime()) / 1000)));\n }\n}\n\nasync function fireAlerts(\n deps: InsightDeps,\n thresholds: number[],\n day: string,\n usedTokens: number,\n dailyTokens: number,\n): Promise<void> {\n const alerts = new AiBudgetAlertModel(deps.db);\n for (const threshold of thresholds) {\n // eslint-disable-next-line no-await-in-loop -- claims must be sequential per threshold\n if (await alerts.claim(day, threshold)) {\n // eslint-disable-next-line no-await-in-loop -- ordered notifications\n await notifySystemWith(deps.notify, \"sys:ai-budget\", {\n threshold,\n day,\n usedTokens,\n dailyTokens,\n });\n }\n }\n}\n\n/** Record one call's usage and fire `sys:ai-budget` for each newly crossed threshold. */\nexport async function recordUsage(deps: InsightDeps, input: AiUsageInput): Promise<void> {\n const { dailyTokens, visionWeight } = deps.ai.budget();\n const now = new Date();\n const before = dailyTokens === undefined ? 0 : await dayTokens(deps, now);\n await new AiUsageModel(deps.db).record(input);\n if (dailyTokens !== undefined) {\n const after = before + countedTokens(input, input.images, { visionWeight });\n await fireAlerts(\n deps,\n crossedThresholds(before, after, dailyTokens),\n utcDay(now),\n after,\n dailyTokens,\n );\n }\n}\n"],"mappings":";;;;;;;;;AAQA,MAAM,UAAU;;AAGhB,eAAsB,UAAU,MAAmB,sBAAM,IAAI,KAAK,GAAoB;CACpF,MAAM,OAAO,MAAM,IAAI,aAAa,KAAK,EAAE,CAAC,CAAC,UAAU,YAAY,GAAG,CAAC;CACvE,MAAM,EAAE,iBAAiB,KAAK,GAAG,OAAO;CACxC,OAAO,KAAK,QAAQ,KAAK,QAAQ,MAAM,cAAc,KAAK,IAAI,QAAQ,EAAE,aAAa,CAAC,GAAG,CAAC;AAC5F;;AAGA,eAAsB,mBACpB,MACA,WACA,sBAAM,IAAI,KAAK,GACA;CACf,MAAM,SAAS,KAAK,GAAG,OAAO;CAK9B,IAAI,MAJgB,IAAI,aAAa,KAAK,EAAE,CAAC,CAAC,WAC5C,4BACA,IAAI,KAAK,IAAI,QAAQ,IAAI,OAAO,EAAA,CAAE,YAAY,CAChD,KACa,OAAO,wBAClB,SAAS,KAAK,mBAAmB,GAAG;CAEtC,IAAI,OAAO,gBAAgB,KAAA,KAAc,MAAM,UAAU,MAAM,GAAG,KAAM,OAAO,aAAa;EAC1F,MAAM,4BAAW,IAAI,KAAK,GAAG,OAAO,GAAG,EAAE,eAAe,EAAA,CAAE,QAAQ,IAAI;EACtE,SAAS,KAAK,mBAAmB,KAAK,IAAI,GAAG,KAAK,MAAM,WAAW,IAAI,QAAQ,KAAK,GAAI,CAAC,CAAC;CAC5F;AACF;AAEA,eAAe,WACb,MACA,YACA,KACA,YACA,aACe;CACf,MAAM,SAAS,IAAI,mBAAmB,KAAK,EAAE;CAC7C,KAAK,MAAM,aAAa,YAEtB,IAAI,MAAM,OAAO,MAAM,KAAK,SAAS,GAEnC,MAAM,iBAAiB,KAAK,QAAQ,iBAAiB;EACnD;EACA;EACA;EACA;CACF,CAAC;AAGP;;AAGA,eAAsB,YAAY,MAAmB,OAAoC;CACvF,MAAM,EAAE,aAAa,iBAAiB,KAAK,GAAG,OAAO;CACrD,MAAM,sBAAM,IAAI,KAAK;CACrB,MAAM,SAAS,gBAAgB,KAAA,IAAY,IAAI,MAAM,UAAU,MAAM,GAAG;CACxE,MAAM,IAAI,aAAa,KAAK,EAAE,CAAC,CAAC,OAAO,KAAK;CAC5C,IAAI,gBAAgB,KAAA,GAAW;EAC7B,MAAM,QAAQ,SAAS,cAAc,OAAO,MAAM,QAAQ,EAAE,aAAa,CAAC;EAC1E,MAAM,WACJ,MACA,kBAAkB,QAAQ,OAAO,WAAW,GAC5C,OAAO,GAAG,GACV,OACA,WACF;CACF;AACF"}
@@ -0,0 +1,21 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ //#region src/insights/deps.ts
6
+ /** Snapshot the current request store into explicit deps (null when AI is off). */
7
+ function insightDepsFromStore() {
8
+ const store = getStore();
9
+ if (!store.ai) return null;
10
+ return {
11
+ ai: store.ai,
12
+ db: store.db,
13
+ storage: store.storage,
14
+ logger: store.logger,
15
+ notify: store
16
+ };
17
+ }
18
+ //#endregion
19
+ export { insightDepsFromStore };
20
+
21
+ //# sourceMappingURL=deps.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deps.mjs","names":[],"sources":["../../src/insights/deps.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { StorageAdapter } from \"@storyshelf/core/adapter/storage\";\n/** Explicit dependencies for insight work (requests and background jobs share them). */\nimport type { Ai } from \"@storyshelf/core/ai\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport type { NotifyDeps } from \"../notify.ts\";\nimport { getStore } from \"../store.ts\";\n\n/** Everything a job needs, captured while the request store is in scope. */\nexport interface InsightDeps {\n ai: Ai;\n db: DatabaseAdapter;\n storage: StorageAdapter;\n logger: Logger;\n notify: NotifyDeps;\n}\n\n/** Snapshot the current request store into explicit deps (null when AI is off). */\nexport function insightDepsFromStore(): InsightDeps | null {\n const store = getStore();\n if (!store.ai) {\n return null;\n }\n return {\n ai: store.ai,\n db: store.db,\n storage: store.storage,\n logger: store.logger,\n notify: store,\n };\n}\n"],"mappings":";;;;;;AAkBA,SAAgB,uBAA2C;CACzD,MAAM,QAAQ,SAAS;CACvB,IAAI,CAAC,MAAM,IACT,OAAO;CAET,OAAO;EACL,IAAI,MAAM;EACV,IAAI,MAAM;EACV,SAAS,MAAM;EACf,QAAQ,MAAM;EACd,QAAQ;CACV;AACF"}
@@ -0,0 +1,22 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { HTTPException } from "hono/http-exception";
5
+ //#region src/insights/errors.ts
6
+ /** JSON errors for the insights API (`{ message }`, optional `Retry-After`). */
7
+ /** Throw an HTTP error with a JSON `{ message }` body. */
8
+ function failWith(status, message, retryAfterSeconds) {
9
+ const headers = { "content-type": "application/json" };
10
+ if (retryAfterSeconds !== void 0) headers["retry-after"] = String(retryAfterSeconds);
11
+ throw new HTTPException(status, {
12
+ message,
13
+ res: new Response(JSON.stringify({ message }), {
14
+ status,
15
+ headers
16
+ })
17
+ });
18
+ }
19
+ //#endregion
20
+ export { failWith };
21
+
22
+ //# sourceMappingURL=errors.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.mjs","names":[],"sources":["../../src/insights/errors.ts"],"sourcesContent":["/** JSON errors for the insights API (`{ message }`, optional `Retry-After`). */\nimport { HTTPException } from \"hono/http-exception\";\n\n/** Throw an HTTP error with a JSON `{ message }` body. */\nexport function failWith(\n status: 400 | 409 | 429 | 501,\n message: string,\n retryAfterSeconds?: number,\n): never {\n const headers: Record<string, string> = { \"content-type\": \"application/json\" };\n if (retryAfterSeconds !== undefined) {\n headers[\"retry-after\"] = String(retryAfterSeconds);\n }\n throw new HTTPException(status, {\n message,\n res: new Response(JSON.stringify({ message }), { status, headers }),\n });\n}\n"],"mappings":";;;;;;;AAIA,SAAgB,SACd,QACA,SACA,mBACO;CACP,MAAM,UAAkC,EAAE,gBAAgB,mBAAmB;CAC7E,IAAI,sBAAsB,KAAA,GACxB,QAAQ,iBAAiB,OAAO,iBAAiB;CAEnD,MAAM,IAAI,cAAc,QAAQ;EAC9B;EACA,KAAK,IAAI,SAAS,KAAK,UAAU,EAAE,QAAQ,CAAC,GAAG;GAAE;GAAQ;EAAQ,CAAC;CACpE,CAAC;AACH"}
@@ -0,0 +1,40 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { currentProjectRole } from "../routers/helpers.mjs";
6
+ import { insightDepsFromStore } from "./deps.mjs";
7
+ import { APPROVER_ROLES } from "../routers/builds.handlers.mjs";
8
+ import { freshen } from "./stale.mjs";
9
+ import { toInsightView } from "./view.mjs";
10
+ import { InsightModel } from "@storyshelf/core/models";
11
+ /** Load the panel, or null when AI is off site-wide or for the project. */
12
+ async function loadHealthPanel(project) {
13
+ const deps = insightDepsFromStore();
14
+ if (!deps || project.aiProfile === null || project.aiProfile === void 0) return null;
15
+ const row = await new InsightModel(deps.db).getHealth(project.id, "30d");
16
+ const role = getStore().authEnabled ? await currentProjectRole(project.id) : "admin";
17
+ return {
18
+ latest: row ? toInsightView(await freshen(deps, row)) : null,
19
+ canGenerate: role !== null && APPROVER_ROLES.includes(role)
20
+ };
21
+ }
22
+ function scoreOf(output) {
23
+ const score = output?.score;
24
+ return typeof score === "number" ? score : null;
25
+ }
26
+ /** The finished digest as a card badge, or null when none is available. */
27
+ async function loadHealthBadge(project) {
28
+ const { ai, db } = getStore();
29
+ if (!ai || project.aiProfile === null || project.aiProfile === void 0) return null;
30
+ const row = await new InsightModel(db).getHealth(project.id, "30d");
31
+ if (row?.status !== "done" || row.verdict === null) return null;
32
+ return {
33
+ verdict: row.verdict,
34
+ score: scoreOf(toInsightView(row).output)
35
+ };
36
+ }
37
+ //#endregion
38
+ export { loadHealthBadge, loadHealthPanel };
39
+
40
+ //# sourceMappingURL=health-panel.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"health-panel.mjs","names":[],"sources":["../../src/insights/health-panel.ts"],"sourcesContent":["/** Data for the project health panel and card badge (null when AI is off for the project). */\nimport { InsightModel } from \"@storyshelf/core/models\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { APPROVER_ROLES } from \"../routers/builds.handlers.ts\";\nimport { currentProjectRole } from \"../routers/helpers.ts\";\nimport { getStore } from \"../store.ts\";\nimport { insightDepsFromStore } from \"./deps.ts\";\nimport { freshen } from \"./stale.ts\";\nimport { toInsightView, type InsightView } from \"./view.ts\";\n\n/** The window the UI shows and generates. */\nexport const HEALTH_UI_WINDOW = \"30d\";\n\n/** What the health panel shows. */\nexport interface HealthPanelData {\n latest: InsightView | null;\n canGenerate: boolean;\n}\n\n/** Compact health for the project card. */\nexport interface HealthBadge {\n verdict: string;\n score: number | null;\n}\n\n/** Load the panel, or null when AI is off site-wide or for the project. */\nexport async function loadHealthPanel(project: Project): Promise<HealthPanelData | null> {\n const deps = insightDepsFromStore();\n if (!deps || project.aiProfile === null || project.aiProfile === undefined) {\n return null;\n }\n const row = await new InsightModel(deps.db).getHealth(project.id, HEALTH_UI_WINDOW);\n const role = getStore().authEnabled ? await currentProjectRole(project.id) : \"admin\";\n return {\n latest: row ? toInsightView(await freshen(deps, row)) : null,\n canGenerate: role !== null && (APPROVER_ROLES as readonly string[]).includes(role),\n };\n}\n\nfunction scoreOf(output: unknown): number | null {\n const score = (output as { score?: unknown } | null)?.score;\n return typeof score === \"number\" ? score : null;\n}\n\n/** The finished digest as a card badge, or null when none is available. */\nexport async function loadHealthBadge(project: Project): Promise<HealthBadge | null> {\n const { ai, db } = getStore();\n if (!ai || project.aiProfile === null || project.aiProfile === undefined) {\n return null;\n }\n const row = await new InsightModel(db).getHealth(project.id, HEALTH_UI_WINDOW);\n if (row?.status !== \"done\" || row.verdict === null) {\n return null;\n }\n return { verdict: row.verdict, score: scoreOf(toInsightView(row).output) };\n}\n"],"mappings":";;;;;;;;;;;AA0BA,eAAsB,gBAAgB,SAAmD;CACvF,MAAM,OAAO,qBAAqB;CAClC,IAAI,CAAC,QAAQ,QAAQ,cAAc,QAAQ,QAAQ,cAAc,KAAA,GAC/D,OAAO;CAET,MAAM,MAAM,MAAM,IAAI,aAAa,KAAK,EAAE,CAAC,CAAC,UAAU,QAAQ,IAAA,KAAoB;CAClF,MAAM,OAAO,SAAS,CAAC,CAAC,cAAc,MAAM,mBAAmB,QAAQ,EAAE,IAAI;CAC7E,OAAO;EACL,QAAQ,MAAM,cAAc,MAAM,QAAQ,MAAM,GAAG,CAAC,IAAI;EACxD,aAAa,SAAS,QAAS,eAAqC,SAAS,IAAI;CACnF;AACF;AAEA,SAAS,QAAQ,QAAgC;CAC/C,MAAM,QAAS,QAAuC;CACtD,OAAO,OAAO,UAAU,WAAW,QAAQ;AAC7C;;AAGA,eAAsB,gBAAgB,SAA+C;CACnF,MAAM,EAAE,IAAI,OAAO,SAAS;CAC5B,IAAI,CAAC,MAAM,QAAQ,cAAc,QAAQ,QAAQ,cAAc,KAAA,GAC7D,OAAO;CAET,MAAM,MAAM,MAAM,IAAI,aAAa,EAAE,CAAC,CAAC,UAAU,QAAQ,IAAA,KAAoB;CAC7E,IAAI,KAAK,WAAW,UAAU,IAAI,YAAY,MAC5C,OAAO;CAET,OAAO;EAAE,SAAS,IAAI;EAAS,OAAO,QAAQ,cAAc,GAAG,CAAC,CAAC,MAAM;CAAE;AAC3E"}
@@ -0,0 +1,103 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { notifyProjectWith } from "../notify.mjs";
5
+ import { recordUsage } from "./budget.mjs";
6
+ import { InsightModel } from "@storyshelf/core/models";
7
+ import { AiError } from "@storyshelf/core/ai";
8
+ //#region src/insights/job.ts
9
+ /** Background insight job: summarize, record usage, persist the result, notify. */
10
+ const running = /* @__PURE__ */ new Set();
11
+ /** Wait for in-flight jobs (graceful teardown and tests). */
12
+ async function settleInsightJobs() {
13
+ await Promise.allSettled(running);
14
+ }
15
+ /** Result envelope stored in `insights.result` (output plus fallback markers). */
16
+ function packResult(result, profileRequested) {
17
+ return JSON.stringify({
18
+ output: result.object,
19
+ meta: {
20
+ profileRequested,
21
+ profileEffective: result.profileEffective,
22
+ visionSkipped: result.visionSkipped,
23
+ imagesSent: result.imagesSent,
24
+ warnings: result.warnings,
25
+ usage: result.usage
26
+ }
27
+ });
28
+ }
29
+ async function succeed(deps, row, spec, result) {
30
+ await recordUsage(deps, {
31
+ insightId: row.id,
32
+ projectId: row.projectId,
33
+ profile: result.profileEffective,
34
+ task: spec.task,
35
+ model: result.model,
36
+ inputTokens: result.usage.inputTokens,
37
+ outputTokens: result.usage.outputTokens,
38
+ estimated: result.usage.estimated,
39
+ images: result.imagesSent,
40
+ status: "ok"
41
+ });
42
+ await new InsightModel(deps.db).markDone(row.id, {
43
+ verdict: result.object.verdict,
44
+ summary: result.object.summary,
45
+ result: packResult(result, spec.profileRequested)
46
+ });
47
+ await notifyProjectWith(deps.notify, spec.project, "insight:ready", {
48
+ insightId: row.id,
49
+ kind: row.kind,
50
+ verdict: result.object.verdict,
51
+ summary: result.object.summary.slice(0, 300)
52
+ }, spec.reviewSuffix);
53
+ }
54
+ async function fail(deps, row, spec, error) {
55
+ const failure = error instanceof AiError ? error : new AiError("unknown", "insight job failed");
56
+ deps.logger.warn({
57
+ insightId: row.id,
58
+ code: failure.code
59
+ }, "insight job failed");
60
+ if (failure.usage) await recordUsage(deps, {
61
+ insightId: row.id,
62
+ projectId: row.projectId,
63
+ profile: row.profile,
64
+ task: spec.task,
65
+ model: row.model,
66
+ inputTokens: failure.usage.inputTokens,
67
+ outputTokens: failure.usage.outputTokens,
68
+ estimated: failure.usage.estimated,
69
+ images: spec.evidence.images.length,
70
+ status: "failed"
71
+ });
72
+ await new InsightModel(deps.db).markFailed(row.id, failure.code);
73
+ }
74
+ async function execute(deps, row, spec) {
75
+ try {
76
+ await new InsightModel(deps.db).markRunning(row.id);
77
+ await succeed(deps, row, spec, await deps.ai.summarize({
78
+ task: spec.task,
79
+ profile: row.profile,
80
+ system: spec.system,
81
+ evidence: spec.evidence,
82
+ schema: spec.schema
83
+ }));
84
+ } catch (error) {
85
+ await fail(deps, row, spec, error).catch((inner) => {
86
+ deps.logger.error({
87
+ err: inner,
88
+ insightId: row.id
89
+ }, "insight failure bookkeeping failed");
90
+ });
91
+ }
92
+ }
93
+ /** Start a job without awaiting it (tracked for graceful settle). */
94
+ function spawnJob(deps, row, spec) {
95
+ const promise = execute(deps, row, spec).finally(() => {
96
+ running.delete(promise);
97
+ });
98
+ running.add(promise);
99
+ }
100
+ //#endregion
101
+ export { packResult, settleInsightJobs, spawnJob };
102
+
103
+ //# sourceMappingURL=job.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"job.mjs","names":[],"sources":["../../src/insights/job.ts"],"sourcesContent":["/** Background insight job: summarize, record usage, persist the result, notify. */\nimport { AiError, type AiEvidence, type AiSummarizeResult, type AiTask } from \"@storyshelf/core/ai\";\nimport { InsightModel } from \"@storyshelf/core/models\";\nimport type { InsightRow, Project } from \"@storyshelf/core/schema\";\nimport type { z } from \"zod\";\nimport { notifyProjectWith } from \"../notify.ts\";\nimport { recordUsage } from \"./budget.ts\";\nimport type { InsightDeps } from \"./deps.ts\";\n\nconst running = new Set<Promise<void>>();\n\n/** Wait for in-flight jobs (graceful teardown and tests). */\nexport async function settleInsightJobs(): Promise<void> {\n await Promise.allSettled(running);\n}\n\n/** What a job summarizes. */\nexport interface JobSpec<T extends { verdict: string; summary: string }> {\n task: AiTask;\n system: string;\n evidence: AiEvidence;\n schema: z.ZodType<T>;\n profileRequested: string | null;\n project: Pick<Project, \"id\" | \"slug\">;\n /** Review-page suffix for the `insight:ready` link. */\n reviewSuffix: string;\n}\n\n/** Result envelope stored in `insights.result` (output plus fallback markers). */\nexport function packResult<T>(\n result: AiSummarizeResult<T>,\n profileRequested: string | null,\n): string {\n return JSON.stringify({\n output: result.object,\n meta: {\n profileRequested,\n profileEffective: result.profileEffective,\n visionSkipped: result.visionSkipped,\n imagesSent: result.imagesSent,\n warnings: result.warnings,\n usage: result.usage,\n },\n });\n}\n\nasync function succeed<T extends { verdict: string; summary: string }>(\n deps: InsightDeps,\n row: InsightRow,\n spec: JobSpec<T>,\n result: AiSummarizeResult<T>,\n): Promise<void> {\n await recordUsage(deps, {\n insightId: row.id,\n projectId: row.projectId,\n profile: result.profileEffective,\n task: spec.task,\n model: result.model,\n inputTokens: result.usage.inputTokens,\n outputTokens: result.usage.outputTokens,\n estimated: result.usage.estimated,\n images: result.imagesSent,\n status: \"ok\",\n });\n await new InsightModel(deps.db).markDone(row.id, {\n verdict: result.object.verdict,\n summary: result.object.summary,\n result: packResult(result, spec.profileRequested),\n });\n await notifyProjectWith(\n deps.notify,\n spec.project,\n \"insight:ready\",\n {\n insightId: row.id,\n kind: row.kind,\n verdict: result.object.verdict,\n summary: result.object.summary.slice(0, 300),\n },\n spec.reviewSuffix,\n );\n}\n\nasync function fail(\n deps: InsightDeps,\n row: InsightRow,\n spec: { task: AiTask; evidence: AiEvidence },\n error: unknown,\n): Promise<void> {\n const failure = error instanceof AiError ? error : new AiError(\"unknown\", \"insight job failed\");\n deps.logger.warn({ insightId: row.id, code: failure.code }, \"insight job failed\");\n if (failure.usage) {\n await recordUsage(deps, {\n insightId: row.id,\n projectId: row.projectId,\n profile: row.profile,\n task: spec.task,\n model: row.model,\n inputTokens: failure.usage.inputTokens,\n outputTokens: failure.usage.outputTokens,\n estimated: failure.usage.estimated,\n images: spec.evidence.images.length,\n status: \"failed\",\n });\n }\n await new InsightModel(deps.db).markFailed(row.id, failure.code);\n}\n\nasync function execute<T extends { verdict: string; summary: string }>(\n deps: InsightDeps,\n row: InsightRow,\n spec: JobSpec<T>,\n): Promise<void> {\n try {\n await new InsightModel(deps.db).markRunning(row.id);\n const result = await deps.ai.summarize({\n task: spec.task,\n profile: row.profile,\n system: spec.system,\n evidence: spec.evidence,\n schema: spec.schema,\n });\n await succeed(deps, row, spec, result);\n } catch (error) {\n await fail(deps, row, spec, error).catch((inner: unknown) => {\n deps.logger.error({ err: inner, insightId: row.id }, \"insight failure bookkeeping failed\");\n });\n }\n}\n\n/** Start a job without awaiting it (tracked for graceful settle). */\nexport function spawnJob<T extends { verdict: string; summary: string }>(\n deps: InsightDeps,\n row: InsightRow,\n spec: JobSpec<T>,\n): void {\n const promise = execute(deps, row, spec).finally(() => {\n running.delete(promise);\n });\n running.add(promise);\n}\n"],"mappings":";;;;;;;;;AASA,MAAM,0BAAU,IAAI,IAAmB;;AAGvC,eAAsB,oBAAmC;CACvD,MAAM,QAAQ,WAAW,OAAO;AAClC;;AAeA,SAAgB,WACd,QACA,kBACQ;CACR,OAAO,KAAK,UAAU;EACpB,QAAQ,OAAO;EACf,MAAM;GACJ;GACA,kBAAkB,OAAO;GACzB,eAAe,OAAO;GACtB,YAAY,OAAO;GACnB,UAAU,OAAO;GACjB,OAAO,OAAO;EAChB;CACF,CAAC;AACH;AAEA,eAAe,QACb,MACA,KACA,MACA,QACe;CACf,MAAM,YAAY,MAAM;EACtB,WAAW,IAAI;EACf,WAAW,IAAI;EACf,SAAS,OAAO;EAChB,MAAM,KAAK;EACX,OAAO,OAAO;EACd,aAAa,OAAO,MAAM;EAC1B,cAAc,OAAO,MAAM;EAC3B,WAAW,OAAO,MAAM;EACxB,QAAQ,OAAO;EACf,QAAQ;CACV,CAAC;CACD,MAAM,IAAI,aAAa,KAAK,EAAE,CAAC,CAAC,SAAS,IAAI,IAAI;EAC/C,SAAS,OAAO,OAAO;EACvB,SAAS,OAAO,OAAO;EACvB,QAAQ,WAAW,QAAQ,KAAK,gBAAgB;CAClD,CAAC;CACD,MAAM,kBACJ,KAAK,QACL,KAAK,SACL,iBACA;EACE,WAAW,IAAI;EACf,MAAM,IAAI;EACV,SAAS,OAAO,OAAO;EACvB,SAAS,OAAO,OAAO,QAAQ,MAAM,GAAG,GAAG;CAC7C,GACA,KAAK,YACP;AACF;AAEA,eAAe,KACb,MACA,KACA,MACA,OACe;CACf,MAAM,UAAU,iBAAiB,UAAU,QAAQ,IAAI,QAAQ,WAAW,oBAAoB;CAC9F,KAAK,OAAO,KAAK;EAAE,WAAW,IAAI;EAAI,MAAM,QAAQ;CAAK,GAAG,oBAAoB;CAChF,IAAI,QAAQ,OACV,MAAM,YAAY,MAAM;EACtB,WAAW,IAAI;EACf,WAAW,IAAI;EACf,SAAS,IAAI;EACb,MAAM,KAAK;EACX,OAAO,IAAI;EACX,aAAa,QAAQ,MAAM;EAC3B,cAAc,QAAQ,MAAM;EAC5B,WAAW,QAAQ,MAAM;EACzB,QAAQ,KAAK,SAAS,OAAO;EAC7B,QAAQ;CACV,CAAC;CAEH,MAAM,IAAI,aAAa,KAAK,EAAE,CAAC,CAAC,WAAW,IAAI,IAAI,QAAQ,IAAI;AACjE;AAEA,eAAe,QACb,MACA,KACA,MACe;CACf,IAAI;EACF,MAAM,IAAI,aAAa,KAAK,EAAE,CAAC,CAAC,YAAY,IAAI,EAAE;EAQlD,MAAM,QAAQ,MAAM,KAAK,MAAM,MAPV,KAAK,GAAG,UAAU;GACrC,MAAM,KAAK;GACX,SAAS,IAAI;GACb,QAAQ,KAAK;GACb,UAAU,KAAK;GACf,QAAQ,KAAK;EACf,CAAC,CACoC;CACvC,SAAS,OAAO;EACd,MAAM,KAAK,MAAM,KAAK,MAAM,KAAK,CAAC,CAAC,OAAO,UAAmB;GAC3D,KAAK,OAAO,MAAM;IAAE,KAAK;IAAO,WAAW,IAAI;GAAG,GAAG,oCAAoC;EAC3F,CAAC;CACH;AACF;;AAGA,SAAgB,SACd,MACA,KACA,MACM;CACN,MAAM,UAAU,QAAQ,MAAM,KAAK,IAAI,CAAC,CAAC,cAAc;EACrD,QAAQ,OAAO,OAAO;CACxB,CAAC;CACD,QAAQ,IAAI,OAAO;AACrB"}
@@ -0,0 +1,59 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { BuildModel, SnapshotModel } from "@storyshelf/core/models";
5
+ import { buildHealthEvidence, resolveLimits, snapshotKey } from "@storyshelf/core/insights";
6
+ import { modeSegment } from "@storyshelf/core/paths";
7
+ //#region src/insights/load-health.ts
8
+ /** Load project-health evidence: build aggregates plus the most churny stories. */
9
+ const DAY_MS = 864e5;
10
+ /** Parse windows like `30d` (1–365 days); null for anything else. */
11
+ function parseWindow(window) {
12
+ const match = /^(?<days>\d{1,3})d$/u.exec(window);
13
+ const days = match?.groups ? Number(match.groups["days"]) : 0;
14
+ return days >= 1 && days <= 365 ? days : null;
15
+ }
16
+ function summarize(build) {
17
+ return {
18
+ createdAt: build.createdAt,
19
+ gitBranch: build.gitBranch,
20
+ status: build.status,
21
+ snapshotCount: build.snapshotCount,
22
+ changedCount: build.changedCount,
23
+ approvedCount: build.approvedCount,
24
+ rejectedCount: build.rejectedCount
25
+ };
26
+ }
27
+ async function churnOf(deps, builds) {
28
+ const counts = /* @__PURE__ */ new Map();
29
+ const model = new SnapshotModel(deps.db);
30
+ const lists = await Promise.all(builds.slice(0, 20).map(async (b) => await model.listByBuild(b.id)));
31
+ for (const snapshot of lists.flat().filter((s) => !s.inherited && s.status !== "approved")) {
32
+ const key = snapshotKey({
33
+ title: snapshot.storyTitle,
34
+ name: snapshot.storyName,
35
+ viewport: modeSegment(snapshot.viewportName, snapshot.browser)
36
+ });
37
+ counts.set(key, (counts.get(key) ?? 0) + 1);
38
+ }
39
+ return [...counts].map(([key, changedBuilds]) => ({
40
+ key,
41
+ changedBuilds
42
+ })).toSorted((a, b) => b.changedBuilds - a.changedBuilds || a.key.localeCompare(b.key));
43
+ }
44
+ /** Assemble health evidence for a project window (e.g. `30d`). */
45
+ async function loadHealthEvidence(deps, project, window, days) {
46
+ const since = (/* @__PURE__ */ new Date(Date.now() - days * DAY_MS)).toISOString();
47
+ const builds = (await new BuildModel(deps.db).list(project.id)).filter((b) => b.createdAt >= since);
48
+ return buildHealthEvidence({
49
+ projectName: project.name,
50
+ window,
51
+ builds: builds.map((build) => summarize(build)),
52
+ churnyStories: await churnOf(deps, builds),
53
+ limits: resolveLimits(deps.ai.limits())
54
+ });
55
+ }
56
+ //#endregion
57
+ export { loadHealthEvidence, parseWindow };
58
+
59
+ //# sourceMappingURL=load-health.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load-health.mjs","names":[],"sources":["../../src/insights/load-health.ts"],"sourcesContent":["/** Load project-health evidence: build aggregates plus the most churny stories. */\nimport {\n buildHealthEvidence,\n snapshotKey,\n type BuiltEvidence,\n type BuildSummary,\n resolveLimits,\n} from \"@storyshelf/core/insights\";\nimport { BuildModel, SnapshotModel } from \"@storyshelf/core/models\";\nimport { modeSegment } from \"@storyshelf/core/paths\";\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport type { InsightDeps } from \"./deps.ts\";\n\nconst DAY_MS = 86_400_000;\n\n/** Parse windows like `30d` (1–365 days); null for anything else. */\nexport function parseWindow(window: string): number | null {\n const match = /^(?<days>\\d{1,3})d$/u.exec(window);\n const days = match?.groups ? Number(match.groups[\"days\"]) : 0;\n return days >= 1 && days <= 365 ? days : null;\n}\n\nfunction summarize(build: Build): BuildSummary {\n return {\n createdAt: build.createdAt,\n gitBranch: build.gitBranch,\n status: build.status,\n snapshotCount: build.snapshotCount,\n changedCount: build.changedCount,\n approvedCount: build.approvedCount,\n rejectedCount: build.rejectedCount,\n };\n}\n\nasync function churnOf(\n deps: InsightDeps,\n builds: Build[],\n): Promise<{ key: string; changedBuilds: number }[]> {\n const counts = new Map<string, number>();\n const model = new SnapshotModel(deps.db);\n const lists = await Promise.all(\n builds.slice(0, 20).map(async (b) => await model.listByBuild(b.id)),\n );\n for (const snapshot of lists.flat().filter((s) => !s.inherited && s.status !== \"approved\")) {\n const key = snapshotKey({\n title: snapshot.storyTitle,\n name: snapshot.storyName,\n viewport: modeSegment(snapshot.viewportName, snapshot.browser),\n });\n counts.set(key, (counts.get(key) ?? 0) + 1);\n }\n return [...counts]\n .map(([key, changedBuilds]) => ({ key, changedBuilds }))\n .toSorted((a, b) => b.changedBuilds - a.changedBuilds || a.key.localeCompare(b.key));\n}\n\n/** Assemble health evidence for a project window (e.g. `30d`). */\nexport async function loadHealthEvidence(\n deps: InsightDeps,\n project: Project,\n window: string,\n days: number,\n): Promise<BuiltEvidence> {\n const since = new Date(Date.now() - days * DAY_MS).toISOString();\n const all = await new BuildModel(deps.db).list(project.id);\n const builds = all.filter((b) => b.createdAt >= since);\n return buildHealthEvidence({\n projectName: project.name,\n window,\n builds: builds.map((build) => summarize(build)),\n churnyStories: await churnOf(deps, builds),\n limits: resolveLimits(deps.ai.limits()),\n });\n}\n"],"mappings":";;;;;;;;AAaA,MAAM,SAAS;;AAGf,SAAgB,YAAY,QAA+B;CACzD,MAAM,QAAQ,uBAAuB,KAAK,MAAM;CAChD,MAAM,OAAO,OAAO,SAAS,OAAO,MAAM,OAAO,OAAO,IAAI;CAC5D,OAAO,QAAQ,KAAK,QAAQ,MAAM,OAAO;AAC3C;AAEA,SAAS,UAAU,OAA4B;CAC7C,OAAO;EACL,WAAW,MAAM;EACjB,WAAW,MAAM;EACjB,QAAQ,MAAM;EACd,eAAe,MAAM;EACrB,cAAc,MAAM;EACpB,eAAe,MAAM;EACrB,eAAe,MAAM;CACvB;AACF;AAEA,eAAe,QACb,MACA,QACmD;CACnD,MAAM,yBAAS,IAAI,IAAoB;CACvC,MAAM,QAAQ,IAAI,cAAc,KAAK,EAAE;CACvC,MAAM,QAAQ,MAAM,QAAQ,IAC1B,OAAO,MAAM,GAAG,EAAE,CAAC,CAAC,IAAI,OAAO,MAAM,MAAM,MAAM,YAAY,EAAE,EAAE,CAAC,CACpE;CACA,KAAK,MAAM,YAAY,MAAM,KAAK,CAAC,CAAC,QAAQ,MAAM,CAAC,EAAE,aAAa,EAAE,WAAW,UAAU,GAAG;EAC1F,MAAM,MAAM,YAAY;GACtB,OAAO,SAAS;GAChB,MAAM,SAAS;GACf,UAAU,YAAY,SAAS,cAAc,SAAS,OAAO;EAC/D,CAAC;EACD,OAAO,IAAI,MAAM,OAAO,IAAI,GAAG,KAAK,KAAK,CAAC;CAC5C;CACA,OAAO,CAAC,GAAG,MAAM,CAAC,CACf,KAAK,CAAC,KAAK,oBAAoB;EAAE;EAAK;CAAc,EAAE,CAAC,CACvD,UAAU,GAAG,MAAM,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,IAAI,cAAc,EAAE,GAAG,CAAC;AACvF;;AAGA,eAAsB,mBACpB,MACA,SACA,QACA,MACwB;CACxB,MAAM,yBAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,MAAM,EAAA,CAAE,YAAY;CAE/D,MAAM,UAAS,MADG,IAAI,WAAW,KAAK,EAAE,CAAC,CAAC,KAAK,QAAQ,EAAE,EAAA,CACtC,QAAQ,MAAM,EAAE,aAAa,KAAK;CACrD,OAAO,oBAAoB;EACzB,aAAa,QAAQ;EACrB;EACA,QAAQ,OAAO,KAAK,UAAU,UAAU,KAAK,CAAC;EAC9C,eAAe,MAAM,QAAQ,MAAM,MAAM;EACzC,QAAQ,cAAc,KAAK,GAAG,OAAO,CAAC;CACxC,CAAC;AACH"}
@@ -0,0 +1,98 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { CaptureAttemptModel, CaptureLogModel, CommentModel, SnapshotModel } from "@storyshelf/core/models";
5
+ import { buildTriageEvidence, rankSnapshots, resolveLimits } from "@storyshelf/core/insights";
6
+ import { modeSegment } from "@storyshelf/core/paths";
7
+ //#region src/insights/load-triage.ts
8
+ /** Load triage evidence inputs (snapshots, logs, comments, thumbnails) and assemble them. */
9
+ function storyIdOf(log) {
10
+ try {
11
+ const fields = log.fields ? JSON.parse(log.fields) : {};
12
+ return typeof fields["storyId"] === "string" ? fields["storyId"] : null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+ function groupLogs(lines) {
18
+ const grouped = /* @__PURE__ */ new Map();
19
+ for (const line of lines.filter((l) => l.level === "warn" || l.level === "error")) {
20
+ const id = storyIdOf(line);
21
+ if (id) grouped.set(id, [...grouped.get(id) ?? [], `${line.level} ${line.message}`]);
22
+ }
23
+ return new Map([...grouped].map(([id, parts]) => [id, parts.join("\n")]));
24
+ }
25
+ /** warn/error capture-log lines of the latest attempt, grouped by story. */
26
+ async function logsByStory(deps, build) {
27
+ const latest = (await new CaptureAttemptModel(deps.db).listByBuild(build.id)).toSorted((a, b) => b.attemptNo - a.attemptNo)[0];
28
+ if (!latest) return /* @__PURE__ */ new Map();
29
+ return groupLogs(await new CaptureLogModel(deps.db).listByAttempt(latest.id, 500));
30
+ }
31
+ function logFor(logs, storyId) {
32
+ const log = logs.get(storyId);
33
+ return log === void 0 ? {} : { log };
34
+ }
35
+ function toEvidence(snapshot, logs) {
36
+ return {
37
+ storyId: snapshot.storyId,
38
+ title: snapshot.storyTitle,
39
+ name: snapshot.storyName,
40
+ viewport: modeSegment(snapshot.viewportName, snapshot.browser),
41
+ status: snapshot.status,
42
+ diffRatio: snapshot.diffRatio,
43
+ inherited: snapshot.inherited,
44
+ ...logFor(logs, snapshot.storyId)
45
+ };
46
+ }
47
+ async function attachScreenshots(deps, rows, items, limits) {
48
+ const top = new Set(items.filter((item) => !item.inherited).toSorted(rankSnapshots).slice(0, limits.maxImages).map((item) => `${item.storyId}@${item.viewport}`));
49
+ return await Promise.all(items.map(async (item) => {
50
+ const row = rows.get(`${item.storyId}@${item.viewport}`);
51
+ if (!row || !top.has(`${item.storyId}@${item.viewport}`)) return item;
52
+ const data = await deps.storage.read(row.screenshotPath).catch(() => null);
53
+ return data ? {
54
+ ...item,
55
+ screenshot: new Uint8Array(data)
56
+ } : item;
57
+ }));
58
+ }
59
+ function changedFilesOf(build) {
60
+ try {
61
+ const parsed = build.changedFiles ? JSON.parse(build.changedFiles) : [];
62
+ return Array.isArray(parsed) ? parsed.filter((f) => typeof f === "string") : [];
63
+ } catch {
64
+ return [];
65
+ }
66
+ }
67
+ /** Assemble triage evidence for a build from the database and storage. */
68
+ async function loadTriageEvidence(deps, project, build) {
69
+ const limits = resolveLimits(deps.ai.limits());
70
+ const snapshots = await new SnapshotModel(deps.db).listByBuild(build.id);
71
+ const logs = await logsByStory(deps, build);
72
+ const items = await attachScreenshots(deps, new Map(snapshots.map((s) => [`${s.storyId}@${modeSegment(s.viewportName, s.browser)}`, s])), snapshots.map((s) => toEvidence(s, logs)), limits);
73
+ const comments = await new CommentModel(deps.db).listByBuild(build.id);
74
+ return buildTriageEvidence({
75
+ projectName: project.name,
76
+ build: {
77
+ gitBranch: build.gitBranch,
78
+ gitSha: build.gitSha,
79
+ message: build.message,
80
+ status: build.status,
81
+ snapshotCount: build.snapshotCount,
82
+ changedCount: build.changedCount,
83
+ approvedCount: build.approvedCount,
84
+ rejectedCount: build.rejectedCount,
85
+ changedFiles: changedFilesOf(build)
86
+ },
87
+ snapshots: items,
88
+ comments: comments.map((c) => ({
89
+ author: "reviewer",
90
+ body: c.body
91
+ })),
92
+ limits
93
+ });
94
+ }
95
+ //#endregion
96
+ export { loadTriageEvidence };
97
+
98
+ //# sourceMappingURL=load-triage.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"load-triage.mjs","names":[],"sources":["../../src/insights/load-triage.ts"],"sourcesContent":["/** Load triage evidence inputs (snapshots, logs, comments, thumbnails) and assemble them. */\nimport {\n buildTriageEvidence,\n rankSnapshots,\n resolveLimits,\n type BuiltEvidence,\n type EvidenceLimits,\n type SnapshotEvidence,\n} from \"@storyshelf/core/insights\";\nimport {\n CaptureAttemptModel,\n CaptureLogModel,\n CommentModel,\n SnapshotModel,\n} from \"@storyshelf/core/models\";\nimport { modeSegment } from \"@storyshelf/core/paths\";\nimport type { Build, CaptureLog, Project, Snapshot } from \"@storyshelf/core/schema\";\nimport type { InsightDeps } from \"./deps.ts\";\n\nfunction storyIdOf(log: CaptureLog): string | null {\n try {\n const fields = log.fields ? (JSON.parse(log.fields) as Record<string, unknown>) : {};\n return typeof fields[\"storyId\"] === \"string\" ? fields[\"storyId\"] : null;\n } catch {\n return null;\n }\n}\n\nfunction groupLogs(lines: CaptureLog[]): Map<string, string> {\n const grouped = new Map<string, string[]>();\n for (const line of lines.filter((l) => l.level === \"warn\" || l.level === \"error\")) {\n const id = storyIdOf(line);\n if (id) {\n grouped.set(id, [...(grouped.get(id) ?? []), `${line.level} ${line.message}`]);\n }\n }\n return new Map([...grouped].map(([id, parts]) => [id, parts.join(\"\\n\")]));\n}\n\n/** warn/error capture-log lines of the latest attempt, grouped by story. */\nasync function logsByStory(deps: InsightDeps, build: Build): Promise<Map<string, string>> {\n const attempts = await new CaptureAttemptModel(deps.db).listByBuild(build.id);\n const latest = attempts.toSorted((a, b) => b.attemptNo - a.attemptNo)[0];\n if (!latest) {\n return new Map();\n }\n return groupLogs(await new CaptureLogModel(deps.db).listByAttempt(latest.id, 500));\n}\n\nfunction logFor(logs: Map<string, string>, storyId: string): { log?: string } {\n const log = logs.get(storyId);\n return log === undefined ? {} : { log };\n}\n\nfunction toEvidence(snapshot: Snapshot, logs: Map<string, string>): SnapshotEvidence {\n return {\n storyId: snapshot.storyId,\n title: snapshot.storyTitle,\n name: snapshot.storyName,\n viewport: modeSegment(snapshot.viewportName, snapshot.browser),\n status: snapshot.status,\n diffRatio: snapshot.diffRatio,\n inherited: snapshot.inherited,\n ...logFor(logs, snapshot.storyId),\n };\n}\n\nasync function attachScreenshots(\n deps: InsightDeps,\n rows: Map<string, Snapshot>,\n items: SnapshotEvidence[],\n limits: EvidenceLimits,\n): Promise<SnapshotEvidence[]> {\n const top = new Set(\n items\n .filter((item) => !item.inherited)\n .toSorted(rankSnapshots)\n .slice(0, limits.maxImages)\n .map((item) => `${item.storyId}@${item.viewport}`),\n );\n return await Promise.all(\n items.map(async (item) => {\n const row = rows.get(`${item.storyId}@${item.viewport}`);\n if (!row || !top.has(`${item.storyId}@${item.viewport}`)) {\n return item;\n }\n const data = await deps.storage.read(row.screenshotPath).catch(() => null);\n return data ? { ...item, screenshot: new Uint8Array(data) } : item;\n }),\n );\n}\n\nfunction changedFilesOf(build: Build): string[] {\n try {\n const parsed: unknown = build.changedFiles ? JSON.parse(build.changedFiles) : [];\n return Array.isArray(parsed) ? parsed.filter((f): f is string => typeof f === \"string\") : [];\n } catch {\n return [];\n }\n}\n\n/** Assemble triage evidence for a build from the database and storage. */\nexport async function loadTriageEvidence(\n deps: InsightDeps,\n project: Project,\n build: Build,\n): Promise<BuiltEvidence> {\n const limits = resolveLimits(deps.ai.limits());\n const snapshots = await new SnapshotModel(deps.db).listByBuild(build.id);\n const logs = await logsByStory(deps, build);\n const rows = new Map(\n snapshots.map((s) => [`${s.storyId}@${modeSegment(s.viewportName, s.browser)}`, s]),\n );\n const items = await attachScreenshots(\n deps,\n rows,\n snapshots.map((s) => toEvidence(s, logs)),\n limits,\n );\n const comments = await new CommentModel(deps.db).listByBuild(build.id);\n return buildTriageEvidence({\n projectName: project.name,\n build: {\n gitBranch: build.gitBranch,\n gitSha: build.gitSha,\n message: build.message,\n status: build.status,\n snapshotCount: build.snapshotCount,\n changedCount: build.changedCount,\n approvedCount: build.approvedCount,\n rejectedCount: build.rejectedCount,\n changedFiles: changedFilesOf(build),\n },\n snapshots: items,\n comments: comments.map((c) => ({ author: \"reviewer\", body: c.body })),\n limits,\n });\n}\n"],"mappings":";;;;;;;;AAmBA,SAAS,UAAU,KAAgC;CACjD,IAAI;EACF,MAAM,SAAS,IAAI,SAAU,KAAK,MAAM,IAAI,MAAM,IAAgC,CAAC;EACnF,OAAO,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa;CACrE,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,UAAU,OAA0C;CAC3D,MAAM,0BAAU,IAAI,IAAsB;CAC1C,KAAK,MAAM,QAAQ,MAAM,QAAQ,MAAM,EAAE,UAAU,UAAU,EAAE,UAAU,OAAO,GAAG;EACjF,MAAM,KAAK,UAAU,IAAI;EACzB,IAAI,IACF,QAAQ,IAAI,IAAI,CAAC,GAAI,QAAQ,IAAI,EAAE,KAAK,CAAC,GAAI,GAAG,KAAK,MAAM,GAAG,KAAK,SAAS,CAAC;CAEjF;CACA,OAAO,IAAI,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,KAAK,CAAC,IAAI,WAAW,CAAC,IAAI,MAAM,KAAK,IAAI,CAAC,CAAC,CAAC;AAC1E;;AAGA,eAAe,YAAY,MAAmB,OAA4C;CAExF,MAAM,UAAS,MADQ,IAAI,oBAAoB,KAAK,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE,EAAA,CACpD,UAAU,GAAG,MAAM,EAAE,YAAY,EAAE,SAAS,CAAC,CAAC;CACtE,IAAI,CAAC,QACH,uBAAO,IAAI,IAAI;CAEjB,OAAO,UAAU,MAAM,IAAI,gBAAgB,KAAK,EAAE,CAAC,CAAC,cAAc,OAAO,IAAI,GAAG,CAAC;AACnF;AAEA,SAAS,OAAO,MAA2B,SAAmC;CAC5E,MAAM,MAAM,KAAK,IAAI,OAAO;CAC5B,OAAO,QAAQ,KAAA,IAAY,CAAC,IAAI,EAAE,IAAI;AACxC;AAEA,SAAS,WAAW,UAAoB,MAA6C;CACnF,OAAO;EACL,SAAS,SAAS;EAClB,OAAO,SAAS;EAChB,MAAM,SAAS;EACf,UAAU,YAAY,SAAS,cAAc,SAAS,OAAO;EAC7D,QAAQ,SAAS;EACjB,WAAW,SAAS;EACpB,WAAW,SAAS;EACpB,GAAG,OAAO,MAAM,SAAS,OAAO;CAClC;AACF;AAEA,eAAe,kBACb,MACA,MACA,OACA,QAC6B;CAC7B,MAAM,MAAM,IAAI,IACd,MACG,QAAQ,SAAS,CAAC,KAAK,SAAS,CAAC,CACjC,SAAS,aAAa,CAAC,CACvB,MAAM,GAAG,OAAO,SAAS,CAAC,CAC1B,KAAK,SAAS,GAAG,KAAK,QAAQ,GAAG,KAAK,UAAU,CACrD;CACA,OAAO,MAAM,QAAQ,IACnB,MAAM,IAAI,OAAO,SAAS;EACxB,MAAM,MAAM,KAAK,IAAI,GAAG,KAAK,QAAQ,GAAG,KAAK,UAAU;EACvD,IAAI,CAAC,OAAO,CAAC,IAAI,IAAI,GAAG,KAAK,QAAQ,GAAG,KAAK,UAAU,GACrD,OAAO;EAET,MAAM,OAAO,MAAM,KAAK,QAAQ,KAAK,IAAI,cAAc,CAAC,CAAC,YAAY,IAAI;EACzE,OAAO,OAAO;GAAE,GAAG;GAAM,YAAY,IAAI,WAAW,IAAI;EAAE,IAAI;CAChE,CAAC,CACH;AACF;AAEA,SAAS,eAAe,OAAwB;CAC9C,IAAI;EACF,MAAM,SAAkB,MAAM,eAAe,KAAK,MAAM,MAAM,YAAY,IAAI,CAAC;EAC/E,OAAO,MAAM,QAAQ,MAAM,IAAI,OAAO,QAAQ,MAAmB,OAAO,MAAM,QAAQ,IAAI,CAAC;CAC7F,QAAQ;EACN,OAAO,CAAC;CACV;AACF;;AAGA,eAAsB,mBACpB,MACA,SACA,OACwB;CACxB,MAAM,SAAS,cAAc,KAAK,GAAG,OAAO,CAAC;CAC7C,MAAM,YAAY,MAAM,IAAI,cAAc,KAAK,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;CACvE,MAAM,OAAO,MAAM,YAAY,MAAM,KAAK;CAI1C,MAAM,QAAQ,MAAM,kBAClB,MACA,IALe,IACf,UAAU,KAAK,MAAM,CAAC,GAAG,EAAE,QAAQ,GAAG,YAAY,EAAE,cAAc,EAAE,OAAO,KAAK,CAAC,CAAC,CAI/E,GACH,UAAU,KAAK,MAAM,WAAW,GAAG,IAAI,CAAC,GACxC,MACF;CACA,MAAM,WAAW,MAAM,IAAI,aAAa,KAAK,EAAE,CAAC,CAAC,YAAY,MAAM,EAAE;CACrE,OAAO,oBAAoB;EACzB,aAAa,QAAQ;EACrB,OAAO;GACL,WAAW,MAAM;GACjB,QAAQ,MAAM;GACd,SAAS,MAAM;GACf,QAAQ,MAAM;GACd,eAAe,MAAM;GACrB,cAAc,MAAM;GACpB,eAAe,MAAM;GACrB,eAAe,MAAM;GACrB,cAAc,eAAe,KAAK;EACpC;EACA,WAAW;EACX,UAAU,SAAS,KAAK,OAAO;GAAE,QAAQ;GAAY,MAAM,EAAE;EAAK,EAAE;EACpE;CACF,CAAC;AACH"}
@@ -0,0 +1,28 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "../store.mjs";
5
+ import { currentProjectRole } from "../routers/helpers.mjs";
6
+ import { insightDepsFromStore } from "./deps.mjs";
7
+ import { APPROVER_ROLES } from "../routers/builds.handlers.mjs";
8
+ import { freshen } from "./stale.mjs";
9
+ import { toInsightView } from "./view.mjs";
10
+ import { InsightModel } from "@storyshelf/core/models";
11
+ //#region src/insights/panel.ts
12
+ /** Data for the build-review insight panel (null when AI is off for the project). */
13
+ /** Load the panel for a build, or null when AI is off site-wide or for the project. */
14
+ async function loadInsightPanel(project, build) {
15
+ const deps = insightDepsFromStore();
16
+ if (!deps || project.aiProfile === null || project.aiProfile === void 0) return null;
17
+ const { authEnabled } = getStore();
18
+ const [latest] = await new InsightModel(deps.db).listForBuild(build.id, 1);
19
+ const role = authEnabled ? await currentProjectRole(project.id) : "admin";
20
+ return {
21
+ latest: latest ? toInsightView(await freshen(deps, latest)) : null,
22
+ canGenerate: role !== null && APPROVER_ROLES.includes(role)
23
+ };
24
+ }
25
+ //#endregion
26
+ export { loadInsightPanel };
27
+
28
+ //# sourceMappingURL=panel.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"panel.mjs","names":[],"sources":["../../src/insights/panel.ts"],"sourcesContent":["/** Data for the build-review insight panel (null when AI is off for the project). */\nimport { InsightModel } from \"@storyshelf/core/models\";\nimport type { Build, Project } from \"@storyshelf/core/schema\";\nimport { APPROVER_ROLES } from \"../routers/builds.handlers.ts\";\nimport { currentProjectRole } from \"../routers/helpers.ts\";\nimport { getStore } from \"../store.ts\";\nimport { insightDepsFromStore } from \"./deps.ts\";\nimport { freshen } from \"./stale.ts\";\nimport { toInsightView, type InsightView } from \"./view.ts\";\n\n/** What the panel shows. */\nexport interface InsightPanelData {\n latest: InsightView | null;\n canGenerate: boolean;\n}\n\n/** Load the panel for a build, or null when AI is off site-wide or for the project. */\nexport async function loadInsightPanel(\n project: Project,\n build: Build,\n): Promise<InsightPanelData | null> {\n const deps = insightDepsFromStore();\n if (!deps || project.aiProfile === null || project.aiProfile === undefined) {\n return null;\n }\n const { authEnabled } = getStore();\n const [latest] = await new InsightModel(deps.db).listForBuild(build.id, 1);\n const role = authEnabled ? await currentProjectRole(project.id) : \"admin\";\n return {\n latest: latest ? toInsightView(await freshen(deps, latest)) : null,\n canGenerate: role !== null && (APPROVER_ROLES as readonly string[]).includes(role),\n };\n}\n"],"mappings":";;;;;;;;;;;;;AAiBA,eAAsB,iBACpB,SACA,OACkC;CAClC,MAAM,OAAO,qBAAqB;CAClC,IAAI,CAAC,QAAQ,QAAQ,cAAc,QAAQ,QAAQ,cAAc,KAAA,GAC/D,OAAO;CAET,MAAM,EAAE,gBAAgB,SAAS;CACjC,MAAM,CAAC,UAAU,MAAM,IAAI,aAAa,KAAK,EAAE,CAAC,CAAC,aAAa,MAAM,IAAI,CAAC;CACzE,MAAM,OAAO,cAAc,MAAM,mBAAmB,QAAQ,EAAE,IAAI;CAClE,OAAO;EACL,QAAQ,SAAS,cAAc,MAAM,QAAQ,MAAM,MAAM,CAAC,IAAI;EAC9D,aAAa,SAAS,QAAS,eAAqC,SAAS,IAAI;CACnF;AACF"}
@@ -0,0 +1,113 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { assertWithinBudget } from "./budget.mjs";
5
+ import { spawnJob } from "./job.mjs";
6
+ import { loadHealthEvidence, parseWindow } from "./load-health.mjs";
7
+ import { loadTriageEvidence } from "./load-triage.mjs";
8
+ import { InsightModel } from "@storyshelf/core/models";
9
+ import { HEALTH_SYSTEM, PROMPT_VERSION, TRIAGE_SYSTEM, computeInputHash, healthEnvelopeSchema, triageEnvelopeSchema } from "@storyshelf/core/insights";
10
+ import { createHash } from "node:crypto";
11
+ //#region src/insights/request.ts
12
+ /** Request-side insight orchestration: evidence, hash, cache check, budget, spawn. */
13
+ /** The profile a project uses: override, else its gate value (`default` = default profile). */
14
+ function profileFor(deps, project, override) {
15
+ const wanted = override ?? project.aiProfile ?? "default";
16
+ if (deps.ai.profileNames().includes(wanted)) return wanted;
17
+ return deps.ai.defaultProfile();
18
+ }
19
+ function digest(data) {
20
+ return createHash("sha256").update(data).digest("hex");
21
+ }
22
+ /** Budget applies only when a new run would start (cache hits and in-flight rows are free). */
23
+ async function guardBudget(deps, input, existing) {
24
+ if (!(existing?.status === "pending" || existing?.status === "running")) await assertWithinBudget(deps, input.projectId);
25
+ }
26
+ async function launch(deps, input, begin) {
27
+ const model = new InsightModel(deps.db);
28
+ const existing = await model.peek(input);
29
+ if (existing?.status === "done" && existing.inputHash === input.inputHash && !input.force) return {
30
+ row: existing,
31
+ status: 200
32
+ };
33
+ await guardBudget(deps, input, existing);
34
+ const { row, run } = await model.start(input);
35
+ if (run) begin(row);
36
+ return {
37
+ row,
38
+ status: row.status === "done" ? 200 : 202
39
+ };
40
+ }
41
+ /** Build the base `InsightStartInput` shared by both kinds. */
42
+ function baseInput(deps, project, profile, task, evidence, images) {
43
+ const model = deps.ai.modelId(profile, task, images.length > 0);
44
+ return {
45
+ projectId: project.id,
46
+ profile,
47
+ model,
48
+ promptVersion: PROMPT_VERSION,
49
+ inputHash: computeInputHash({
50
+ evidenceText: evidence.text,
51
+ imageDigests: images.map((data) => digest(data)),
52
+ promptVersion: PROMPT_VERSION,
53
+ task,
54
+ profile,
55
+ model
56
+ })
57
+ };
58
+ }
59
+ /** Request (or fetch the cached) triage insight for a build. */
60
+ async function requestTriage(deps, project, build, options) {
61
+ const profile = profileFor(deps, project, options.profile);
62
+ const loaded = await loadTriageEvidence(deps, project, build);
63
+ const vision = deps.ai.hasVision(profile, "triage");
64
+ const evidence = {
65
+ ...loaded,
66
+ images: vision ? loaded.images : []
67
+ };
68
+ return await launch(deps, {
69
+ ...baseInput(deps, project, profile, "triage", evidence, evidence.images.map((i) => i.data)),
70
+ buildId: build.id,
71
+ kind: "triage",
72
+ windowKey: null,
73
+ force: options.force
74
+ }, (row) => {
75
+ spawnJob(deps, row, {
76
+ task: "triage",
77
+ system: TRIAGE_SYSTEM,
78
+ evidence,
79
+ schema: triageEnvelopeSchema,
80
+ profileRequested: options.profile ?? project.aiProfile ?? null,
81
+ project,
82
+ reviewSuffix: `/builds/${build.id}`
83
+ });
84
+ });
85
+ }
86
+ /** Request (or fetch the cached) project-health insight for a window like `30d`. */
87
+ async function requestHealth(deps, project, window, options) {
88
+ const days = parseWindow(window);
89
+ if (days === null) return null;
90
+ const profile = profileFor(deps, project, options.profile);
91
+ const evidence = await loadHealthEvidence(deps, project, window, days);
92
+ return await launch(deps, {
93
+ ...baseInput(deps, project, profile, "health", evidence, []),
94
+ buildId: null,
95
+ kind: "health",
96
+ windowKey: window,
97
+ force: options.force
98
+ }, (row) => {
99
+ spawnJob(deps, row, {
100
+ task: "health",
101
+ system: HEALTH_SYSTEM,
102
+ evidence,
103
+ schema: healthEnvelopeSchema,
104
+ profileRequested: options.profile ?? project.aiProfile ?? null,
105
+ project,
106
+ reviewSuffix: ""
107
+ });
108
+ });
109
+ }
110
+ //#endregion
111
+ export { profileFor, requestHealth, requestTriage };
112
+
113
+ //# sourceMappingURL=request.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request.mjs","names":[],"sources":["../../src/insights/request.ts"],"sourcesContent":["/** Request-side insight orchestration: evidence, hash, cache check, budget, spawn. */\nimport {\n HEALTH_SYSTEM,\n PROMPT_VERSION,\n TRIAGE_SYSTEM,\n computeInputHash,\n healthEnvelopeSchema,\n triageEnvelopeSchema,\n type BuiltEvidence,\n} from \"@storyshelf/core/insights\";\nimport { InsightModel, type InsightStartInput } from \"@storyshelf/core/models\";\nimport type { Build, InsightRow, Project } from \"@storyshelf/core/schema\";\nimport { createHash } from \"node:crypto\";\nimport { assertWithinBudget } from \"./budget.ts\";\nimport type { InsightDeps } from \"./deps.ts\";\nimport { spawnJob } from \"./job.ts\";\nimport { loadHealthEvidence, parseWindow } from \"./load-health.ts\";\nimport { loadTriageEvidence } from \"./load-triage.ts\";\n\n/** Outcome of a request: the row and whether it was already finished (200) or queued (202). */\nexport interface RequestOutcome {\n row: InsightRow;\n status: 200 | 202;\n}\n\n/** Options for starting a run. */\nexport interface RunOptions {\n force: boolean;\n /** Profile override (honoured only for site admins by the router). */\n profile?: string | undefined;\n}\n\n/** The profile a project uses: override, else its gate value (`default` = default profile). */\nexport function profileFor(deps: InsightDeps, project: Project, override?: string): string {\n const wanted = override ?? project.aiProfile ?? \"default\";\n const names = deps.ai.profileNames();\n if (names.includes(wanted)) {\n return wanted;\n }\n return deps.ai.defaultProfile();\n}\n\nfunction digest(data: Uint8Array): string {\n return createHash(\"sha256\").update(data).digest(\"hex\");\n}\n\n/** Budget applies only when a new run would start (cache hits and in-flight rows are free). */\nasync function guardBudget(\n deps: InsightDeps,\n input: InsightStartInput,\n existing: InsightRow | null,\n): Promise<void> {\n const inFlight = existing?.status === \"pending\" || existing?.status === \"running\";\n if (!inFlight) {\n await assertWithinBudget(deps, input.projectId);\n }\n}\n\nasync function launch(\n deps: InsightDeps,\n input: InsightStartInput,\n begin: (row: InsightRow) => void,\n): Promise<RequestOutcome> {\n const model = new InsightModel(deps.db);\n const existing = await model.peek(input);\n if (existing?.status === \"done\" && existing.inputHash === input.inputHash && !input.force) {\n return { row: existing, status: 200 };\n }\n await guardBudget(deps, input, existing);\n const { row, run } = await model.start(input);\n if (run) {\n begin(row);\n }\n return { row, status: row.status === \"done\" ? 200 : 202 };\n}\n\n/** Build the base `InsightStartInput` shared by both kinds. */\nfunction baseInput(\n deps: InsightDeps,\n project: Project,\n profile: string,\n task: \"triage\" | \"health\",\n evidence: BuiltEvidence,\n images: Uint8Array[],\n): Pick<InsightStartInput, \"inputHash\" | \"profile\" | \"model\" | \"promptVersion\" | \"projectId\"> {\n const model = deps.ai.modelId(profile, task, images.length > 0);\n return {\n projectId: project.id,\n profile,\n model,\n promptVersion: PROMPT_VERSION,\n inputHash: computeInputHash({\n evidenceText: evidence.text,\n imageDigests: images.map((data) => digest(data)),\n promptVersion: PROMPT_VERSION,\n task,\n profile,\n model,\n }),\n };\n}\n\n/** Request (or fetch the cached) triage insight for a build. */\nexport async function requestTriage(\n deps: InsightDeps,\n project: Project,\n build: Build,\n options: RunOptions,\n): Promise<RequestOutcome> {\n const profile = profileFor(deps, project, options.profile);\n const loaded = await loadTriageEvidence(deps, project, build);\n const vision = deps.ai.hasVision(profile, \"triage\");\n const evidence = { ...loaded, images: vision ? loaded.images : [] };\n const input: InsightStartInput = {\n ...baseInput(\n deps,\n project,\n profile,\n \"triage\",\n evidence,\n evidence.images.map((i) => i.data),\n ),\n buildId: build.id,\n kind: \"triage\",\n windowKey: null,\n force: options.force,\n };\n return await launch(deps, input, (row) => {\n spawnJob(deps, row, {\n task: \"triage\",\n system: TRIAGE_SYSTEM,\n evidence,\n schema: triageEnvelopeSchema,\n profileRequested: options.profile ?? project.aiProfile ?? null,\n project,\n reviewSuffix: `/builds/${build.id}`,\n });\n });\n}\n\n/** Request (or fetch the cached) project-health insight for a window like `30d`. */\nexport async function requestHealth(\n deps: InsightDeps,\n project: Project,\n window: string,\n options: RunOptions,\n): Promise<RequestOutcome | null> {\n const days = parseWindow(window);\n if (days === null) {\n return null;\n }\n const profile = profileFor(deps, project, options.profile);\n const evidence = await loadHealthEvidence(deps, project, window, days);\n const input: InsightStartInput = {\n ...baseInput(deps, project, profile, \"health\", evidence, []),\n buildId: null,\n kind: \"health\",\n windowKey: window,\n force: options.force,\n };\n return await launch(deps, input, (row) => {\n spawnJob(deps, row, {\n task: \"health\",\n system: HEALTH_SYSTEM,\n evidence,\n schema: healthEnvelopeSchema,\n profileRequested: options.profile ?? project.aiProfile ?? null,\n project,\n reviewSuffix: \"\",\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;AAiCA,SAAgB,WAAW,MAAmB,SAAkB,UAA2B;CACzF,MAAM,SAAS,YAAY,QAAQ,aAAa;CAEhD,IADc,KAAK,GAAG,aACd,CAAC,CAAC,SAAS,MAAM,GACvB,OAAO;CAET,OAAO,KAAK,GAAG,eAAe;AAChC;AAEA,SAAS,OAAO,MAA0B;CACxC,OAAO,WAAW,QAAQ,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,OAAO,KAAK;AACvD;;AAGA,eAAe,YACb,MACA,OACA,UACe;CAEf,IAAI,EADa,UAAU,WAAW,aAAa,UAAU,WAAW,YAEtE,MAAM,mBAAmB,MAAM,MAAM,SAAS;AAElD;AAEA,eAAe,OACb,MACA,OACA,OACyB;CACzB,MAAM,QAAQ,IAAI,aAAa,KAAK,EAAE;CACtC,MAAM,WAAW,MAAM,MAAM,KAAK,KAAK;CACvC,IAAI,UAAU,WAAW,UAAU,SAAS,cAAc,MAAM,aAAa,CAAC,MAAM,OAClF,OAAO;EAAE,KAAK;EAAU,QAAQ;CAAI;CAEtC,MAAM,YAAY,MAAM,OAAO,QAAQ;CACvC,MAAM,EAAE,KAAK,QAAQ,MAAM,MAAM,MAAM,KAAK;CAC5C,IAAI,KACF,MAAM,GAAG;CAEX,OAAO;EAAE;EAAK,QAAQ,IAAI,WAAW,SAAS,MAAM;CAAI;AAC1D;;AAGA,SAAS,UACP,MACA,SACA,SACA,MACA,UACA,QAC4F;CAC5F,MAAM,QAAQ,KAAK,GAAG,QAAQ,SAAS,MAAM,OAAO,SAAS,CAAC;CAC9D,OAAO;EACL,WAAW,QAAQ;EACnB;EACA;EACA,eAAe;EACf,WAAW,iBAAiB;GAC1B,cAAc,SAAS;GACvB,cAAc,OAAO,KAAK,SAAS,OAAO,IAAI,CAAC;GAC/C,eAAe;GACf;GACA;GACA;EACF,CAAC;CACH;AACF;;AAGA,eAAsB,cACpB,MACA,SACA,OACA,SACyB;CACzB,MAAM,UAAU,WAAW,MAAM,SAAS,QAAQ,OAAO;CACzD,MAAM,SAAS,MAAM,mBAAmB,MAAM,SAAS,KAAK;CAC5D,MAAM,SAAS,KAAK,GAAG,UAAU,SAAS,QAAQ;CAClD,MAAM,WAAW;EAAE,GAAG;EAAQ,QAAQ,SAAS,OAAO,SAAS,CAAC;CAAE;CAelE,OAAO,MAAM,OAAO,MAAM;EAbxB,GAAG,UACD,MACA,SACA,SACA,UACA,UACA,SAAS,OAAO,KAAK,MAAM,EAAE,IAAI,CACnC;EACA,SAAS,MAAM;EACf,MAAM;EACN,WAAW;EACX,OAAO,QAAQ;CAEa,IAAI,QAAQ;EACxC,SAAS,MAAM,KAAK;GAClB,MAAM;GACN,QAAQ;GACR;GACA,QAAQ;GACR,kBAAkB,QAAQ,WAAW,QAAQ,aAAa;GAC1D;GACA,cAAc,WAAW,MAAM;EACjC,CAAC;CACH,CAAC;AACH;;AAGA,eAAsB,cACpB,MACA,SACA,QACA,SACgC;CAChC,MAAM,OAAO,YAAY,MAAM;CAC/B,IAAI,SAAS,MACX,OAAO;CAET,MAAM,UAAU,WAAW,MAAM,SAAS,QAAQ,OAAO;CACzD,MAAM,WAAW,MAAM,mBAAmB,MAAM,SAAS,QAAQ,IAAI;CAQrE,OAAO,MAAM,OAAO,MAAM;EANxB,GAAG,UAAU,MAAM,SAAS,SAAS,UAAU,UAAU,CAAC,CAAC;EAC3D,SAAS;EACT,MAAM;EACN,WAAW;EACX,OAAO,QAAQ;CAEa,IAAI,QAAQ;EACxC,SAAS,MAAM,KAAK;GAClB,MAAM;GACN,QAAQ;GACR;GACA,QAAQ;GACR,kBAAkB,QAAQ,WAAW,QAAQ,aAAa;GAC1D;GACA,cAAc;EAChB,CAAC;CACH,CAAC;AACH"}