@k2b/cloud 0.6.0 → 0.8.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 (349) hide show
  1. package/README.md +2 -2
  2. package/package.json +22 -11
  3. package/scripts/README.md +10 -0
  4. package/scripts/browser-performance.ts +14 -0
  5. package/scripts/build-pdf-renderer.ts +39 -0
  6. package/scripts/build.ts +20 -35
  7. package/scripts/preload.ts +16 -12
  8. package/scripts/sync-recovery-smoke.ts +22 -6
  9. package/src/_internal/app-presentation.ts +14 -2
  10. package/src/_internal/build-metadata.ts +8 -1
  11. package/src/_internal/capabilities.ts +131 -7
  12. package/src/_internal/capability-streams.ts +81 -0
  13. package/src/_internal/define-app.ts +80 -37
  14. package/src/_internal/heartbeat.ts +4 -0
  15. package/src/_internal/help-catalog.ts +27 -172
  16. package/src/_internal/help.ts +13 -76
  17. package/src/_internal/page-responses.ts +2 -0
  18. package/src/_internal/postgres-application-name.ts +30 -0
  19. package/src/_internal/process-identity.ts +15 -0
  20. package/src/_internal/process-sync.ts +1 -1
  21. package/src/_internal/readiness.ts +4 -1
  22. package/src/_internal/registry-validation.ts +29 -25
  23. package/src/_internal/registry.ts +2 -53
  24. package/src/_internal/runtime-context.ts +5 -16
  25. package/src/_internal/server-timing.ts +13 -0
  26. package/src/_internal/status-preserving-ssr.ts +18 -6
  27. package/src/_internal/web-vitals-asset.ts +10 -0
  28. package/src/access/PermissionEditor.tsx +18 -151
  29. package/src/access/PrincipalPicker.tsx +110 -0
  30. package/src/access/ResourceApiKeys.tsx +1 -1
  31. package/src/access/ui.ts +1 -0
  32. package/src/ai/admin.ts +14 -13
  33. package/src/ai/assistant-models.ts +12 -1
  34. package/src/ai/audio-format.ts +34 -0
  35. package/src/ai/audio-tool.ts +90 -0
  36. package/src/ai/browser-code-contracts.ts +201 -0
  37. package/src/ai/browser.ts +4 -0
  38. package/src/ai/capabilities.ts +55 -36
  39. package/src/ai/capability-execution.ts +47 -12
  40. package/src/ai/chat/blocks.tsx +229 -90
  41. package/src/ai/chat/builtin-tools.tsx +2 -4
  42. package/src/ai/chat/capability-result.ts +15 -0
  43. package/src/ai/chat/capability-table.tsx +2 -10
  44. package/src/ai/chat/composer-adapter.ts +83 -3
  45. package/src/ai/chat/message-actions.tsx +8 -7
  46. package/src/ai/chat/message-utils.ts +25 -1
  47. package/src/ai/chat/messages.ts +2 -0
  48. package/src/ai/chat/presentation.tsx +31 -47
  49. package/src/ai/chat/primitives.tsx +43 -1
  50. package/src/ai/chat/tool-disclosure.tsx +21 -2
  51. package/src/ai/chat/tool-groups.ts +58 -0
  52. package/src/ai/chat-quotas.ts +25 -0
  53. package/src/ai/chat-task-contracts.ts +14 -10
  54. package/src/ai/chat-tasks.ts +297 -43
  55. package/src/ai/client/controller.ts +165 -39
  56. package/src/ai/client/file-source.ts +12 -6
  57. package/src/ai/client/live-connection.ts +1 -0
  58. package/src/ai/client/projection.ts +29 -11
  59. package/src/ai/client/transport.ts +6 -0
  60. package/src/ai/code-capability-routes.ts +105 -0
  61. package/src/ai/code-capability-transport.ts +39 -0
  62. package/src/ai/code-execution.ts +53 -0
  63. package/src/ai/code-mode-skill.ts +118 -0
  64. package/src/ai/code-runtime-tools.ts +179 -0
  65. package/src/ai/code-source-contracts.ts +270 -0
  66. package/src/ai/code-source-tools.ts +188 -0
  67. package/src/ai/compaction.ts +27 -6
  68. package/src/ai/data-analysis-skill.ts +10 -0
  69. package/src/ai/default-tools.ts +99 -3
  70. package/src/ai/dictation-runtime.ts +171 -0
  71. package/src/ai/dictations.ts +188 -0
  72. package/src/ai/draft-content.ts +49 -0
  73. package/src/ai/enrich.ts +6 -0
  74. package/src/ai/executor.ts +189 -33
  75. package/src/ai/fetch-file-tool.ts +2 -2
  76. package/src/ai/file-content-version.ts +27 -0
  77. package/src/ai/file-context.ts +2 -1
  78. package/src/ai/file-media-type.ts +12 -0
  79. package/src/ai/file-reference-contracts.ts +15 -0
  80. package/src/ai/file-tools.ts +8 -1
  81. package/src/ai/files-store.ts +212 -71
  82. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  83. package/src/ai/grids-skill.ts +42 -10
  84. package/src/ai/http.ts +26 -8
  85. package/src/ai/index.ts +26 -3
  86. package/src/ai/inference-calls.ts +197 -0
  87. package/src/ai/live-events.ts +5 -1
  88. package/src/ai/live.ts +1 -1
  89. package/src/ai/markdown-pdf-tool.ts +2 -2
  90. package/src/ai/memories.ts +30 -27
  91. package/src/ai/memory-learning-runs.ts +1 -1
  92. package/src/ai/memory-learning.ts +64 -32
  93. package/src/ai/memory-tool.ts +1 -5
  94. package/src/ai/memory-workflow-evidence.ts +9 -4
  95. package/src/ai/message-queue.ts +178 -0
  96. package/src/ai/migrate.ts +189 -11
  97. package/src/ai/model-pricing.ts +43 -0
  98. package/src/ai/pdf-render-worker.ts +75 -0
  99. package/src/ai/pdf-render.fixture.ts +28 -0
  100. package/src/ai/pdf-render.ts +77 -0
  101. package/src/ai/projects-routes.ts +18 -43
  102. package/src/ai/projects.ts +33 -14
  103. package/src/ai/protocol.ts +3 -0
  104. package/src/ai/provider.ts +1 -8
  105. package/src/ai/quota-provider.ts +192 -0
  106. package/src/ai/quota-report.ts +152 -0
  107. package/src/ai/quotas-migrate.ts +45 -0
  108. package/src/ai/quotas.ts +168 -0
  109. package/src/ai/routes.ts +241 -11
  110. package/src/ai/run-timeout.ts +12 -0
  111. package/src/ai/runtime-tools.ts +7 -5
  112. package/src/ai/runtime.ts +74 -17
  113. package/src/ai/settings.ts +36 -8
  114. package/src/ai/short-id.ts +4 -5
  115. package/src/ai/skill-catalog.ts +0 -10
  116. package/src/ai/skill-search.ts +58 -0
  117. package/src/ai/skill-seeds.ts +114 -20
  118. package/src/ai/skill-tool.ts +21 -5
  119. package/src/ai/skills-routes.ts +49 -4
  120. package/src/ai/skills.ts +358 -163
  121. package/src/ai/store.ts +416 -201
  122. package/src/ai/stream.ts +5 -0
  123. package/src/ai/structured-runs.ts +4 -3
  124. package/src/ai/structured.ts +12 -1
  125. package/src/ai/system-prompt.ts +14 -2
  126. package/src/ai/task-contracts.ts +84 -0
  127. package/src/ai/task-execution.ts +92 -0
  128. package/src/ai/timeline.ts +2 -5
  129. package/src/ai/todo-contracts.ts +32 -0
  130. package/src/ai/todo-tool.ts +19 -0
  131. package/src/ai/tool-audit.ts +30 -40
  132. package/src/ai/tools.ts +6 -0
  133. package/src/ai/transcription.ts +202 -0
  134. package/src/ai/turn-timing.ts +114 -0
  135. package/src/ai/types.ts +58 -10
  136. package/src/ai/ui.tsx +3 -0
  137. package/src/ai/usage.ts +118 -118
  138. package/src/ai/vision-tool.ts +60 -10
  139. package/src/api/admin-ai-projects.ts +2 -2
  140. package/src/api/admin-ai-quotas.ts +80 -0
  141. package/src/api/admin-ai-skills.ts +48 -5
  142. package/src/api/admin-ai-usage.ts +2 -2
  143. package/src/api/admin-core-settings.ts +25 -0
  144. package/src/api/admin-identity.ts +91 -51
  145. package/src/api/admin-linux-identities.ts +10 -4
  146. package/src/api/admin-rail.ts +58 -0
  147. package/src/api/announcements.ts +14 -0
  148. package/src/api/app-approval.ts +2 -2
  149. package/src/api/auth.ts +1 -1
  150. package/src/api/capabilities.ts +324 -43
  151. package/src/api/capability-streams.ts +262 -0
  152. package/src/api/help.ts +8 -44
  153. package/src/api/index.ts +8 -2
  154. package/src/api/mcp.ts +45 -34
  155. package/src/api/me-web-vitals.ts +35 -0
  156. package/src/api/me.ts +2 -0
  157. package/src/api/search/schemas.ts +26 -15
  158. package/src/api/search.bench.ts +1 -1
  159. package/src/api/search.ts +54 -17
  160. package/src/browser/CloudResourceSearch.tsx +640 -702
  161. package/src/browser/app-approval-vault.ts +19 -7
  162. package/src/browser/command-bridge.ts +109 -0
  163. package/src/browser/command-shortcuts.ts +76 -0
  164. package/src/browser/commands.ts +67 -0
  165. package/src/browser/navigation-search.ts +48 -0
  166. package/src/browser/notifications.ts +1 -1
  167. package/src/browser/resource-picker.tsx +5 -18
  168. package/src/browser/resource-search-dialog.ts +9 -0
  169. package/src/browser/resource-search-input.ts +46 -0
  170. package/src/browser/resource-search-messages.ts +76 -40
  171. package/src/browser/resource-search.ts +7 -0
  172. package/src/browser/search-bridge.ts +102 -0
  173. package/src/browser/search-commands.ts +59 -0
  174. package/src/browser/search.ts +13 -0
  175. package/src/browser/spotlight-position.ts +208 -0
  176. package/src/browser/testing.ts +6 -0
  177. package/src/browser/web-vitals.ts +25 -0
  178. package/src/capabilities/claims.ts +179 -0
  179. package/src/capabilities/client.ts +34 -0
  180. package/src/capabilities/command-link.ts +16 -0
  181. package/src/capabilities/executions.ts +298 -0
  182. package/src/capabilities/migrate.ts +81 -0
  183. package/src/capabilities/server.ts +116 -35
  184. package/src/capabilities/stream-body.ts +16 -0
  185. package/src/capabilities/streams.ts +28 -0
  186. package/src/cli/admin/ai-quotas.ts +161 -0
  187. package/src/cli/admin/ai-skills.ts +64 -0
  188. package/src/cli/admin/ai-usage.ts +53 -30
  189. package/src/cli/admin/app-credentials.ts +77 -0
  190. package/src/cli/admin/data.ts +17 -0
  191. package/src/cli/admin/index.ts +10 -1
  192. package/src/cli/admin/instance.ts +2 -2
  193. package/src/cli/admin/linux.ts +4 -2
  194. package/src/cli/admin/sync.ts +1 -1
  195. package/src/cli/capabilities.ts +42 -0
  196. package/src/cli/capability-streams.ts +25 -0
  197. package/src/cli/index.ts +3 -2
  198. package/src/config/define-env.ts +97 -0
  199. package/src/config/env.ts +147 -51
  200. package/src/config/index.ts +3 -1
  201. package/src/contracts/app.ts +23 -3
  202. package/src/contracts/capabilities.ts +75 -5
  203. package/src/contracts/capability-streams.ts +46 -0
  204. package/src/contracts/commands.ts +49 -0
  205. package/src/contracts/index.ts +5 -2
  206. package/src/contracts/rail-admin.ts +38 -0
  207. package/src/contracts/rail-preferences.ts +16 -0
  208. package/src/contracts/registry.ts +5 -27
  209. package/src/contracts/settings-types.ts +1 -1
  210. package/src/contracts/shared.ts +18 -5
  211. package/src/contracts/web-vitals.ts +27 -0
  212. package/src/desktop/index.ts +1 -0
  213. package/src/desktop/solid.tsx +1 -1
  214. package/src/index.ts +8 -6
  215. package/src/server/index.ts +3 -0
  216. package/src/server/middleware/auth.ts +3 -2
  217. package/src/server/middleware/route-template.ts +9 -3
  218. package/src/server/middleware/runtime.ts +2 -1
  219. package/src/server/middleware/settings.ts +17 -11
  220. package/src/server/ratelimit.ts +1 -1
  221. package/src/server/services/access-revision.ts +10 -0
  222. package/src/server/services/access.ts +3 -1
  223. package/src/server/services/index.ts +1 -0
  224. package/src/services/account-category-policy.ts +9 -0
  225. package/src/services/account-lifecycle/audit.ts +1 -1
  226. package/src/services/accounts/app.ts +12 -2
  227. package/src/services/accounts/email-write.ts +23 -0
  228. package/src/services/accounts/groups.ts +15 -4
  229. package/src/services/accounts/identities.ts +249 -0
  230. package/src/services/accounts/identity-reconciliation.ts +87 -0
  231. package/src/services/accounts/ipa-data.ts +2 -2
  232. package/src/services/accounts/local-groups.ts +2 -2
  233. package/src/services/accounts/notification-sender.ts +12 -2
  234. package/src/services/accounts/posix.ts +45 -29
  235. package/src/services/accounts/users.ts +2 -2
  236. package/src/services/announcements/index.ts +69 -8
  237. package/src/services/app-approval.ts +11 -10
  238. package/src/services/audit/index.ts +1 -1
  239. package/src/services/cache-fill.ts +35 -0
  240. package/src/services/help/index.ts +109 -0
  241. package/src/services/help/maintenance.ts +25 -0
  242. package/src/services/help/store.ts +180 -0
  243. package/src/services/help/types.ts +46 -0
  244. package/src/services/identity/invocation-actor.ts +2 -2
  245. package/src/services/identity/key-config.ts +8 -5
  246. package/src/services/identity/key-ring.ts +1 -1
  247. package/src/services/identity/runtime-config.ts +6 -5
  248. package/src/services/index.ts +15 -0
  249. package/src/services/ipa/posix.ts +1 -1
  250. package/src/services/ipa/sync.ts +1 -1
  251. package/src/services/ipa/users.ts +268 -221
  252. package/src/services/legal-consent.ts +1 -1
  253. package/src/services/logging/index.ts +15 -13
  254. package/src/services/logging/trace.ts +2 -1
  255. package/src/services/logging/web-vitals.ts +81 -0
  256. package/src/services/mandates/index.ts +21 -4
  257. package/src/services/mandates/policy.ts +165 -2
  258. package/src/services/notifications/batches.ts +125 -57
  259. package/src/services/notifications/browser.ts +12 -0
  260. package/src/services/notifications/index.ts +0 -2
  261. package/src/services/notifications/platform.ts +0 -4
  262. package/src/services/oauth-tokens.ts +3 -3
  263. package/src/services/pdf/gotenberg.ts +131 -72
  264. package/src/services/pdf/index.ts +11 -8
  265. package/src/services/providers/local/users.ts +25 -16
  266. package/src/services/public-http.ts +101 -0
  267. package/src/services/rail-shortcuts.ts +95 -0
  268. package/src/services/rail-snapshot.ts +53 -0
  269. package/src/services/request-cache-redis.ts +29 -0
  270. package/src/services/session/index.ts +15 -2
  271. package/src/services/session/user.ts +62 -37
  272. package/src/services/settings/app.ts +5 -1
  273. package/src/services/settings/core-settings.ts +26 -35
  274. package/src/services/settings/defaults.ts +5 -0
  275. package/src/services/settings/index.ts +3 -3
  276. package/src/services/settings/store.ts +58 -84
  277. package/src/services/webauthn.ts +1 -1
  278. package/src/shared/ai-costs.ts +42 -0
  279. package/src/shared/ai-platform-prompt.ts +1 -1
  280. package/src/shared/ai-quotas.ts +136 -0
  281. package/src/shared/ai-usage.ts +6 -16
  282. package/src/shared/app-presentation.ts +21 -15
  283. package/src/shared/capability-messages.ts +14 -0
  284. package/src/shared/help.ts +6 -42
  285. package/src/shared/index.ts +3 -0
  286. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  287. package/src/ssr/AdminLayout.tsx +7 -5
  288. package/src/ssr/AdminSidebar.tsx +30 -16
  289. package/src/ssr/AppLaunchpad.island.tsx +33 -136
  290. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  291. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  292. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  293. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  294. package/src/ssr/Layout.tsx +17 -6
  295. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  296. package/src/ssr/LayoutFooter.tsx +1 -1
  297. package/src/ssr/LayoutHeader.tsx +46 -14
  298. package/src/ssr/LayoutHelp.tsx +22 -23
  299. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  300. package/src/ssr/LayoutRail.tsx +17 -9
  301. package/src/ssr/MinimalLayout.tsx +1 -1
  302. package/src/ssr/MobileNavigation.tsx +71 -0
  303. package/src/ssr/MobileProfileActions.tsx +49 -0
  304. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  305. package/src/ssr/RailApps.island.tsx +2 -2
  306. package/src/ssr/RailEditor.tsx +17 -2
  307. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  308. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  309. package/src/ssr/admin-navigation.ts +11 -1
  310. package/src/ssr/help.ts +29 -0
  311. package/src/ssr/islands/SearchBar.tsx +67 -0
  312. package/src/ssr/islands/index.ts +3 -1
  313. package/src/ssr/layout-context.ts +2 -2
  314. package/src/ssr/layout-help-search.ts +15 -0
  315. package/src/ssr/mobile-menu-history.ts +70 -0
  316. package/src/ssr/platform-messages.ts +16 -8
  317. package/src/ssr/profile-actions.ts +42 -0
  318. package/src/ssr/rail-context.ts +8 -4
  319. package/src/ssr/rail-messages.ts +2 -0
  320. package/src/ssr/rail-navigation.ts +3 -3
  321. package/src/ssr/workspace-navigation.ts +75 -0
  322. package/src/styles/effects.css +6 -6
  323. package/src/styles/global.css +54 -0
  324. package/src/styles/input.css +43 -41
  325. package/src/styles/resource-search.css +505 -0
  326. package/src/styles/utilities-feedback.css +13 -0
  327. package/src/styles/utilities-layout.css +1 -5
  328. package/src/styles/utilities-navigation.css +3 -39
  329. package/src/types/ambient.d.ts +5 -0
  330. package/src/workflows/ai/index.ts +2 -2
  331. package/src/workflows/ai/runtime.ts +19 -90
  332. package/src/workflows/ai/store.ts +2 -0
  333. package/src/workflows/ai/types.ts +6 -82
  334. package/src/workflows/ai-actions.ts +16 -13
  335. package/src/workflows/definition.ts +10 -5
  336. package/src/workflows/store/actions.ts +44 -20
  337. package/src/workflows/store/index.ts +4 -2
  338. package/src/workflows/store/runs.ts +36 -0
  339. package/src/workflows/store/worker-pool.ts +55 -0
  340. package/src/workflows/store/worker-runtime.ts +90 -0
  341. package/src/ai/kit-skill.ts +0 -22
  342. package/src/ai/usage-migrate.ts +0 -81
  343. package/src/browser/notification-target.ts +0 -27
  344. package/src/contracts/notification-live.ts +0 -66
  345. package/src/server/services/freeipa/test-certificates.ts +0 -44
  346. package/src/services/notifications/live.ts +0 -50
  347. package/src/services/session/test-fixture.ts +0 -18
  348. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  349. package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/migrate.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { sql } from "bun";
2
+ import { migrateAiMessageQueue } from "./message-queue";
2
3
  import { migrateAiModelAccess } from "./model-access-migrate";
4
+ import { migrateAiQuotas } from "./quotas-migrate";
3
5
  import { withAiShortId } from "./short-id";
4
- import { migrateAiTurnUsage } from "./usage-migrate";
5
6
 
6
7
  const backfillAiShortIds = async (
7
8
  constraint: string,
@@ -141,10 +142,21 @@ export const migrateCloudAi = async (): Promise<void> => {
141
142
  await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enrich_failed_at TIMESTAMPTZ`.simple();
142
143
  await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS enrich_fail_count INTEGER NOT NULL DEFAULT 0`.simple();
143
144
  await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ`.simple();
145
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS done BOOLEAN`.simple();
146
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS last_used_at TIMESTAMPTZ`.simple();
147
+ await sql`DO $$ BEGIN
148
+ IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'ai' AND table_name = 'conversations' AND column_name = 'done_at') THEN
149
+ UPDATE ai.conversations SET done = TRUE WHERE done_at IS NOT NULL;
150
+ ALTER TABLE ai.conversations DROP COLUMN done_at;
151
+ END IF;
152
+ END $$`.simple();
144
153
  await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS last_viewed_at TIMESTAMPTZ NOT NULL DEFAULT now()`.simple();
145
154
  await sql`ALTER TABLE ai.conversations ALTER COLUMN last_viewed_at SET DEFAULT now()`.simple();
146
155
  await sql`UPDATE ai.conversations SET last_viewed_at = now() WHERE last_viewed_at IS NULL`.simple();
147
156
  await sql`ALTER TABLE ai.conversations ALTER COLUMN last_viewed_at SET NOT NULL`.simple();
157
+ await sql`UPDATE ai.conversations SET last_used_at = GREATEST(created_at, updated_at, last_viewed_at) WHERE last_used_at IS NULL`.simple();
158
+ await sql`ALTER TABLE ai.conversations ALTER COLUMN last_used_at SET DEFAULT now()`.simple();
159
+ await sql`ALTER TABLE ai.conversations ALTER COLUMN last_used_at SET NOT NULL`.simple();
148
160
  await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_text TEXT NOT NULL DEFAULT ''`.simple();
149
161
  await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS search_document TSVECTOR NOT NULL DEFAULT ''::tsvector`.simple();
150
162
  await sql`
@@ -242,7 +254,6 @@ export const migrateCloudAi = async (): Promise<void> => {
242
254
  input_tokens INTEGER CHECK (input_tokens >= 0),
243
255
  output_tokens INTEGER CHECK (output_tokens >= 0),
244
256
  total_tokens INTEGER CHECK (total_tokens >= 0),
245
- credits_used DOUBLE PRECISION CHECK (credits_used >= 0),
246
257
  mode TEXT,
247
258
  repaired BOOLEAN,
248
259
  attempts INTEGER CHECK (attempts IS NULL OR attempts >= 1),
@@ -447,6 +458,7 @@ export const migrateCloudAi = async (): Promise<void> => {
447
458
  await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ`.simple();
448
459
  await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS heartbeat_at TIMESTAMPTZ`.simple();
449
460
  await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS run_config JSONB`.simple();
461
+ await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS run_budget_ms DOUBLE PRECISION`.simple();
450
462
  await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS attempt INTEGER NOT NULL DEFAULT 0`.simple();
451
463
  await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS live_blocks JSONB`.simple();
452
464
  await sql`ALTER TABLE ai.turns ADD COLUMN IF NOT EXISTS live_seq BIGINT NOT NULL DEFAULT 0`.simple();
@@ -464,9 +476,15 @@ export const migrateCloudAi = async (): Promise<void> => {
464
476
  `.simple();
465
477
 
466
478
  await sql`
467
- CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turns_one_active_per_conversation
468
- ON ai.turns(conversation_id)
469
- WHERE status IN ('queued', 'running', 'waiting_for_action')
479
+ DO $$ BEGIN
480
+ IF EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'ai'
481
+ AND indexname = 'idx_ai_turns_one_active_per_conversation' AND indexdef NOT LIKE '%background%') THEN
482
+ DROP INDEX ai.idx_ai_turns_one_active_per_conversation;
483
+ END IF;
484
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_turns_one_active_per_conversation
485
+ ON ai.turns(conversation_id)
486
+ WHERE status IN ('queued', 'running', 'waiting_for_action') AND NOT COALESCE(run_config ? 'background', false);
487
+ END $$
470
488
  `.simple();
471
489
 
472
490
  await sql`
@@ -593,6 +611,14 @@ export const migrateCloudAi = async (): Promise<void> => {
593
611
  )
594
612
  `.simple();
595
613
 
614
+ await sql`CREATE TABLE IF NOT EXISTS ai.turn_generation_timings (
615
+ turn_id UUID NOT NULL REFERENCES ai.turns(id) ON DELETE CASCADE,
616
+ request_id TEXT NOT NULL,
617
+ started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
618
+ ended_at TIMESTAMPTZ,
619
+ PRIMARY KEY (turn_id, request_id)
620
+ )`.simple();
621
+
596
622
  await sql`ALTER TABLE ai.pending_actions ADD COLUMN IF NOT EXISTS review JSONB`.simple();
597
623
 
598
624
  await sql`
@@ -670,9 +696,6 @@ export const migrateCloudAi = async (): Promise<void> => {
670
696
  location TEXT NOT NULL DEFAULT 'server',
671
697
  status TEXT NOT NULL DEFAULT 'pending',
672
698
  approval_state TEXT NOT NULL DEFAULT 'not_required',
673
- input_meta JSONB,
674
- output_meta JSONB,
675
- error TEXT,
676
699
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
677
700
  started_at TIMESTAMPTZ,
678
701
  approval_requested_at TIMESTAMPTZ,
@@ -695,6 +718,23 @@ export const migrateCloudAi = async (): Promise<void> => {
695
718
  `.simple();
696
719
 
697
720
  await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS idempotency_key TEXT`.simple();
721
+
722
+ // Capability shape, outcome, and latency belong to capabilities.executions,
723
+ // which the capability dispatcher writes exactly once. ai.tool_calls keeps
724
+ // only AI-specific state: approval, turn linkage, and the correlating
725
+ // request id shared with the execution record and the app's audit rows.
726
+ await sql`ALTER TABLE ai.tool_calls ADD COLUMN IF NOT EXISTS request_id TEXT`.simple();
727
+ await sql`
728
+ ALTER TABLE ai.tool_calls
729
+ DROP COLUMN IF EXISTS input_meta,
730
+ DROP COLUMN IF EXISTS output_meta,
731
+ DROP COLUMN IF EXISTS error
732
+ `.simple();
733
+ await sql`
734
+ CREATE INDEX IF NOT EXISTS idx_ai_tool_calls_request
735
+ ON ai.tool_calls(request_id)
736
+ WHERE request_id IS NOT NULL
737
+ `.simple();
698
738
  await sql`
699
739
  CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_tool_calls_idempotency_key
700
740
  ON ai.tool_calls(idempotency_key)
@@ -1391,6 +1431,24 @@ export const migrateCloudAi = async (): Promise<void> => {
1391
1431
  CONSTRAINT ai_skills_extra_frontmatter_check CHECK (jsonb_typeof(extra_frontmatter) = 'object')
1392
1432
  )
1393
1433
  `.simple();
1434
+ // pg_trgm is a standard PostgreSQL extension, also used by Mail and Grids.
1435
+ await sql`CREATE EXTENSION IF NOT EXISTS pg_trgm`.simple();
1436
+ await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS search_text TEXT
1437
+ GENERATED ALWAYS AS (name || ' ' || description) STORED`.simple();
1438
+ // Optional BM25 improves relevance without changing which Skills are visible.
1439
+ try {
1440
+ const [extension] = await sql<{ installed: boolean }[]>`
1441
+ SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch') AS installed
1442
+ `;
1443
+ if (extension?.installed) {
1444
+ await sql
1445
+ .unsafe(`CREATE INDEX IF NOT EXISTS skills_search_bm25_idx
1446
+ ON ai.skills USING bm25 ((search_text)) WITH (text_config='simple')`)
1447
+ .simple();
1448
+ }
1449
+ } catch (error) {
1450
+ console.warn("Optional Skill BM25 index unavailable; native Skill search remains active", error);
1451
+ }
1394
1452
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_short_id ON ai.skills(short_id)`.simple();
1395
1453
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_name ON ai.skills(name)`.simple();
1396
1454
  await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS managed_key TEXT`.simple();
@@ -1401,6 +1459,12 @@ export const migrateCloudAi = async (): Promise<void> => {
1401
1459
  seeded_at TIMESTAMPTZ NOT NULL DEFAULT now()
1402
1460
  )
1403
1461
  `.simple();
1462
+ // A seed's UUID deliberately has no FK: it is a durable deletion tombstone.
1463
+ await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS skill_id UUID`.simple();
1464
+ await sql`ALTER TABLE ai.skill_seeds ADD COLUMN IF NOT EXISTS catalog_version INTEGER NOT NULL DEFAULT 0`.simple();
1465
+ await sql`ALTER TABLE ai.skills ADD COLUMN IF NOT EXISTS template_id TEXT,
1466
+ ADD COLUMN IF NOT EXISTS template_version INTEGER, ADD COLUMN IF NOT EXISTS template_hash TEXT`.simple();
1467
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_skills_template_id ON ai.skills(template_id) WHERE template_id IS NOT NULL`.simple();
1404
1468
  await backfillAiShortIds(
1405
1469
  "idx_ai_skills_short_id",
1406
1470
  await sql<{ id: string }[]>`SELECT id FROM ai.skills WHERE short_id IS NULL`,
@@ -1558,6 +1622,15 @@ export const migrateCloudAi = async (): Promise<void> => {
1558
1622
  }
1559
1623
  await sql`ALTER TABLE ai.project_access ALTER COLUMN short_id SET NOT NULL`.simple();
1560
1624
 
1625
+ // Skills and Projects are shared only with authenticated identities.
1626
+ // Preserve each existing grant and permission level while closing anonymous access.
1627
+ await sql`
1628
+ UPDATE auth.access SET authenticated_only = true
1629
+ WHERE user_id IS NULL AND group_id IS NULL AND service_account_id IS NULL
1630
+ AND authenticated_only = false
1631
+ AND id IN (SELECT access_id FROM ai.skill_access UNION SELECT access_id FROM ai.project_access)
1632
+ `;
1633
+
1561
1634
  await sql`
1562
1635
  CREATE TABLE IF NOT EXISTS ai.project_knowledge (
1563
1636
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1624,6 +1697,15 @@ export const migrateCloudAi = async (): Promise<void> => {
1624
1697
  UNIQUE (project_id, resource_type, resource_id)
1625
1698
  )
1626
1699
  `.simple();
1700
+ await sql`
1701
+ CREATE TABLE IF NOT EXISTS ai.project_skills (
1702
+ project_id UUID NOT NULL REFERENCES ai.projects(id) ON DELETE CASCADE,
1703
+ skill_id UUID NOT NULL REFERENCES ai.skills(id) ON DELETE CASCADE,
1704
+ PRIMARY KEY (project_id, skill_id)
1705
+ )
1706
+ `.simple();
1707
+ await sql`CREATE INDEX IF NOT EXISTS idx_ai_project_skills_skill ON ai.project_skills(skill_id)`.simple();
1708
+
1627
1709
  await sql`ALTER TABLE ai.project_resource_refs ADD COLUMN IF NOT EXISTS short_id TEXT`.simple();
1628
1710
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_project_resource_refs_short_id ON ai.project_resource_refs(project_id, short_id)`.simple();
1629
1711
  await backfillAiShortIds(
@@ -1661,7 +1743,7 @@ export const migrateCloudAi = async (): Promise<void> => {
1661
1743
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
1662
1744
  CONSTRAINT ai_live_invalidation_domains_check CHECK (
1663
1745
  cardinality(domains) > 0 AND domains <@ ARRAY[
1664
- 'conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
1746
+ 'conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks', 'conversation-dictations',
1665
1747
  'project-list', 'project-detail', 'project-context'
1666
1748
  ]::text[]
1667
1749
  )
@@ -1918,6 +2000,11 @@ export const migrateCloudAi = async (): Promise<void> => {
1918
2000
  END
1919
2001
  $$ LANGUAGE plpgsql
1920
2002
  `.simple();
2003
+ await sql`DROP TRIGGER IF EXISTS ai_live_project_skills_changed ON ai.project_skills`.simple();
2004
+ await sql`
2005
+ CREATE TRIGGER ai_live_project_skills_changed AFTER INSERT OR DELETE ON ai.project_skills
2006
+ FOR EACH ROW EXECUTE FUNCTION ai.live_project_child_changed('project-detail,project-context')
2007
+ `.simple();
1921
2008
  await sql`DROP TRIGGER IF EXISTS ai_live_project_knowledge_changed ON ai.project_knowledge`.simple();
1922
2009
  await sql`
1923
2010
  CREATE TRIGGER ai_live_project_knowledge_changed AFTER INSERT OR UPDATE OR DELETE ON ai.project_knowledge
@@ -2075,8 +2162,99 @@ export const migrateCloudAi = async (): Promise<void> => {
2075
2162
  ADD COLUMN IF NOT EXISTS trace_id TEXT`.simple();
2076
2163
  await sql`CREATE INDEX IF NOT EXISTS idx_ai_structured_runs_user_created ON ai.structured_runs(user_id, created_at DESC)`.simple();
2077
2164
 
2078
- await migrateAiTurnUsage();
2079
- await migrateAiModelAccess();
2165
+ await sql`
2166
+ CREATE TABLE IF NOT EXISTS ai.dictations (
2167
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
2168
+ short_id TEXT NOT NULL CONSTRAINT ai_dictations_short_id_key UNIQUE,
2169
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
2170
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
2171
+ operation_id UUID NOT NULL,
2172
+ request_hash TEXT NOT NULL,
2173
+ source_path TEXT NOT NULL,
2174
+ source_bytes BYTEA,
2175
+ media_type TEXT NOT NULL,
2176
+ model_profile_id TEXT NOT NULL,
2177
+ language TEXT,
2178
+ status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'canceled')),
2179
+ disposition TEXT NOT NULL DEFAULT 'pending' CHECK (disposition IN ('pending', 'applied', 'discarded')),
2180
+ result TEXT,
2181
+ error_code TEXT,
2182
+ attempts INTEGER NOT NULL DEFAULT 0,
2183
+ lease_token UUID,
2184
+ lease_until TIMESTAMPTZ,
2185
+ next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2186
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2187
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
2188
+ UNIQUE (conversation_id, user_id, operation_id)
2189
+ )
2190
+ `.simple();
2191
+ // Provenance is separate from origin, which still controls file write rights.
2192
+ // Backfill only an unchanged file created in the dictation transaction.
2193
+ await sql`
2194
+ DO $$ BEGIN
2195
+ IF NOT EXISTS (SELECT 1 FROM information_schema.columns
2196
+ WHERE table_schema = 'ai' AND table_name = 'files' AND column_name = 'dictation_recorded_at') THEN
2197
+ ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS dictation_recorded_at TIMESTAMPTZ;
2198
+ UPDATE ai.files f SET dictation_recorded_at = d.created_at FROM ai.dictations d
2199
+ WHERE f.conversation_id = d.conversation_id AND f.path = d.source_path
2200
+ AND f.origin = 'user' AND f.version = 1 AND f.updated_at = d.created_at
2201
+ AND f.dictation_recorded_at IS NULL;
2202
+ END IF;
2203
+ END $$
2204
+ `.simple();
2205
+ await sql`ALTER TABLE ai.turn_files ADD COLUMN IF NOT EXISTS dictation_recorded_at TIMESTAMPTZ`.simple();
2206
+
2207
+ // Retained for the conversation lifetime so a delayed upload cannot undo a discard.
2208
+ await sql`CREATE TABLE IF NOT EXISTS ai.dictation_cancellations (
2209
+ conversation_id UUID NOT NULL REFERENCES ai.conversations(id) ON DELETE CASCADE,
2210
+ user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
2211
+ operation_id UUID NOT NULL,
2212
+ PRIMARY KEY (conversation_id, user_id, operation_id)
2213
+ )`.simple();
2214
+ await sql`CREATE INDEX IF NOT EXISTS ai_dictations_pending ON ai.dictations(conversation_id, created_at, id) WHERE disposition = 'pending'`.simple();
2215
+ await sql`CREATE INDEX IF NOT EXISTS ai_dictations_recovery ON ai.dictations(status, next_attempt_at, lease_until) WHERE status IN ('queued', 'running')`.simple();
2216
+ await sql.begin(async (tx) => {
2217
+ await tx`ALTER TABLE ai.live_invalidation_outbox DROP CONSTRAINT IF EXISTS ai_live_invalidation_domains_check`;
2218
+ await tx`ALTER TABLE ai.live_invalidation_outbox ADD CONSTRAINT ai_live_invalidation_domains_check CHECK (
2219
+ domains <@ ARRAY['conversation-list', 'conversation-detail', 'conversation-sources', 'conversation-files', 'conversation-tasks',
2220
+ 'conversation-dictations', 'project-list', 'project-detail', 'project-context']::text[] AND cardinality(domains) > 0
2221
+ )`;
2222
+ });
2223
+ await sql`DROP TRIGGER IF EXISTS ai_live_dictations_changed ON ai.dictations`.simple();
2224
+ await sql`DROP TRIGGER IF EXISTS ai_live_dictations_state_changed ON ai.dictations`.simple();
2225
+ await sql`CREATE TRIGGER ai_live_dictations_changed AFTER INSERT OR DELETE ON ai.dictations
2226
+ FOR EACH ROW EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-dictations')`.simple();
2227
+ await sql`CREATE TRIGGER ai_live_dictations_state_changed AFTER UPDATE ON ai.dictations
2228
+ FOR EACH ROW WHEN (OLD.status IS DISTINCT FROM NEW.status OR OLD.disposition IS DISTINCT FROM NEW.disposition)
2229
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-dictations')`.simple();
2230
+
2231
+ // A tool artifact may be reused only by its producing call and while unedited.
2232
+ await sql`ALTER TABLE ai.files ADD COLUMN IF NOT EXISTS producer_call_key TEXT`.simple();
2080
2233
 
2234
+ await migrateAiModelAccess();
2235
+ await migrateAiQuotas();
2236
+
2237
+ await sql`CREATE OR REPLACE FUNCTION ai.sidebar_tool_label(blocks jsonb) RETURNS jsonb AS $$
2238
+ SELECT jsonb_build_object('label', left(COALESCE(block->'presentation'->>'title', block->>'name'),200), 'status', block->>'status')
2239
+ FROM jsonb_array_elements(CASE WHEN jsonb_typeof(blocks) = 'array' THEN blocks ELSE '[]'::jsonb END) WITH ORDINALITY AS entries(block, n)
2240
+ WHERE block->>'kind' = 'tool' ORDER BY n DESC LIMIT 1
2241
+ $$ LANGUAGE sql IMMUTABLE`.simple();
2242
+ await sql`DROP TRIGGER IF EXISTS ai_live_sidebar_tool_changed ON ai.turns`.simple();
2243
+ await sql`CREATE TRIGGER ai_live_sidebar_tool_changed AFTER UPDATE OF live_blocks ON ai.turns
2244
+ FOR EACH ROW WHEN (ai.sidebar_tool_label(OLD.live_blocks) IS DISTINCT FROM ai.sidebar_tool_label(NEW.live_blocks))
2245
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')`.simple();
2246
+ await sql`DROP TRIGGER IF EXISTS ai_live_sidebar_plan_changed ON ai.messages`.simple();
2247
+ await sql`CREATE TRIGGER ai_live_sidebar_plan_changed AFTER INSERT ON ai.messages
2248
+ FOR EACH ROW WHEN (NEW.role = 'tool_result' OR NEW.meta IS NOT NULL)
2249
+ EXECUTE FUNCTION ai.live_conversation_child_changed('conversation-list')`.simple();
2250
+ // Separate run history keeps background tool loops out of the interactive chat.
2251
+ await sql`CREATE TABLE IF NOT EXISTS ai.task_messages (LIKE ai.messages INCLUDING DEFAULTS INCLUDING CONSTRAINTS, FOREIGN KEY (conversation_id) REFERENCES ai.conversations(id) ON DELETE CASCADE)`.simple();
2252
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS ai_task_messages_id ON ai.task_messages(id)`.simple();
2253
+ await sql`CREATE UNIQUE INDEX IF NOT EXISTS ai_task_messages_seq ON ai.task_messages(loop_id, seq)`.simple();
2254
+
2255
+ await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS result_text TEXT`.simple();
2256
+ await sql`ALTER TABLE ai.chat_task_occurrences ADD COLUMN IF NOT EXISTS delivered_at TIMESTAMPTZ`.simple();
2257
+ await sql`ALTER TABLE ai.conversations ADD COLUMN IF NOT EXISTS background_received_at TIMESTAMPTZ`.simple();
2258
+ await migrateAiMessageQueue();
2081
2259
  console.log(" ✓ ai conversation tables");
2082
2260
  };
@@ -0,0 +1,43 @@
1
+ import { sql } from "bun";
2
+ import { coreSettings } from "../services";
3
+ import { audit } from "../services/audit";
4
+ import { invalidateSettingsCache, set as setSetting } from "../services/settings";
5
+ import { decryptValue } from "../services/settings/crypto";
6
+ import { type AiModelPricing, AiModelPricingSchema } from "../shared/ai-costs";
7
+ import { AiQuotaError } from "./quotas";
8
+ import { parseAiModelProfiles } from "./settings";
9
+
10
+ /** Change prices only, preserving credentials and model access in their own stores. */
11
+ export async function setAiModelPricing(id: string, pricing: AiModelPricing | null, expected: AiModelPricing | null, actorId: string) {
12
+ if (pricing) AiModelPricingSchema.parse(pricing);
13
+ const key = "ai.model_profiles_json";
14
+ const fallback = await coreSettings.get<string>(key);
15
+ const result = await sql.begin(async (db) => {
16
+ const [row] = await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key} FOR UPDATE`;
17
+ const raw = row ? await decryptValue(row.value) : fallback;
18
+ if (typeof raw !== "string") throw new Error("Invalid model configuration.");
19
+ const parsed = parseAiModelProfiles(raw);
20
+ if (parsed.error) throw new Error(parsed.error.message);
21
+ const profile = parsed.profiles.find((profile) => profile.id === id);
22
+ if (!profile) throw new AiQuotaError("quota_conflict", "Model no longer exists.");
23
+ if (profile.capabilities.includes("transcription")) throw new AiQuotaError("quota_conflict", "Audio pricing is not supported.");
24
+ if (JSON.stringify(profile.pricing ?? null) !== JSON.stringify(expected))
25
+ throw new AiQuotaError("quota_conflict", "Model prices changed. Read them again before updating.");
26
+ if (pricing) profile.pricing = pricing;
27
+ else delete profile.pricing;
28
+ await setSetting(key, JSON.stringify(parsed.profiles), db);
29
+ await audit.record(
30
+ {
31
+ action: "ai.model.pricing",
32
+ outcome: "allowed",
33
+ actor: { userId: actorId },
34
+ target: { type: "ai-model", id },
35
+ metadata: { pricing },
36
+ },
37
+ db,
38
+ );
39
+ return { id, pricing: profile.pricing ?? null };
40
+ });
41
+ await invalidateSettingsCache([key]);
42
+ return result;
43
+ }
@@ -0,0 +1,75 @@
1
+ // Standalone subprocess. No server imports or application credentials.
2
+
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import { createCanvas } from "@napi-rs/canvas";
6
+ import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
7
+ import { z } from "zod";
8
+
9
+ const MAX_BYTES = 10 * 1024 * 1024;
10
+ const inputSchema = z.object({
11
+ pdf: z.string().max(Math.ceil((MAX_BYTES * 4) / 3)),
12
+ pages: z.array(z.number().int().positive()).min(1).max(3),
13
+ });
14
+ const require = createRequire(import.meta.url);
15
+ const root = dirname(require.resolve("pdfjs-dist/package.json"));
16
+ try {
17
+ const input = inputSchema.parse(JSON.parse(await Bun.stdin.text()));
18
+ const data = Buffer.from(input.pdf, "base64");
19
+ if (data.byteLength > MAX_BYTES) throw new Error("PDF exceeds the 10 MiB limit.");
20
+ const task = getDocument({
21
+ data: new Uint8Array(data),
22
+ verbosity: 0,
23
+ cMapUrl: join(root, "cmaps/"),
24
+ cMapPacked: true,
25
+ standardFontDataUrl: join(root, "standard_fonts/"),
26
+ wasmUrl: join(root, "wasm/"),
27
+ useSystemFonts: false,
28
+ stopAtErrors: true,
29
+ // Bound decoded source-image allocations as well as the output canvas.
30
+ maxImageSize: 16 * 1024 * 1024,
31
+ });
32
+ try {
33
+ const document = await task.promise;
34
+ if (input.pages.some((page) => page > document.numPages))
35
+ throw new Error(`PDF has ${document.numPages} pages. Choose pages within that range.`);
36
+ const pages: { page: number; png: string }[] = [];
37
+ let outputBytes = 0;
38
+ for (const number of input.pages) {
39
+ const page = await document.getPage(number);
40
+ const original = page.getViewport({ scale: 1 });
41
+ if (!(original.width > 0 && original.height > 0 && Number.isFinite(original.width + original.height)))
42
+ throw new Error("PDF page dimensions are invalid.");
43
+ const viewport = page.getViewport({ scale: Math.min(2, 2000 / Math.max(original.width, original.height)) });
44
+ const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
45
+ try {
46
+ // PDF.js uses the Canvas API implemented by the native canvas; its types
47
+ // describe browser HTMLCanvasElement rather than the supported Node canvas.
48
+ await page.render({ canvas: canvas as unknown as HTMLCanvasElement, viewport }).promise;
49
+ const png: Uint8Array = canvas.toBuffer("image/png");
50
+ outputBytes += png.byteLength;
51
+ if (outputBytes > MAX_BYTES) throw new Error("Rendered PDF exceeds the 10 MiB image budget.");
52
+ pages.push({ page: number, png: Buffer.from(png).toString("base64") });
53
+ } finally {
54
+ canvas.width = 0;
55
+ canvas.height = 0;
56
+ page.cleanup();
57
+ }
58
+ }
59
+ process.stdout.write(JSON.stringify({ totalPages: document.numPages, pages }));
60
+ } finally {
61
+ await task.destroy();
62
+ }
63
+ } catch (error) {
64
+ const name = error instanceof Error ? error.name : "";
65
+ const message =
66
+ name === "PasswordException"
67
+ ? "Password-protected PDFs are not supported. Provide an unlocked copy."
68
+ : name === "InvalidPDFException"
69
+ ? "The PDF is invalid or damaged."
70
+ : error instanceof Error
71
+ ? error.message.slice(0, 500)
72
+ : "PDF rendering failed.";
73
+ process.stdout.write(JSON.stringify({ error: message }));
74
+ process.exitCode = 1;
75
+ }
@@ -0,0 +1,28 @@
1
+ /** Small, deterministic PDF with embedded vector text; no fixture download. */
2
+ export function visionPdfFixture(): Uint8Array {
3
+ const objects = [
4
+ "<< /Type /Catalog /Pages 2 0 R >>",
5
+ "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
6
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 6 0 R >>",
7
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 7 0 R >>",
8
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
9
+ ...["Invoice INV-7", "Total 42.00 EUR"].map((text) => {
10
+ const content = `BT /F1 20 Tf 20 120 Td (${text}) Tj ET`;
11
+ return `<< /Length ${content.length} >>\nstream\n${content}\nendstream`;
12
+ }),
13
+ ];
14
+ let pdf = "%PDF-1.4\n";
15
+ const offsets = [0];
16
+ for (const [index, object] of objects.entries()) {
17
+ offsets.push(pdf.length);
18
+ pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
19
+ }
20
+ const start = pdf.length;
21
+ pdf += `xref\n0 ${offsets.length}\n0000000000 65535 f \n`;
22
+ pdf += offsets
23
+ .slice(1)
24
+ .map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`)
25
+ .join("");
26
+ pdf += `trailer\n<< /Size ${offsets.length} /Root 1 0 R >>\nstartxref\n${start}\n%%EOF`;
27
+ return new TextEncoder().encode(pdf);
28
+ }
@@ -0,0 +1,77 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { z } from "zod";
3
+
4
+ declare const __CLOUD_PDF_RENDER_WORKER__: string | undefined;
5
+ let activeRenders = 0;
6
+ export const PDF_MAX_BYTES = 10 * 1024 * 1024;
7
+ export const PdfPages = z
8
+ .array(z.number().int().positive())
9
+ .min(1)
10
+ .max(3)
11
+ .refine((pages) => new Set(pages).size === pages.length, "Choose each page only once.");
12
+ const RenderResult = z.object({
13
+ totalPages: z.number().int().positive(),
14
+ pages: z
15
+ .array(z.object({ page: z.number().int().positive(), png: z.string() }))
16
+ .min(1)
17
+ .max(3),
18
+ });
19
+
20
+ /** Isolate native decoding from the server: abort/deadline kills the process. */
21
+ export async function renderPdfPages(bytes: Uint8Array, pages: number[] = [1], signal?: AbortSignal) {
22
+ PdfPages.parse(pages);
23
+ if (bytes.byteLength > PDF_MAX_BYTES) throw new Error("PDF exceeds the 10 MiB view_image limit.");
24
+ if (process.platform !== "linux") throw new Error("PDF page inspection requires the Linux Cloud runtime with memory isolation.");
25
+ const boundedSignal = AbortSignal.any([AbortSignal.timeout(30_000), ...(signal ? [signal] : [])]);
26
+ boundedSignal.throwIfAborted();
27
+ if (activeRenders >= 2) throw new Error("PDF rendering is busy. Retry after the current inspection finishes.");
28
+ activeRenders++;
29
+ try {
30
+ const worker =
31
+ typeof __CLOUD_PDF_RENDER_WORKER__ === "string"
32
+ ? new URL(__CLOUD_PDF_RENDER_WORKER__, import.meta.url)
33
+ : new URL("./pdf-render-worker.ts", import.meta.url);
34
+ // RLIMIT_DATA bounds the decoder heap/native writable mappings on Linux.
35
+ // Positional arguments keep paths out of shell code. No elevated privileges.
36
+ const child = Bun.spawn(
37
+ ["/bin/sh", "-c", 'ulimit -d 524288 || exit 70; exec "$@"', "pdf-decoder", process.execPath, "--no-env-file", fileURLToPath(worker)],
38
+ {
39
+ stdin: new Blob([JSON.stringify({ pdf: Buffer.from(bytes).toString("base64"), pages })]),
40
+ stdout: "pipe",
41
+ stderr: "ignore",
42
+ // The decoder needs no application credentials or runtime configuration.
43
+ env: { PATH: process.env.PATH, LANG: "C.UTF-8" },
44
+ },
45
+ );
46
+ const stop = () => child.kill("SIGKILL");
47
+ boundedSignal.addEventListener("abort", stop, { once: true });
48
+ if (boundedSignal.aborted) stop();
49
+ try {
50
+ let length = 0;
51
+ const stdout = child.stdout.pipeThrough(
52
+ new TransformStream<Uint8Array, Uint8Array>({
53
+ transform(chunk, controller) {
54
+ length += chunk.byteLength;
55
+ // Base64 overhead plus a small JSON envelope over the aggregate PNG budget.
56
+ if (length > Math.ceil((PDF_MAX_BYTES * 4) / 3) + 4096) throw new Error("Rendered PDF exceeds the 10 MiB image budget.");
57
+ controller.enqueue(chunk);
58
+ },
59
+ }),
60
+ );
61
+ const text = await new Response(stdout).text();
62
+ boundedSignal.throwIfAborted();
63
+ if (!text.trim()) throw new Error("PDF decoder failed or exceeded its memory budget.");
64
+ const output: unknown = JSON.parse(text);
65
+ const failure = z.object({ error: z.string().max(500) }).safeParse(output);
66
+ if (failure.success) throw new Error(failure.data.error);
67
+ if ((await child.exited) !== 0) throw new Error("PDF rendering failed.");
68
+ return RenderResult.parse(output);
69
+ } finally {
70
+ boundedSignal.removeEventListener("abort", stop);
71
+ stop();
72
+ await child.exited;
73
+ }
74
+ } finally {
75
+ activeRenders--;
76
+ }
77
+ }