@k2b/cloud 0.6.0-rc.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 (540) hide show
  1. package/README.md +98 -0
  2. package/package.json +128 -0
  3. package/public/logo.svg +1 -0
  4. package/scripts/app-favicon.test.ts +49 -0
  5. package/scripts/app-favicon.ts +51 -0
  6. package/scripts/build.ts +205 -0
  7. package/scripts/preload.ts +164 -0
  8. package/scripts/sync-recovery-smoke.ts +200 -0
  9. package/src/_internal/app-favicon.ts +2 -0
  10. package/src/_internal/app-presentation.ts +61 -0
  11. package/src/_internal/app-workspace-state.ts +24 -0
  12. package/src/_internal/bounded-json.ts +47 -0
  13. package/src/_internal/build-metadata.ts +9 -0
  14. package/src/_internal/capabilities.ts +1118 -0
  15. package/src/_internal/define-app.ts +801 -0
  16. package/src/_internal/heartbeat.ts +167 -0
  17. package/src/_internal/help-catalog.ts +241 -0
  18. package/src/_internal/help.ts +95 -0
  19. package/src/_internal/nats-connection.ts +34 -0
  20. package/src/_internal/page-responses.ts +40 -0
  21. package/src/_internal/process-sync.ts +117 -0
  22. package/src/_internal/readiness.ts +3 -0
  23. package/src/_internal/registry-validation.ts +136 -0
  24. package/src/_internal/registry-watch.ts +52 -0
  25. package/src/_internal/registry.ts +338 -0
  26. package/src/_internal/runtime-compatibility.ts +64 -0
  27. package/src/_internal/runtime-context.ts +84 -0
  28. package/src/_internal/runtime-watcher.ts +78 -0
  29. package/src/_internal/static-assets.ts +86 -0
  30. package/src/_internal/status-preserving-ssr.ts +49 -0
  31. package/src/access/PermissionEditor.tsx +486 -0
  32. package/src/access/ResourceApiKeys.tsx +271 -0
  33. package/src/access/messages.ts +121 -0
  34. package/src/access/ui.ts +16 -0
  35. package/src/account/Avatar.tsx +28 -0
  36. package/src/account/EntitySearch.tsx +363 -0
  37. package/src/account/avatar-upload-dialog.tsx +197 -0
  38. package/src/account/avatar-upload.ts +149 -0
  39. package/src/account/ui.ts +3 -0
  40. package/src/ai/admin.ts +47 -0
  41. package/src/ai/approval-routes.ts +130 -0
  42. package/src/ai/approvals.ts +139 -0
  43. package/src/ai/assistant-models.ts +61 -0
  44. package/src/ai/attachments.ts +40 -0
  45. package/src/ai/browser.ts +92 -0
  46. package/src/ai/capabilities.ts +714 -0
  47. package/src/ai/capability-execution.ts +151 -0
  48. package/src/ai/chat/blocks.render.test.tsx +1108 -0
  49. package/src/ai/chat/blocks.tsx +730 -0
  50. package/src/ai/chat/builtin-tools.tsx +428 -0
  51. package/src/ai/chat/capability-table.tsx +87 -0
  52. package/src/ai/chat/composer-adapter.ts +169 -0
  53. package/src/ai/chat/file-tools.tsx +81 -0
  54. package/src/ai/chat/message-actions.tsx +370 -0
  55. package/src/ai/chat/message-utils.ts +479 -0
  56. package/src/ai/chat/messages.ts +43 -0
  57. package/src/ai/chat/presentation.tsx +395 -0
  58. package/src/ai/chat/primitives.tsx +3 -0
  59. package/src/ai/chat/tool-disclosure.tsx +41 -0
  60. package/src/ai/chat/turn-navigator-utils.ts +28 -0
  61. package/src/ai/chat/turn-navigator.tsx +164 -0
  62. package/src/ai/chat/user-message.tsx +149 -0
  63. package/src/ai/chat/visual-tools.tsx +435 -0
  64. package/src/ai/chat/web-tools.tsx +202 -0
  65. package/src/ai/chat-task-contracts.ts +113 -0
  66. package/src/ai/chat-tasks.ts +952 -0
  67. package/src/ai/client/controller.ts +1211 -0
  68. package/src/ai/client/file-source.ts +95 -0
  69. package/src/ai/client/live-connection.ts +140 -0
  70. package/src/ai/client/projection.ts +178 -0
  71. package/src/ai/client/transport.ts +111 -0
  72. package/src/ai/compaction.ts +182 -0
  73. package/src/ai/credentials.ts +124 -0
  74. package/src/ai/default-tool-contracts.ts +108 -0
  75. package/src/ai/default-tools.ts +127 -0
  76. package/src/ai/enrich.ts +280 -0
  77. package/src/ai/executor.ts +1624 -0
  78. package/src/ai/fetch-file-tool.ts +313 -0
  79. package/src/ai/file-context.ts +92 -0
  80. package/src/ai/file-media-type.ts +28 -0
  81. package/src/ai/file-mount.ts +16 -0
  82. package/src/ai/file-tools.ts +566 -0
  83. package/src/ai/files-store.ts +390 -0
  84. package/src/ai/firecrawl-tools.ts +305 -0
  85. package/src/ai/grids-skill.ts +126 -0
  86. package/src/ai/http.ts +265 -0
  87. package/src/ai/index.ts +337 -0
  88. package/src/ai/kit-skill.ts +22 -0
  89. package/src/ai/limits.ts +5 -0
  90. package/src/ai/live-events.ts +289 -0
  91. package/src/ai/live-messages.ts +45 -0
  92. package/src/ai/live-outbox.ts +102 -0
  93. package/src/ai/live-routes.ts +412 -0
  94. package/src/ai/live.ts +2 -0
  95. package/src/ai/maintenance.ts +302 -0
  96. package/src/ai/markdown-pdf-tool.ts +95 -0
  97. package/src/ai/memories.ts +605 -0
  98. package/src/ai/memory-learning-runs.ts +222 -0
  99. package/src/ai/memory-learning.ts +619 -0
  100. package/src/ai/memory-tool.ts +120 -0
  101. package/src/ai/memory-workflow-evidence.ts +158 -0
  102. package/src/ai/migrate.ts +2082 -0
  103. package/src/ai/model-access-migrate.ts +30 -0
  104. package/src/ai/model-access.ts +191 -0
  105. package/src/ai/personal-agent.ts +6 -0
  106. package/src/ai/prefs.ts +72 -0
  107. package/src/ai/project-tool.ts +94 -0
  108. package/src/ai/projects-routes.ts +277 -0
  109. package/src/ai/projects.ts +902 -0
  110. package/src/ai/protocol.ts +279 -0
  111. package/src/ai/provider.ts +73 -0
  112. package/src/ai/public-projection.ts +25 -0
  113. package/src/ai/resource-markers.ts +31 -0
  114. package/src/ai/resource-refs.ts +70 -0
  115. package/src/ai/routes.ts +1131 -0
  116. package/src/ai/runtime-tools.ts +39 -0
  117. package/src/ai/runtime.ts +544 -0
  118. package/src/ai/settings.ts +529 -0
  119. package/src/ai/short-id.ts +45 -0
  120. package/src/ai/skill-catalog.ts +73 -0
  121. package/src/ai/skill-format.ts +145 -0
  122. package/src/ai/skill-seeds.ts +430 -0
  123. package/src/ai/skill-tool.ts +81 -0
  124. package/src/ai/skills-routes.ts +143 -0
  125. package/src/ai/skills.ts +934 -0
  126. package/src/ai/solid.ts +27 -0
  127. package/src/ai/store.ts +3113 -0
  128. package/src/ai/stream.ts +308 -0
  129. package/src/ai/structured-runs.ts +66 -0
  130. package/src/ai/structured.ts +186 -0
  131. package/src/ai/system-prompt.ts +170 -0
  132. package/src/ai/task-prompt.ts +22 -0
  133. package/src/ai/timeline.ts +148 -0
  134. package/src/ai/tool-audit.ts +177 -0
  135. package/src/ai/tool-result-budget.ts +16 -0
  136. package/src/ai/tools.ts +155 -0
  137. package/src/ai/types.ts +967 -0
  138. package/src/ai/ui.tsx +39 -0
  139. package/src/ai/usage-migrate.ts +81 -0
  140. package/src/ai/usage.ts +315 -0
  141. package/src/ai/validate.ts +54 -0
  142. package/src/ai/vision-tool.ts +79 -0
  143. package/src/api/accounts-entities.ts +174 -0
  144. package/src/api/admin-ai-projects.ts +74 -0
  145. package/src/api/admin-ai-skills.ts +76 -0
  146. package/src/api/admin-ai-usage.ts +42 -0
  147. package/src/api/admin-core-settings.ts +508 -0
  148. package/src/api/admin-identity.ts +148 -0
  149. package/src/api/admin-lifecycle.ts +206 -0
  150. package/src/api/admin-linux-identities.ts +66 -0
  151. package/src/api/announcements.ts +131 -0
  152. package/src/api/app-approval.ts +129 -0
  153. package/src/api/apps.ts +51 -0
  154. package/src/api/auth/schemas.ts +54 -0
  155. package/src/api/auth.ts +370 -0
  156. package/src/api/capabilities.ts +658 -0
  157. package/src/api/help-messages.ts +19 -0
  158. package/src/api/help.ts +78 -0
  159. package/src/api/index.ts +108 -0
  160. package/src/api/mcp.ts +744 -0
  161. package/src/api/me-rail.ts +45 -0
  162. package/src/api/me.ts +753 -0
  163. package/src/api/search/schemas.ts +79 -0
  164. package/src/api/search.bench.ts +185 -0
  165. package/src/api/search.ts +407 -0
  166. package/src/api/sync-ops.ts +79 -0
  167. package/src/api/widgets.ts +143 -0
  168. package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
  169. package/src/browser/CloudResourceSearch.tsx +791 -0
  170. package/src/browser/app-approval-vault.ts +305 -0
  171. package/src/browser/app-approval.ts +234 -0
  172. package/src/browser/live-websocket.ts +268 -0
  173. package/src/browser/locale-preference.ts +25 -0
  174. package/src/browser/notification-target.ts +27 -0
  175. package/src/browser/notification-timeout.ts +14 -0
  176. package/src/browser/notifications.ts +181 -0
  177. package/src/browser/resource-clipboard.ts +70 -0
  178. package/src/browser/resource-picker.tsx +39 -0
  179. package/src/browser/resource-search-messages.ts +55 -0
  180. package/src/browser/resource-search.ts +27 -0
  181. package/src/capabilities/client.ts +149 -0
  182. package/src/capabilities/response.ts +39 -0
  183. package/src/capabilities/server.ts +215 -0
  184. package/src/capabilities/signals.ts +4 -0
  185. package/src/capabilities/testing.ts +12 -0
  186. package/src/capabilities/types.ts +32 -0
  187. package/src/cli/access.ts +499 -0
  188. package/src/cli/account.ts +382 -0
  189. package/src/cli/admin/account-administration.ts +45 -0
  190. package/src/cli/admin/account-categories.ts +49 -0
  191. package/src/cli/admin/ai-usage.ts +118 -0
  192. package/src/cli/admin/app-sign-in.ts +49 -0
  193. package/src/cli/admin/data.ts +343 -0
  194. package/src/cli/admin/documentation.ts +34 -0
  195. package/src/cli/admin/gateway.ts +155 -0
  196. package/src/cli/admin/index.ts +61 -0
  197. package/src/cli/admin/instance.ts +250 -0
  198. package/src/cli/admin/jobs.ts +223 -0
  199. package/src/cli/admin/legal.ts +122 -0
  200. package/src/cli/admin/linux.ts +53 -0
  201. package/src/cli/admin/logs.ts +324 -0
  202. package/src/cli/admin/metrics.ts +142 -0
  203. package/src/cli/admin/nats.ts +167 -0
  204. package/src/cli/admin/notifications.ts +516 -0
  205. package/src/cli/admin/shared.ts +113 -0
  206. package/src/cli/admin/sync.ts +188 -0
  207. package/src/cli/admin/telemetry.ts +314 -0
  208. package/src/cli/admin/webhooks.ts +222 -0
  209. package/src/cli/admin/workflows.ts +367 -0
  210. package/src/cli/apps.ts +40 -0
  211. package/src/cli/capabilities.ts +239 -0
  212. package/src/cli/commands.ts +604 -0
  213. package/src/cli/index.ts +61 -0
  214. package/src/cli/locale.ts +31 -0
  215. package/src/cli/output.ts +58 -0
  216. package/src/clients/core.ts +20 -0
  217. package/src/config/env.ts +54 -0
  218. package/src/config/index.ts +6 -0
  219. package/src/contracts/account-categories.ts +62 -0
  220. package/src/contracts/announcements.ts +121 -0
  221. package/src/contracts/app-approval.ts +197 -0
  222. package/src/contracts/app.ts +162 -0
  223. package/src/contracts/capabilities.ts +484 -0
  224. package/src/contracts/index.ts +14 -0
  225. package/src/contracts/notification-live.ts +66 -0
  226. package/src/contracts/notification-types.ts +235 -0
  227. package/src/contracts/posix.ts +69 -0
  228. package/src/contracts/profile.ts +85 -0
  229. package/src/contracts/rail-preferences.ts +53 -0
  230. package/src/contracts/registry.ts +110 -0
  231. package/src/contracts/resource-clipboard.ts +33 -0
  232. package/src/contracts/settings-types.ts +98 -0
  233. package/src/contracts/shared.ts +408 -0
  234. package/src/contracts/user-notifications.ts +101 -0
  235. package/src/contracts/widgets.ts +183 -0
  236. package/src/desktop/index.ts +704 -0
  237. package/src/desktop/solid.tsx +938 -0
  238. package/src/index.ts +58 -0
  239. package/src/server/actor.ts +41 -0
  240. package/src/server/api/index.ts +2 -0
  241. package/src/server/api/respond.ts +96 -0
  242. package/src/server/api-client.ts +54 -0
  243. package/src/server/app-context.ts +39 -0
  244. package/src/server/help.ts +187 -0
  245. package/src/server/index.ts +89 -0
  246. package/src/server/locale.ts +55 -0
  247. package/src/server/middleware/auth.ts +386 -0
  248. package/src/server/middleware/index.ts +28 -0
  249. package/src/server/middleware/invocation.ts +55 -0
  250. package/src/server/middleware/middleware.ts +65 -0
  251. package/src/server/middleware/openapi.ts +126 -0
  252. package/src/server/middleware/rate-limit.ts +125 -0
  253. package/src/server/middleware/request-logger.ts +41 -0
  254. package/src/server/middleware/route-template.ts +46 -0
  255. package/src/server/middleware/runtime.ts +22 -0
  256. package/src/server/middleware/settings.ts +55 -0
  257. package/src/server/middleware/validator.ts +49 -0
  258. package/src/server/middleware/workload.ts +22 -0
  259. package/src/server/ratelimit.ts +151 -0
  260. package/src/server/request-cookies.ts +18 -0
  261. package/src/server/services/access.ts +644 -0
  262. package/src/server/services/freeipa/client.ts +137 -0
  263. package/src/server/services/freeipa/index.ts +9 -0
  264. package/src/server/services/freeipa/session.ts +123 -0
  265. package/src/server/services/freeipa/test-certificates.ts +44 -0
  266. package/src/server/services/freeipa/tls.ts +44 -0
  267. package/src/server/services/freeipa/transport.ts +116 -0
  268. package/src/server/services/freeipa/util.ts +59 -0
  269. package/src/server/services/geo.ts +198 -0
  270. package/src/server/services/index.ts +48 -0
  271. package/src/server/services/pagination.ts +22 -0
  272. package/src/server/services/services.ts +13 -0
  273. package/src/server/time.ts +33 -0
  274. package/src/services/account-category-policy.ts +51 -0
  275. package/src/services/account-lifecycle/audit.ts +41 -0
  276. package/src/services/account-lifecycle/index.ts +940 -0
  277. package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
  278. package/src/services/account-lifecycle/notification-sender.ts +13 -0
  279. package/src/services/account-lifecycle/scheduler.ts +506 -0
  280. package/src/services/account-model.ts +22 -0
  281. package/src/services/accounts/app.ts +1671 -0
  282. package/src/services/accounts/authz.ts +44 -0
  283. package/src/services/accounts/avatar.ts +120 -0
  284. package/src/services/accounts/base-group.ts +11 -0
  285. package/src/services/accounts/base-user.ts +46 -0
  286. package/src/services/accounts/duplicate-emails.ts +82 -0
  287. package/src/services/accounts/entities.ts +622 -0
  288. package/src/services/accounts/group-sql.ts +106 -0
  289. package/src/services/accounts/groups.ts +272 -0
  290. package/src/services/accounts/index.ts +14 -0
  291. package/src/services/accounts/ipa-data.ts +66 -0
  292. package/src/services/accounts/lifecycle.ts +2 -0
  293. package/src/services/accounts/local-groups.ts +499 -0
  294. package/src/services/accounts/model.ts +129 -0
  295. package/src/services/accounts/notification-sender.ts +31 -0
  296. package/src/services/accounts/posix.ts +386 -0
  297. package/src/services/accounts/request-policy.ts +11 -0
  298. package/src/services/accounts/switching.ts +126 -0
  299. package/src/services/accounts/users.ts +745 -0
  300. package/src/services/announcements/index.ts +224 -0
  301. package/src/services/app-approval.ts +498 -0
  302. package/src/services/audit/index.ts +438 -0
  303. package/src/services/auth-flows/index.ts +15 -0
  304. package/src/services/auth-flows/ipa.ts +197 -0
  305. package/src/services/auth-flows/magic-link.ts +185 -0
  306. package/src/services/auth-flows/notification-sender.ts +10 -0
  307. package/src/services/auth-flows/password-reset.ts +268 -0
  308. package/src/services/auth-flows/proxy-return.ts +49 -0
  309. package/src/services/document-extraction.ts +148 -0
  310. package/src/services/freeipa-config.ts +179 -0
  311. package/src/services/gateway.ts +186 -0
  312. package/src/services/identity/constants.ts +18 -0
  313. package/src/services/identity/index.ts +52 -0
  314. package/src/services/identity/invocation-actor.ts +120 -0
  315. package/src/services/identity/invocation-authority.ts +53 -0
  316. package/src/services/identity/invocation-operations.ts +15 -0
  317. package/src/services/identity/invocation-token.ts +276 -0
  318. package/src/services/identity/key-config.ts +41 -0
  319. package/src/services/identity/key-ring.ts +530 -0
  320. package/src/services/identity/metrics.ts +29 -0
  321. package/src/services/identity/routes.ts +18 -0
  322. package/src/services/identity/runtime-config.ts +88 -0
  323. package/src/services/identity/session-token.ts +152 -0
  324. package/src/services/identity/signer-refresh.ts +44 -0
  325. package/src/services/identity/workload-auth.ts +64 -0
  326. package/src/services/index.ts +187 -0
  327. package/src/services/ipa/auth.ts +216 -0
  328. package/src/services/ipa/connection.ts +57 -0
  329. package/src/services/ipa/effective-groups.ts +70 -0
  330. package/src/services/ipa/groups.ts +722 -0
  331. package/src/services/ipa/guard.ts +17 -0
  332. package/src/services/ipa/index.ts +17 -0
  333. package/src/services/ipa/posix.ts +19 -0
  334. package/src/services/ipa/profile.ts +132 -0
  335. package/src/services/ipa/runtime.ts +28 -0
  336. package/src/services/ipa/search.ts +154 -0
  337. package/src/services/ipa/service-account.ts +15 -0
  338. package/src/services/ipa/sync-planning.ts +100 -0
  339. package/src/services/ipa/sync.ts +862 -0
  340. package/src/services/ipa/users.ts +806 -0
  341. package/src/services/legal-consent.ts +76 -0
  342. package/src/services/logging/index.ts +477 -0
  343. package/src/services/logging/redaction.ts +20 -0
  344. package/src/services/logging/trace.ts +1231 -0
  345. package/src/services/mandates/index.ts +855 -0
  346. package/src/services/mandates/policy.ts +85 -0
  347. package/src/services/network-security.ts +31 -0
  348. package/src/services/notifications/batches.ts +722 -0
  349. package/src/services/notifications/browser.ts +252 -0
  350. package/src/services/notifications/catalog.ts +113 -0
  351. package/src/services/notifications/channels.ts +104 -0
  352. package/src/services/notifications/dispatcher.ts +139 -0
  353. package/src/services/notifications/email.ts +118 -0
  354. package/src/services/notifications/index.ts +633 -0
  355. package/src/services/notifications/live.ts +50 -0
  356. package/src/services/notifications/observability.ts +407 -0
  357. package/src/services/notifications/platform.ts +421 -0
  358. package/src/services/notifications/runtime.ts +120 -0
  359. package/src/services/notifications/user.ts +248 -0
  360. package/src/services/notifications/web-push-transport.ts +135 -0
  361. package/src/services/oauth-tokens.ts +209 -0
  362. package/src/services/outbox.ts +187 -0
  363. package/src/services/pdf/gotenberg.ts +290 -0
  364. package/src/services/pdf/index.ts +43 -0
  365. package/src/services/pdf/markdown.ts +209 -0
  366. package/src/services/pdf/template-preview.ts +122 -0
  367. package/src/services/postgres.ts +76 -0
  368. package/src/services/providers/index.ts +27 -0
  369. package/src/services/providers/local/auth.ts +51 -0
  370. package/src/services/providers/local/index.ts +4 -0
  371. package/src/services/providers/local/users.ts +263 -0
  372. package/src/services/rail-preferences.ts +27 -0
  373. package/src/services/runtime-lifecycle.ts +191 -0
  374. package/src/services/secrets.ts +10 -0
  375. package/src/services/service-account-credentials.ts +891 -0
  376. package/src/services/service-accounts.ts +184 -0
  377. package/src/services/session/index.ts +236 -0
  378. package/src/services/session/test-fixture.ts +18 -0
  379. package/src/services/session/user.ts +144 -0
  380. package/src/services/settings/api.ts +61 -0
  381. package/src/services/settings/app.ts +86 -0
  382. package/src/services/settings/core-settings.ts +796 -0
  383. package/src/services/settings/crypto.ts +69 -0
  384. package/src/services/settings/defaults.ts +374 -0
  385. package/src/services/settings/index.ts +215 -0
  386. package/src/services/settings/namespace.ts +9 -0
  387. package/src/services/settings/snapshot.ts +49 -0
  388. package/src/services/settings/store.ts +241 -0
  389. package/src/services/settings/templates.ts +4 -0
  390. package/src/services/sync-ops.ts +403 -0
  391. package/src/services/topic-cursor.ts +22 -0
  392. package/src/services/weather/forecast.ts +323 -0
  393. package/src/services/weather/geo.ts +119 -0
  394. package/src/services/weather/index.ts +67 -0
  395. package/src/services/weather/location.ts +24 -0
  396. package/src/services/weather/locations.ts +133 -0
  397. package/src/services/weather/migrate.ts +45 -0
  398. package/src/services/weather/settings.ts +66 -0
  399. package/src/services/weather/short-id.ts +57 -0
  400. package/src/services/weather/types.ts +61 -0
  401. package/src/services/weather/ui.ts +50 -0
  402. package/src/services/webauthn.ts +385 -0
  403. package/src/shared/account-action-notice.ts +70 -0
  404. package/src/shared/account-display.ts +15 -0
  405. package/src/shared/account-session.ts +15 -0
  406. package/src/shared/ai-platform-prompt.ts +134 -0
  407. package/src/shared/ai-usage.ts +49 -0
  408. package/src/shared/app-presentation.ts +48 -0
  409. package/src/shared/app-url.ts +12 -0
  410. package/src/shared/branding.ts +14 -0
  411. package/src/shared/capability-messages.ts +70 -0
  412. package/src/shared/email-html.ts +54 -0
  413. package/src/shared/format.ts +91 -0
  414. package/src/shared/help.ts +68 -0
  415. package/src/shared/icons.ts +400 -0
  416. package/src/shared/index.ts +54 -0
  417. package/src/shared/locale.ts +48 -0
  418. package/src/shared/login-method.ts +44 -0
  419. package/src/shared/markdown/client.ts +130 -0
  420. package/src/shared/markdown/extensions/code.ts +105 -0
  421. package/src/shared/markdown/extensions/guided-help.ts +100 -0
  422. package/src/shared/markdown/extensions/images.ts +79 -0
  423. package/src/shared/markdown/extensions/info-blocks.ts +97 -0
  424. package/src/shared/markdown/extensions/katex.ts +120 -0
  425. package/src/shared/markdown/extensions/links.ts +43 -0
  426. package/src/shared/markdown/extensions/mark.ts +48 -0
  427. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  428. package/src/shared/markdown/extensions/tables.ts +109 -0
  429. package/src/shared/markdown/extensions/task-list.ts +53 -0
  430. package/src/shared/markdown/formula.ts +1187 -0
  431. package/src/shared/markdown/index.ts +193 -0
  432. package/src/shared/markdown/shared.ts +36 -0
  433. package/src/shared/mock-cover.ts +130 -0
  434. package/src/shared/network-address.ts +106 -0
  435. package/src/shared/redirect.ts +56 -0
  436. package/src/shared/template-rendering.ts +140 -0
  437. package/src/shared/theme.ts +68 -0
  438. package/src/shared/time.ts +13 -0
  439. package/src/ssr/AdminLayout.tsx +53 -0
  440. package/src/ssr/AdminSidebar.tsx +50 -0
  441. package/src/ssr/AppLaunchpad.island.tsx +236 -0
  442. package/src/ssr/AppWorkspaceController.island.tsx +21 -0
  443. package/src/ssr/BrowserNotifications.island.tsx +135 -0
  444. package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
  445. package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
  446. package/src/ssr/GlobalSearchDialog.tsx +35 -0
  447. package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
  448. package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
  449. package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
  450. package/src/ssr/Layout.render.test.tsx +148 -0
  451. package/src/ssr/Layout.tsx +292 -0
  452. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  453. package/src/ssr/LayoutFooter.tsx +22 -0
  454. package/src/ssr/LayoutHeader.tsx +81 -0
  455. package/src/ssr/LayoutHelp.tsx +869 -0
  456. package/src/ssr/LayoutPreferences.island.tsx +36 -0
  457. package/src/ssr/LayoutRail.tsx +58 -0
  458. package/src/ssr/MinimalLayout.tsx +46 -0
  459. package/src/ssr/PageError.tsx +29 -0
  460. package/src/ssr/ProfilePreferences.island.tsx +92 -0
  461. package/src/ssr/RailApps.island.tsx +61 -0
  462. package/src/ssr/RailEditor.tsx +293 -0
  463. package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
  464. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  465. package/src/ssr/admin-active-link.ts +47 -0
  466. package/src/ssr/admin-navigation.ts +78 -0
  467. package/src/ssr/app-appearance.ts +23 -0
  468. package/src/ssr/app-navigation.ts +41 -0
  469. package/src/ssr/help-messages.ts +104 -0
  470. package/src/ssr/index.ts +14 -0
  471. package/src/ssr/islands/SearchBar.island.tsx +80 -0
  472. package/src/ssr/islands/index.ts +16 -0
  473. package/src/ssr/layout-context.ts +17 -0
  474. package/src/ssr/layout-help-markdown.ts +9 -0
  475. package/src/ssr/layout-help-navigation.ts +24 -0
  476. package/src/ssr/layout-help-url.ts +5 -0
  477. package/src/ssr/layout-runtime.ts +22 -0
  478. package/src/ssr/page-error-messages.ts +25 -0
  479. package/src/ssr/platform-messages.ts +135 -0
  480. package/src/ssr/preference-controller.ts +44 -0
  481. package/src/ssr/profile-preferences-messages.ts +29 -0
  482. package/src/ssr/rail-context.ts +21 -0
  483. package/src/ssr/rail-messages.ts +66 -0
  484. package/src/ssr/rail-navigation.ts +60 -0
  485. package/src/ssr/runtime.ts +31 -0
  486. package/src/ssr/url-filter.ts +127 -0
  487. package/src/styles/base-popover.css +36 -0
  488. package/src/styles/effects.css +851 -0
  489. package/src/styles/global.css +268 -0
  490. package/src/styles/input.css +58 -0
  491. package/src/styles/tokens.css +230 -0
  492. package/src/styles/utilities-buttons.css +405 -0
  493. package/src/styles/utilities-data.css +33 -0
  494. package/src/styles/utilities-feedback.css +232 -0
  495. package/src/styles/utilities-layout.css +603 -0
  496. package/src/styles/utilities-markdown-editor.css +525 -0
  497. package/src/styles/utilities-markdown-table.css +234 -0
  498. package/src/styles/utilities-navigation.css +1054 -0
  499. package/src/types/ambient.d.ts +24 -0
  500. package/src/workflows/ai/index.ts +17 -0
  501. package/src/workflows/ai/runtime.ts +286 -0
  502. package/src/workflows/ai/store.ts +314 -0
  503. package/src/workflows/ai/types.ts +111 -0
  504. package/src/workflows/ai-actions.ts +269 -0
  505. package/src/workflows/authoring.ts +88 -0
  506. package/src/workflows/builtins.ts +193 -0
  507. package/src/workflows/contracts.ts +280 -0
  508. package/src/workflows/definition.ts +334 -0
  509. package/src/workflows/editor.ts +113 -0
  510. package/src/workflows/index.ts +71 -0
  511. package/src/workflows/language/binder.ts +45 -0
  512. package/src/workflows/language/canonical.ts +29 -0
  513. package/src/workflows/language/compiler.ts +396 -0
  514. package/src/workflows/language/expressions.ts +34 -0
  515. package/src/workflows/language/index.ts +20 -0
  516. package/src/workflows/language/references.ts +55 -0
  517. package/src/workflows/language/schema.ts +134 -0
  518. package/src/workflows/language/strict-yaml.ts +131 -0
  519. package/src/workflows/manifest.ts +24 -0
  520. package/src/workflows/module.ts +12 -0
  521. package/src/workflows/runtime/coordinator.ts +115 -0
  522. package/src/workflows/runtime/executor.ts +1003 -0
  523. package/src/workflows/runtime/index.ts +60 -0
  524. package/src/workflows/runtime/instant.ts +8 -0
  525. package/src/workflows/runtime/ports.ts +213 -0
  526. package/src/workflows/runtime/schedule.ts +203 -0
  527. package/src/workflows/runtime/trigger.ts +37 -0
  528. package/src/workflows/runtime/wake.ts +13 -0
  529. package/src/workflows/store/actions.ts +438 -0
  530. package/src/workflows/store/budget.ts +145 -0
  531. package/src/workflows/store/definitions.ts +345 -0
  532. package/src/workflows/store/events.ts +383 -0
  533. package/src/workflows/store/index.ts +107 -0
  534. package/src/workflows/store/observability.ts +594 -0
  535. package/src/workflows/store/runs.ts +975 -0
  536. package/src/workflows/store/transaction.ts +23 -0
  537. package/src/workflows/store/worker.ts +439 -0
  538. package/src/workflows/testing/conformance.ts +72 -0
  539. package/src/workflows/testing/index.ts +11 -0
  540. package/src/workflows/testing/process-fixtures.ts +275 -0
@@ -0,0 +1,1231 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { SyncEvent } from "@k2b/sync";
3
+ import { sql } from "bun";
4
+ import type { PaginationParams } from "../../contracts/shared";
5
+ import { escapeLikePattern, parsePgJsonRecord, toPgTextArray } from "../postgres";
6
+ import { isSensitiveMetadataKey, REDACTED, redactMetadata } from "./redaction";
7
+
8
+ export type TraceAttributeValue = string | number | boolean | null | undefined;
9
+ export type TraceAttributes = Record<string, TraceAttributeValue>;
10
+ export type TraceCategory = "job" | "schedule" | "backfill" | "ai" | "http" | "notification" | "sync" | "custom";
11
+ export type TraceSeverity = "debug" | "info" | "warn" | "error";
12
+ export type TraceSpanKind = "internal" | "server" | "client" | "producer" | "consumer";
13
+ export type TraceStatus = "unset" | "ok" | "error";
14
+
15
+ export type TraceContext = {
16
+ traceId: string;
17
+ spanId: string;
18
+ traceparent: string;
19
+ };
20
+
21
+ export type TraceSpan = TraceContext & {
22
+ spanKey: string | null;
23
+ parentSpanId: string | null;
24
+ name: string;
25
+ source: string;
26
+ appId: string | null;
27
+ category: TraceCategory;
28
+ kind: TraceSpanKind;
29
+ status: TraceStatus;
30
+ statusMessage: string | null;
31
+ attributes: Record<string, unknown> | null;
32
+ summary: Record<string, unknown> | null;
33
+ eventCount: number;
34
+ startedAt: string;
35
+ endedAt: string | null;
36
+ durationMs: number | null;
37
+ updatedAt: string;
38
+ };
39
+
40
+ export type TraceEvent = {
41
+ id: string;
42
+ traceId: string;
43
+ spanId: string;
44
+ name: string;
45
+ severity: TraceSeverity;
46
+ attributes: Record<string, unknown> | null;
47
+ body: string | null;
48
+ occurredAt: string;
49
+ };
50
+
51
+ export type TraceSummary = {
52
+ total: number;
53
+ totalWindow: number;
54
+ running: number;
55
+ succeededWindow: number;
56
+ failedWindow: number;
57
+ sources: number;
58
+ avgDurationMs: number | null;
59
+ p95DurationMs: number | null;
60
+ };
61
+
62
+ export type TraceWindow = "10m" | "1h" | "12h" | "24h" | "7d" | "30d";
63
+
64
+ /**
65
+ * A span open longer than this is treated as abandoned rather than running,
66
+ * and a span that *closed* after longer than this is treated as anomalous
67
+ * rather than slow.
68
+ *
69
+ * Both cases are real: a process that dies mid-run leaves an open span
70
+ * forever, and a sweep that closes such spans later produces "runs" of
71
+ * several days. Counting either as normal makes "running" report activity
72
+ * that is not happening and drags duration percentiles far off the real
73
+ * distribution, so both are separated out instead of silently averaged in.
74
+ */
75
+ export const TRACE_STUCK_AFTER_MS = 60 * 60 * 1000;
76
+
77
+ export type TraceRunStats = {
78
+ runs: number;
79
+ sources: number;
80
+ /** Open and started recently — genuinely in flight. */
81
+ running: number;
82
+ /** Open past the abandonment threshold — nothing is working on these. */
83
+ stuck: number;
84
+ /** Closed, but with a duration past the threshold; excluded from percentiles. */
85
+ anomalous: number;
86
+ succeeded: number;
87
+ failed: number;
88
+ errorRate: number;
89
+ avgDurationMs: number | null;
90
+ p95DurationMs: number | null;
91
+ p99DurationMs: number | null;
92
+ };
93
+
94
+ export type TraceSourceGroup = {
95
+ source: string;
96
+ appId: string | null;
97
+ categories: TraceCategory[];
98
+ names: string[];
99
+ runs: number;
100
+ jobRuns: number;
101
+ scheduleRuns: number;
102
+ aiRuns: number;
103
+ customRuns: number;
104
+ running: number;
105
+ stuck: number;
106
+ anomalous: number;
107
+ succeeded: number;
108
+ failed: number;
109
+ errorRate: number;
110
+ avgDurationMs: number | null;
111
+ p95DurationMs: number | null;
112
+ p99DurationMs: number | null;
113
+ latestName: string | null;
114
+ latestCategory: TraceCategory | null;
115
+ latestStatus: TraceStatus | null;
116
+ latestStartedAt: string | null;
117
+ latestEndedAt: string | null;
118
+ latestDurationMs: number | null;
119
+ };
120
+
121
+ export type TraceListFilter = {
122
+ appId?: string;
123
+ source?: string;
124
+ sources?: string[];
125
+ status?: TraceStatus;
126
+ active?: boolean;
127
+ category?: TraceCategory;
128
+ attributeEquals?: TraceAttributes;
129
+ search?: string;
130
+ sinceHours?: number;
131
+ sinceSeconds?: number;
132
+ window?: TraceWindow;
133
+ minDurationMs?: number;
134
+ };
135
+
136
+ export type TraceStartParams = {
137
+ name: string;
138
+ source: string;
139
+ spanKey?: string;
140
+ parent?: TraceContext;
141
+ appId?: string;
142
+ category?: TraceCategory;
143
+ kind?: TraceSpanKind;
144
+ attributes?: TraceAttributes | Record<string, unknown>;
145
+ startedAt?: Date | number | string;
146
+ };
147
+
148
+ export type TraceRecordParams = {
149
+ context?: TraceContext;
150
+ spanKey?: string;
151
+ name?: string;
152
+ source?: string;
153
+ appId?: string;
154
+ category?: TraceCategory;
155
+ kind?: TraceSpanKind;
156
+ event: string;
157
+ severity?: TraceSeverity;
158
+ attributes?: TraceAttributes | Record<string, unknown>;
159
+ body?: string;
160
+ status?: TraceStatus;
161
+ statusMessage?: string;
162
+ summary?: Record<string, unknown>;
163
+ occurredAt?: Date | number | string;
164
+ };
165
+
166
+ export type TraceEndParams = {
167
+ context?: TraceContext;
168
+ spanKey?: string;
169
+ status?: TraceStatus;
170
+ statusMessage?: string;
171
+ summary?: Record<string, unknown>;
172
+ endedAt?: Date | number | string;
173
+ };
174
+
175
+ export type TraceCompleteParams = TraceStartParams & {
176
+ status?: TraceStatus;
177
+ statusMessage?: string;
178
+ summary?: Record<string, unknown>;
179
+ endedAt?: Date | number | string;
180
+ };
181
+
182
+ export type TraceWithSpanOptions<T> = {
183
+ summarize?: (result: T) => Record<string, unknown> | undefined;
184
+ onError?: (error: unknown) => Record<string, unknown> | undefined;
185
+ };
186
+
187
+ type DbTraceSpanRow = {
188
+ trace_id: string;
189
+ span_id: string;
190
+ span_key: string | null;
191
+ parent_span_id: string | null;
192
+ name: string;
193
+ source: string;
194
+ app_id: string | null;
195
+ category: TraceCategory;
196
+ kind: TraceSpanKind;
197
+ status: TraceStatus;
198
+ status_message: string | null;
199
+ attributes: Record<string, unknown> | string | null;
200
+ summary: Record<string, unknown> | string | null;
201
+ event_count?: number;
202
+ started_at: Date | string;
203
+ ended_at: Date | string | null;
204
+ duration_ms: number | string | null;
205
+ updated_at: Date | string;
206
+ };
207
+
208
+ type DbTraceEventRow = {
209
+ id: string | number;
210
+ trace_id: string;
211
+ span_id: string;
212
+ name: string;
213
+ severity: TraceSeverity;
214
+ attributes: Record<string, unknown> | string | null;
215
+ body: string | null;
216
+ occurred_at: Date | string;
217
+ };
218
+
219
+ type DbTraceStatsRow = {
220
+ runs: number;
221
+ sources: number;
222
+ running: number;
223
+ stuck: number;
224
+ anomalous: number;
225
+ succeeded: number;
226
+ failed: number;
227
+ error_rate: number | string | null;
228
+ avg_duration_ms: number | string | null;
229
+ p95_duration_ms: number | string | null;
230
+ p99_duration_ms: number | string | null;
231
+ };
232
+
233
+ type DbTraceSourceGroupRow = DbTraceStatsRow & {
234
+ source: string;
235
+ app_id: string | null;
236
+ categories: TraceCategory[] | string | null;
237
+ names: string[] | string | null;
238
+ job_runs: number;
239
+ schedule_runs: number;
240
+ ai_runs: number;
241
+ custom_runs: number;
242
+ latest_name: string | null;
243
+ latest_category: TraceCategory | null;
244
+ latest_status: TraceStatus | null;
245
+ latest_started_at: Date | string | null;
246
+ latest_ended_at: Date | string | null;
247
+ latest_duration_ms: number | string | null;
248
+ };
249
+
250
+ const MAX_TEXT_LENGTH = 2_000;
251
+ const MAX_ARRAY_ITEMS = 50;
252
+ const MAX_OBJECT_KEYS = 100;
253
+ const MAX_JSON_DEPTH = 4;
254
+ const DEFAULT_SOURCE = "trace";
255
+ const TRACE_WINDOW_SECONDS: Record<TraceWindow, number> = {
256
+ "10m": 10 * 60,
257
+ "1h": 60 * 60,
258
+ "12h": 12 * 60 * 60,
259
+ "24h": 24 * 60 * 60,
260
+ "7d": 7 * 24 * 60 * 60,
261
+ "30d": 30 * 24 * 60 * 60,
262
+ };
263
+
264
+ const normalizeDate = (value: Date | number | string | undefined): Date => {
265
+ if (value instanceof Date) return value;
266
+ if (typeof value === "number" || typeof value === "string") {
267
+ const date = new Date(value);
268
+ if (!Number.isNaN(date.getTime())) return date;
269
+ }
270
+ return new Date();
271
+ };
272
+
273
+ const toIso = (value: Date | string): string => (value instanceof Date ? value.toISOString() : new Date(value).toISOString());
274
+
275
+ const toNumberOrNull = (value: number | string | null | undefined): number | null => {
276
+ if (value === null || value === undefined) return null;
277
+ const number = Number(value);
278
+ return Number.isFinite(number) ? number : null;
279
+ };
280
+
281
+ const toNumber = (value: number | string | null | undefined): number => toNumberOrNull(value) ?? 0;
282
+
283
+ const toStringArray = <T extends string>(value: T[] | string | null | undefined): T[] => {
284
+ if (!value) return [];
285
+ if (Array.isArray(value)) return value.filter(Boolean);
286
+ return value
287
+ .replace(/[{}]/g, "")
288
+ .split(",")
289
+ .map((item) => item.trim())
290
+ .filter(Boolean) as T[];
291
+ };
292
+
293
+ const randomHex = (bytes: number): string => randomBytes(bytes).toString("hex");
294
+
295
+ const hashHex = (value: string, bytes: number): string =>
296
+ createHash("sha256")
297
+ .update(value)
298
+ .digest("hex")
299
+ .slice(0, bytes * 2);
300
+
301
+ const contextFor = (traceId: string, spanId: string): TraceContext => ({
302
+ traceId,
303
+ spanId,
304
+ traceparent: `00-${traceId}-${spanId}-01`,
305
+ });
306
+
307
+ const newContext = (spanKey?: string, parent?: TraceContext): TraceContext => {
308
+ if (spanKey) return contextFor(hashHex(`trace:${spanKey}`, 16), hashHex(`span:${spanKey}`, 8));
309
+ return contextFor(parent?.traceId ?? randomHex(16), randomHex(8));
310
+ };
311
+
312
+ const contextFromParams = (params: { context?: TraceContext; spanKey?: string }): TraceContext | null => {
313
+ if (params.context) return params.context;
314
+ if (params.spanKey) return newContext(params.spanKey);
315
+ return null;
316
+ };
317
+
318
+ const sanitizeJson = (input: unknown, depth = 0): unknown => {
319
+ const redacted = redactMetadata(input);
320
+ if (redacted === null) return null;
321
+ if (typeof redacted === "string") return redacted.length > MAX_TEXT_LENGTH ? `${redacted.slice(0, MAX_TEXT_LENGTH)}...` : redacted;
322
+ if (typeof redacted === "number") return Number.isFinite(redacted) ? redacted : null;
323
+ if (typeof redacted === "boolean") return redacted;
324
+ if (typeof redacted === "bigint") return redacted.toString();
325
+ if (redacted === undefined || typeof redacted === "function" || typeof redacted === "symbol") return null;
326
+ if (depth >= MAX_JSON_DEPTH) return "[Max depth]";
327
+ if (Array.isArray(redacted)) {
328
+ const items = redacted.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitizeJson(item, depth + 1));
329
+ if (redacted.length > MAX_ARRAY_ITEMS) items.push(`[${redacted.length - MAX_ARRAY_ITEMS} more]`);
330
+ return items;
331
+ }
332
+ if (typeof redacted === "object") {
333
+ const out: Record<string, unknown> = {};
334
+ const entries = Object.entries(redacted as Record<string, unknown>).slice(0, MAX_OBJECT_KEYS);
335
+ for (const [key, value] of entries) {
336
+ out[key] = isSensitiveMetadataKey(key) ? REDACTED : sanitizeJson(value, depth + 1);
337
+ }
338
+ const extra = Object.keys(redacted as Record<string, unknown>).length - entries.length;
339
+ if (extra > 0) out.__truncatedKeys = extra;
340
+ return out;
341
+ }
342
+ return null;
343
+ };
344
+
345
+ const sanitizeRecord = (input: Record<string, unknown> | TraceAttributes | undefined): Record<string, unknown> | null => {
346
+ if (!input) return null;
347
+ const sanitized = sanitizeJson(input);
348
+ if (!sanitized || typeof sanitized !== "object" || Array.isArray(sanitized)) return null;
349
+ return sanitized as Record<string, unknown>;
350
+ };
351
+
352
+ const sanitizeAttributes = (input: Record<string, unknown> | TraceAttributes | undefined): Record<string, unknown> | null => {
353
+ const record = sanitizeRecord(input);
354
+ if (!record) return null;
355
+ const out: Record<string, unknown> = {};
356
+ for (const [key, value] of Object.entries(record)) {
357
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
358
+ out[key] = value;
359
+ } else {
360
+ out[key] = JSON.stringify(value);
361
+ }
362
+ }
363
+ return out;
364
+ };
365
+
366
+ const errorAttributes = (error: Error): TraceAttributes => ({
367
+ "exception.type": error.name || "Error",
368
+ "exception.message": error.message,
369
+ });
370
+
371
+ const mapSpanRow = (row: DbTraceSpanRow): TraceSpan => {
372
+ const context = contextFor(row.trace_id, row.span_id);
373
+ return {
374
+ ...context,
375
+ spanKey: row.span_key,
376
+ parentSpanId: row.parent_span_id,
377
+ name: row.name,
378
+ source: row.source,
379
+ appId: row.app_id,
380
+ category: row.category,
381
+ kind: row.kind,
382
+ status: row.status,
383
+ statusMessage: row.status_message,
384
+ attributes: parsePgJsonRecord(row.attributes),
385
+ summary: parsePgJsonRecord(row.summary),
386
+ eventCount: Number(row.event_count ?? 0),
387
+ startedAt: toIso(row.started_at),
388
+ endedAt: row.ended_at ? toIso(row.ended_at) : null,
389
+ durationMs: toNumberOrNull(row.duration_ms),
390
+ updatedAt: toIso(row.updated_at),
391
+ };
392
+ };
393
+
394
+ const mapEventRow = (row: DbTraceEventRow): TraceEvent => ({
395
+ id: String(row.id),
396
+ traceId: row.trace_id,
397
+ spanId: row.span_id,
398
+ name: row.name,
399
+ severity: row.severity,
400
+ attributes: parsePgJsonRecord(row.attributes),
401
+ body: row.body,
402
+ occurredAt: toIso(row.occurred_at),
403
+ });
404
+
405
+ const mapStatsRow = (row: DbTraceStatsRow | undefined): TraceRunStats => ({
406
+ runs: row?.runs ?? 0,
407
+ sources: row?.sources ?? 0,
408
+ running: row?.running ?? 0,
409
+ stuck: row?.stuck ?? 0,
410
+ anomalous: row?.anomalous ?? 0,
411
+ succeeded: row?.succeeded ?? 0,
412
+ failed: row?.failed ?? 0,
413
+ errorRate: toNumber(row?.error_rate),
414
+ avgDurationMs: toNumberOrNull(row?.avg_duration_ms),
415
+ p95DurationMs: toNumberOrNull(row?.p95_duration_ms),
416
+ p99DurationMs: toNumberOrNull(row?.p99_duration_ms),
417
+ });
418
+
419
+ const mapSourceGroupRow = (row: DbTraceSourceGroupRow): TraceSourceGroup => ({
420
+ source: row.source,
421
+ appId: row.app_id,
422
+ categories: toStringArray(row.categories),
423
+ names: toStringArray(row.names),
424
+ runs: row.runs,
425
+ jobRuns: row.job_runs,
426
+ scheduleRuns: row.schedule_runs,
427
+ aiRuns: row.ai_runs,
428
+ customRuns: row.custom_runs,
429
+ running: row.running,
430
+ stuck: row.stuck,
431
+ anomalous: row.anomalous,
432
+ succeeded: row.succeeded,
433
+ failed: row.failed,
434
+ errorRate: toNumber(row.error_rate),
435
+ avgDurationMs: toNumberOrNull(row.avg_duration_ms),
436
+ p95DurationMs: toNumberOrNull(row.p95_duration_ms),
437
+ p99DurationMs: toNumberOrNull(row.p99_duration_ms),
438
+ latestName: row.latest_name,
439
+ latestCategory: row.latest_category,
440
+ latestStatus: row.latest_status,
441
+ latestStartedAt: row.latest_started_at ? toIso(row.latest_started_at) : null,
442
+ latestEndedAt: row.latest_ended_at ? toIso(row.latest_ended_at) : null,
443
+ latestDurationMs: toNumberOrNull(row.latest_duration_ms),
444
+ });
445
+
446
+ const updateSpan = async (
447
+ context: TraceContext,
448
+ params: {
449
+ status?: TraceStatus;
450
+ statusMessage?: string;
451
+ summary?: Record<string, unknown>;
452
+ endedAt?: Date;
453
+ },
454
+ ): Promise<void> => {
455
+ const summary = sanitizeRecord(params.summary);
456
+ const summaryJson = summary ? JSON.stringify(summary) : null;
457
+ const statusMessage = params.statusMessage ? String(sanitizeJson(params.statusMessage)) : null;
458
+ await sql`
459
+ UPDATE logging.trace_spans
460
+ SET
461
+ status = COALESCE(${params.status ?? null}::text, status),
462
+ status_message = COALESCE(${statusMessage}::text, status_message),
463
+ summary = CASE
464
+ WHEN (${summaryJson}::text)::jsonb IS NULL THEN logging.trace_spans.summary
465
+ ELSE COALESCE(logging.trace_spans.summary, '{}'::jsonb) || (${summaryJson}::text)::jsonb
466
+ END,
467
+ ended_at = COALESCE(${params.endedAt ?? null}, ended_at),
468
+ duration_ms = CASE
469
+ WHEN COALESCE(${params.endedAt ?? null}, ended_at) IS NULL THEN duration_ms
470
+ ELSE GREATEST(0, EXTRACT(EPOCH FROM (COALESCE(${params.endedAt ?? null}, ended_at) - started_at)) * 1000)
471
+ END,
472
+ updated_at = now()
473
+ WHERE trace_id = ${context.traceId} AND span_id = ${context.spanId}
474
+ `.catch((error: Error) => console.error("[logging:trace] span update failed:", error.message));
475
+ };
476
+
477
+ const start = async (params: TraceStartParams, preservePresentation = false): Promise<TraceContext> => {
478
+ const context = newContext(params.spanKey, params.parent);
479
+ const attributes = sanitizeAttributes(params.attributes);
480
+ const attributesJson = attributes ? JSON.stringify(attributes) : null;
481
+ const startedAt = normalizeDate(params.startedAt);
482
+ const [stored] = await sql<Array<{ trace_id: string; span_id: string }>>`
483
+ INSERT INTO logging.trace_spans (
484
+ trace_id,
485
+ span_id,
486
+ span_key,
487
+ parent_span_id,
488
+ name,
489
+ source,
490
+ app_id,
491
+ category,
492
+ kind,
493
+ status,
494
+ attributes,
495
+ started_at,
496
+ updated_at
497
+ )
498
+ VALUES (
499
+ ${context.traceId},
500
+ ${context.spanId},
501
+ ${params.spanKey ?? null},
502
+ ${params.parent?.spanId ?? null},
503
+ ${params.name},
504
+ ${params.source},
505
+ ${params.appId ?? null},
506
+ ${params.category ?? "custom"},
507
+ ${params.kind ?? "internal"},
508
+ 'unset',
509
+ (${attributesJson}::text)::jsonb,
510
+ ${startedAt},
511
+ now()
512
+ )
513
+ ON CONFLICT (trace_id, span_id) DO UPDATE
514
+ SET
515
+ span_key = COALESCE(logging.trace_spans.span_key, EXCLUDED.span_key),
516
+ parent_span_id = COALESCE(logging.trace_spans.parent_span_id, EXCLUDED.parent_span_id),
517
+ name = CASE WHEN ${preservePresentation} THEN logging.trace_spans.name ELSE EXCLUDED.name END,
518
+ source = CASE WHEN ${preservePresentation} THEN logging.trace_spans.source ELSE EXCLUDED.source END,
519
+ app_id = COALESCE(EXCLUDED.app_id, logging.trace_spans.app_id),
520
+ category = CASE WHEN ${preservePresentation} THEN logging.trace_spans.category ELSE EXCLUDED.category END,
521
+ kind = CASE WHEN ${preservePresentation} THEN logging.trace_spans.kind ELSE EXCLUDED.kind END,
522
+ attributes = COALESCE(logging.trace_spans.attributes, '{}'::jsonb) || COALESCE(EXCLUDED.attributes, '{}'::jsonb),
523
+ started_at = LEAST(logging.trace_spans.started_at, EXCLUDED.started_at),
524
+ updated_at = now()
525
+ RETURNING trace_id, span_id
526
+ `.catch((error: Error) => {
527
+ console.error("[logging:trace] span start failed:", error.message);
528
+ return [];
529
+ });
530
+ return stored ? contextFor(stored.trace_id, stored.span_id) : context;
531
+ };
532
+
533
+ /**
534
+ * Persists a span whose complete lifecycle is already known. Hot request paths
535
+ * can retain exact tracing without a start/update round-trip pair.
536
+ */
537
+ const complete = async (params: TraceCompleteParams, preservePresentation = false): Promise<TraceContext> => {
538
+ const context = newContext(params.spanKey, params.parent);
539
+ const attributes = sanitizeAttributes(params.attributes);
540
+ const summary = sanitizeRecord(params.summary);
541
+ const attributesJson = attributes ? JSON.stringify(attributes) : null;
542
+ const summaryJson = summary ? JSON.stringify(summary) : null;
543
+ const statusMessage = params.statusMessage ? String(sanitizeJson(params.statusMessage)) : null;
544
+ const startedAt = normalizeDate(params.startedAt);
545
+ const endedAt = normalizeDate(params.endedAt);
546
+ const [stored] = await sql<Array<{ trace_id: string; span_id: string }>>`
547
+ INSERT INTO logging.trace_spans (
548
+ trace_id,
549
+ span_id,
550
+ span_key,
551
+ parent_span_id,
552
+ name,
553
+ source,
554
+ app_id,
555
+ category,
556
+ kind,
557
+ status,
558
+ status_message,
559
+ attributes,
560
+ summary,
561
+ started_at,
562
+ ended_at,
563
+ duration_ms,
564
+ updated_at
565
+ )
566
+ VALUES (
567
+ ${context.traceId},
568
+ ${context.spanId},
569
+ ${params.spanKey ?? null},
570
+ ${params.parent?.spanId ?? null},
571
+ ${params.name},
572
+ ${params.source},
573
+ ${params.appId ?? null},
574
+ ${params.category ?? "custom"},
575
+ ${params.kind ?? "internal"},
576
+ ${params.status ?? "ok"},
577
+ ${statusMessage},
578
+ (${attributesJson}::text)::jsonb,
579
+ (${summaryJson}::text)::jsonb,
580
+ ${startedAt},
581
+ ${endedAt},
582
+ GREATEST(0, EXTRACT(EPOCH FROM (${endedAt}::timestamptz - ${startedAt}::timestamptz)) * 1000),
583
+ now()
584
+ )
585
+ ON CONFLICT (trace_id, span_id) DO UPDATE
586
+ SET
587
+ span_key = COALESCE(logging.trace_spans.span_key, EXCLUDED.span_key),
588
+ parent_span_id = COALESCE(logging.trace_spans.parent_span_id, EXCLUDED.parent_span_id),
589
+ name = CASE WHEN ${preservePresentation} THEN logging.trace_spans.name ELSE EXCLUDED.name END,
590
+ source = CASE WHEN ${preservePresentation} THEN logging.trace_spans.source ELSE EXCLUDED.source END,
591
+ app_id = COALESCE(EXCLUDED.app_id, logging.trace_spans.app_id),
592
+ category = CASE WHEN ${preservePresentation} THEN logging.trace_spans.category ELSE EXCLUDED.category END,
593
+ kind = CASE WHEN ${preservePresentation} THEN logging.trace_spans.kind ELSE EXCLUDED.kind END,
594
+ status = EXCLUDED.status,
595
+ status_message = EXCLUDED.status_message,
596
+ attributes = COALESCE(logging.trace_spans.attributes, '{}'::jsonb) || COALESCE(EXCLUDED.attributes, '{}'::jsonb),
597
+ summary = COALESCE(logging.trace_spans.summary, '{}'::jsonb) || COALESCE(EXCLUDED.summary, '{}'::jsonb),
598
+ started_at = LEAST(logging.trace_spans.started_at, EXCLUDED.started_at),
599
+ ended_at = GREATEST(logging.trace_spans.ended_at, EXCLUDED.ended_at),
600
+ duration_ms = GREATEST(
601
+ 0,
602
+ EXTRACT(EPOCH FROM (GREATEST(logging.trace_spans.ended_at, EXCLUDED.ended_at) - LEAST(logging.trace_spans.started_at, EXCLUDED.started_at))) * 1000
603
+ ),
604
+ updated_at = now()
605
+ RETURNING trace_id, span_id
606
+ `.catch((error: Error) => {
607
+ console.error("[logging:trace] completed span write failed:", error.message);
608
+ return [];
609
+ });
610
+ return stored ? contextFor(stored.trace_id, stored.span_id) : context;
611
+ };
612
+
613
+ const record = async (params: TraceRecordParams, preservePresentation = false): Promise<TraceContext> => {
614
+ const standalone = !params.context && !params.spanKey;
615
+ const context =
616
+ params.context ??
617
+ (await start(
618
+ {
619
+ name: params.name ?? params.event,
620
+ source: params.source ?? DEFAULT_SOURCE,
621
+ spanKey: params.spanKey,
622
+ appId: params.appId,
623
+ category: params.category ?? "custom",
624
+ kind: params.kind ?? "internal",
625
+ startedAt: params.occurredAt,
626
+ },
627
+ preservePresentation,
628
+ ));
629
+ const occurredAt = normalizeDate(params.occurredAt);
630
+ const attributes = sanitizeAttributes(params.attributes);
631
+ const attributesJson = attributes ? JSON.stringify(attributes) : null;
632
+ const body = params.body ? String(sanitizeJson(params.body)) : null;
633
+ await sql`
634
+ INSERT INTO logging.trace_events (trace_id, span_id, name, severity, attributes, body, occurred_at)
635
+ VALUES (
636
+ ${context.traceId},
637
+ ${context.spanId},
638
+ ${params.event},
639
+ ${params.severity ?? "info"},
640
+ (${attributesJson}::text)::jsonb,
641
+ ${body},
642
+ ${occurredAt}
643
+ )
644
+ `.catch((error: Error) => console.error("[logging:trace] event write failed:", error.message));
645
+ if (params.status || params.statusMessage || params.summary) {
646
+ await updateSpan(context, { status: params.status, statusMessage: params.statusMessage, summary: params.summary });
647
+ }
648
+ if (standalone) await end({ context, status: params.status ?? "unset", summary: params.summary, endedAt: occurredAt });
649
+ return context;
650
+ };
651
+
652
+ const end = async (params: TraceEndParams): Promise<void> => {
653
+ // An application may attach a summary immediately after Sync emits start.
654
+ // Wait for that queued insert before updating the same deterministic span.
655
+ if (params.spanKey) await pendingSyncWrites.get(params.spanKey);
656
+ const context = contextFromParams(params);
657
+ if (!context) return;
658
+ await updateSpan(context, {
659
+ status: params.status ?? "ok",
660
+ statusMessage: params.statusMessage,
661
+ summary: params.summary,
662
+ endedAt: normalizeDate(params.endedAt),
663
+ });
664
+ };
665
+
666
+ const withSpan = async <T>(
667
+ params: TraceStartParams,
668
+ fn: (span: TraceContext) => Promise<T> | T,
669
+ options?: TraceWithSpanOptions<T>,
670
+ ): Promise<T> => {
671
+ const span = await start(params);
672
+ try {
673
+ const result = await fn(span);
674
+ await end({ context: span, status: "ok", summary: options?.summarize?.(result) });
675
+ return result;
676
+ } catch (error) {
677
+ const message = error instanceof Error ? error.message : String(error);
678
+ await record({
679
+ context: span,
680
+ event: "exception",
681
+ severity: "error",
682
+ attributes: error instanceof Error ? errorAttributes(error) : { "exception.message": message },
683
+ });
684
+ await end({ context: span, status: "error", statusMessage: message, summary: options?.onError?.(error) });
685
+ throw error;
686
+ }
687
+ };
688
+
689
+ const normalizeSinceHours = (sinceHours: number | undefined): number | null =>
690
+ Number.isFinite(sinceHours) && sinceHours && sinceHours > 0 ? Math.trunc(sinceHours) : null;
691
+
692
+ const normalizeSinceSeconds = (filter: TraceListFilter | undefined): number | null => {
693
+ if (Number.isFinite(filter?.sinceSeconds) && filter?.sinceSeconds && filter.sinceSeconds > 0) return Math.trunc(filter.sinceSeconds);
694
+ if (filter?.window) return TRACE_WINDOW_SECONDS[filter.window];
695
+ const sinceHours = normalizeSinceHours(filter?.sinceHours);
696
+ return sinceHours ? sinceHours * 60 * 60 : null;
697
+ };
698
+
699
+ const normalizeMinDurationMs = (value: number | undefined): number | null =>
700
+ Number.isFinite(value) && value && value > 0 ? Math.trunc(value) : null;
701
+
702
+ const normalizeLimit = (limit: number | undefined): number => Math.min(Math.max(Math.trunc(limit ?? 50), 1), 200);
703
+
704
+ const traceConditions = (filter: TraceListFilter | undefined): any[] => {
705
+ const conditions: any[] = [sql`TRUE`];
706
+ const searchPattern = filter?.search ? `%${escapeLikePattern(filter.search)}%` : null;
707
+ const sources = filter?.sources?.map((value) => value.trim()).filter(Boolean);
708
+ const sourceList = sources && sources.length > 0 ? toPgTextArray([...new Set(sources)]) : null;
709
+ const sinceSeconds = normalizeSinceSeconds(filter);
710
+ const minDurationMs = normalizeMinDurationMs(filter?.minDurationMs);
711
+ const attributeEquals = sanitizeAttributes(filter?.attributeEquals);
712
+
713
+ if (filter?.appId) conditions.push(sql`s.app_id = ${filter.appId}`);
714
+ if (filter?.source) conditions.push(sql`s.source = ${filter.source}`);
715
+ if (sourceList && !filter?.source) conditions.push(sql`s.source = ANY(${sourceList}::text[])`);
716
+ if (filter?.status) conditions.push(sql`s.status = ${filter.status}`);
717
+ if (filter?.active === true) conditions.push(sql`s.ended_at IS NULL`);
718
+ if (filter?.active === false) conditions.push(sql`s.ended_at IS NOT NULL`);
719
+ if (filter?.category) conditions.push(sql`s.category = ${filter.category}`);
720
+ if (attributeEquals && Object.keys(attributeEquals).length > 0) {
721
+ conditions.push(sql`s.attributes @> (${JSON.stringify(attributeEquals)}::text)::jsonb`);
722
+ }
723
+ if (sinceSeconds) conditions.push(sql`s.started_at >= now() - (${sinceSeconds}::int * INTERVAL '1 second')`);
724
+ if (minDurationMs) conditions.push(sql`s.duration_ms IS NOT NULL AND s.duration_ms > ${minDurationMs}`);
725
+ if (searchPattern) {
726
+ conditions.push(sql`(
727
+ s.name ILIKE ${searchPattern} ESCAPE '\'
728
+ OR s.source ILIKE ${searchPattern} ESCAPE '\'
729
+ OR COALESCE(s.app_id, '') ILIKE ${searchPattern} ESCAPE '\'
730
+ OR COALESCE(s.span_key, '') ILIKE ${searchPattern} ESCAPE '\'
731
+ )`);
732
+ }
733
+
734
+ return conditions;
735
+ };
736
+
737
+ const traceWhere = (filter: TraceListFilter | undefined) =>
738
+ traceConditions(filter).reduce((acc, condition) => sql`${acc} AND ${condition}`);
739
+
740
+ /**
741
+ * Abandoned spans are old by definition, so a windowed count hides exactly the
742
+ * ones worth seeing: with a 24h window, a span orphaned three days ago reports
743
+ * zero. Stuck is therefore counted across all retained spans, with the rest of
744
+ * the filter (source, category, search) still applied.
745
+ */
746
+ const stuckWhere = (filter: TraceListFilter | undefined) =>
747
+ traceConditions({ ...filter, window: undefined })
748
+ .concat([sql`s.ended_at IS NULL`, sql`s.started_at < now() - (${TRACE_STUCK_AFTER_MS}::bigint * INTERVAL '1 millisecond')`])
749
+ .reduce((acc, condition) => sql`${acc} AND ${condition}`);
750
+
751
+ const countStuck = async (filter: TraceListFilter | undefined): Promise<number> => {
752
+ const [row] = await sql<{ count: number }[]>`
753
+ SELECT COUNT(*)::int AS count FROM logging.trace_spans s WHERE ${stuckWhere(filter)}
754
+ `;
755
+ return row?.count ?? 0;
756
+ };
757
+
758
+ const countStuckBySource = async (filter: TraceListFilter | undefined): Promise<Map<string, number>> => {
759
+ const rows = await sql<{ source: string; count: number }[]>`
760
+ SELECT s.source, COUNT(*)::int AS count
761
+ FROM logging.trace_spans s WHERE ${stuckWhere(filter)}
762
+ GROUP BY s.source
763
+ `;
764
+ return new Map(rows.map((row) => [row.source, row.count]));
765
+ };
766
+
767
+ const list = async (
768
+ pagination: PaginationParams,
769
+ options?: {
770
+ filter?: TraceListFilter;
771
+ },
772
+ ): Promise<{ spans: TraceSpan[]; total: number }> => {
773
+ const { offset, perPage } = pagination;
774
+ const where = traceWhere(options?.filter);
775
+ const [countRows, dataRows] = await Promise.all([
776
+ sql<{ count: number }[]>`
777
+ SELECT COUNT(*)::int AS count
778
+ FROM logging.trace_spans s
779
+ WHERE ${where}
780
+ `,
781
+ sql<DbTraceSpanRow[]>`
782
+ SELECT
783
+ s.*,
784
+ (
785
+ SELECT COUNT(*)::int
786
+ FROM logging.trace_events e
787
+ WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id
788
+ ) AS event_count
789
+ FROM logging.trace_spans s
790
+ WHERE ${where}
791
+ ORDER BY COALESCE(s.ended_at, s.updated_at, s.started_at) DESC
792
+ LIMIT ${perPage} OFFSET ${offset}
793
+ `,
794
+ ]);
795
+
796
+ return { spans: dataRows.map(mapSpanRow), total: countRows[0]?.count ?? 0 };
797
+ };
798
+
799
+ const getSpan = async (params: { traceId: string; spanId: string }): Promise<TraceSpan | null> => {
800
+ const rows = await sql<DbTraceSpanRow[]>`
801
+ SELECT
802
+ s.*,
803
+ (
804
+ SELECT COUNT(*)::int
805
+ FROM logging.trace_events e
806
+ WHERE e.trace_id = s.trace_id AND e.span_id = s.span_id
807
+ ) AS event_count
808
+ FROM logging.trace_spans s
809
+ WHERE s.trace_id = ${params.traceId} AND s.span_id = ${params.spanId}
810
+ LIMIT 1
811
+ `;
812
+ return rows[0] ? mapSpanRow(rows[0]) : null;
813
+ };
814
+
815
+ const stats = async (options?: { filter?: TraceListFilter }): Promise<TraceRunStats> => {
816
+ const where = traceWhere(options?.filter);
817
+ const [row] = await sql<DbTraceStatsRow[]>`
818
+ SELECT
819
+ COUNT(*)::int AS runs,
820
+ COUNT(DISTINCT s.source)::int AS sources,
821
+ COUNT(*) FILTER (WHERE s.ended_at IS NULL AND s.started_at >= now() - (${TRACE_STUCK_AFTER_MS}::bigint * INTERVAL '1 millisecond'))::int AS running,
822
+ COUNT(*) FILTER (WHERE s.ended_at IS NULL AND s.started_at < now() - (${TRACE_STUCK_AFTER_MS}::bigint * INTERVAL '1 millisecond'))::int AS stuck,
823
+ COUNT(*) FILTER (WHERE s.duration_ms > ${TRACE_STUCK_AFTER_MS})::int AS anomalous,
824
+ COUNT(*) FILTER (WHERE s.status = 'ok')::int AS succeeded,
825
+ COUNT(*) FILTER (WHERE s.status = 'error')::int AS failed,
826
+ COALESCE(ROUND((COUNT(*) FILTER (WHERE s.status = 'error'))::numeric * 100 / NULLIF(COUNT(*), 0), 2), 0)::float AS error_rate,
827
+ (AVG(s.duration_ms) FILTER (WHERE s.duration_ms IS NOT NULL AND s.duration_ms <= ${TRACE_STUCK_AFTER_MS}))::float AS avg_duration_ms,
828
+ (percentile_cont(0.95) WITHIN GROUP (ORDER BY s.duration_ms) FILTER (WHERE s.duration_ms IS NOT NULL AND s.duration_ms <= ${TRACE_STUCK_AFTER_MS}))::float AS p95_duration_ms,
829
+ (percentile_cont(0.99) WITHIN GROUP (ORDER BY s.duration_ms) FILTER (WHERE s.duration_ms IS NOT NULL AND s.duration_ms <= ${TRACE_STUCK_AFTER_MS}))::float AS p99_duration_ms
830
+ FROM logging.trace_spans s
831
+ WHERE ${where}
832
+ `;
833
+ return { ...mapStatsRow(row), stuck: await countStuck(options?.filter) };
834
+ };
835
+
836
+ const sourceGroups = async (options?: { filter?: TraceListFilter }): Promise<TraceSourceGroup[]> => {
837
+ const where = traceWhere(options?.filter);
838
+ const stuckBySource = await countStuckBySource(options?.filter);
839
+ const rows = await sql<DbTraceSourceGroupRow[]>`
840
+ WITH grouped AS (
841
+ SELECT
842
+ s.source,
843
+ (array_remove(array_agg(DISTINCT s.app_id), NULL))[1] AS app_id,
844
+ array_agg(DISTINCT s.category ORDER BY s.category)::text[] AS categories,
845
+ array_agg(DISTINCT s.name ORDER BY s.name)::text[] AS names,
846
+ COUNT(*)::int AS runs,
847
+ COUNT(DISTINCT s.source)::int AS sources,
848
+ COUNT(*) FILTER (WHERE s.category = 'job')::int AS job_runs,
849
+ COUNT(*) FILTER (WHERE s.category = 'schedule')::int AS schedule_runs,
850
+ COUNT(*) FILTER (WHERE s.category = 'ai')::int AS ai_runs,
851
+ COUNT(*) FILTER (WHERE s.category = 'custom')::int AS custom_runs,
852
+ COUNT(*) FILTER (WHERE s.ended_at IS NULL AND s.started_at >= now() - (${TRACE_STUCK_AFTER_MS}::bigint * INTERVAL '1 millisecond'))::int AS running,
853
+ COUNT(*) FILTER (WHERE s.ended_at IS NULL AND s.started_at < now() - (${TRACE_STUCK_AFTER_MS}::bigint * INTERVAL '1 millisecond'))::int AS stuck,
854
+ COUNT(*) FILTER (WHERE s.duration_ms > ${TRACE_STUCK_AFTER_MS})::int AS anomalous,
855
+ COUNT(*) FILTER (WHERE s.status = 'ok')::int AS succeeded,
856
+ COUNT(*) FILTER (WHERE s.status = 'error')::int AS failed,
857
+ COALESCE(ROUND((COUNT(*) FILTER (WHERE s.status = 'error'))::numeric * 100 / NULLIF(COUNT(*), 0), 2), 0)::float AS error_rate,
858
+ (AVG(s.duration_ms) FILTER (WHERE s.duration_ms IS NOT NULL AND s.duration_ms <= ${TRACE_STUCK_AFTER_MS}))::float AS avg_duration_ms,
859
+ (percentile_cont(0.95) WITHIN GROUP (ORDER BY s.duration_ms) FILTER (WHERE s.duration_ms IS NOT NULL AND s.duration_ms <= ${TRACE_STUCK_AFTER_MS}))::float AS p95_duration_ms,
860
+ (percentile_cont(0.99) WITHIN GROUP (ORDER BY s.duration_ms) FILTER (WHERE s.duration_ms IS NOT NULL AND s.duration_ms <= ${TRACE_STUCK_AFTER_MS}))::float AS p99_duration_ms,
861
+ (array_agg(s.name ORDER BY s.started_at DESC, s.updated_at DESC))[1] AS latest_name,
862
+ (array_agg(s.category ORDER BY s.started_at DESC, s.updated_at DESC))[1] AS latest_category,
863
+ (array_agg(s.status ORDER BY s.started_at DESC, s.updated_at DESC))[1] AS latest_status,
864
+ (array_agg(s.started_at ORDER BY s.started_at DESC, s.updated_at DESC))[1] AS latest_started_at,
865
+ (array_agg(s.ended_at ORDER BY s.started_at DESC, s.updated_at DESC))[1] AS latest_ended_at,
866
+ (array_agg(s.duration_ms ORDER BY s.started_at DESC, s.updated_at DESC))[1] AS latest_duration_ms
867
+ FROM logging.trace_spans s
868
+ WHERE ${where}
869
+ GROUP BY s.source
870
+ )
871
+ SELECT *
872
+ FROM grouped
873
+ ORDER BY
874
+ (latest_ended_at IS NULL) DESC,
875
+ (latest_status = 'error') DESC,
876
+ error_rate DESC,
877
+ p99_duration_ms DESC NULLS LAST,
878
+ runs DESC,
879
+ source ASC
880
+ `;
881
+ return rows.map((row) => ({ ...mapSourceGroupRow(row), stuck: stuckBySource.get(row.source) ?? 0 }));
882
+ };
883
+
884
+ const events = async (params: { traceId: string; spanId?: string; limit?: number }): Promise<TraceEvent[]> => {
885
+ const limit = normalizeLimit(params.limit);
886
+ const rows = await sql<DbTraceEventRow[]>`
887
+ SELECT id, trace_id, span_id, name, severity, attributes, body, occurred_at
888
+ FROM logging.trace_events
889
+ WHERE trace_id = ${params.traceId}
890
+ AND (${params.spanId ?? null}::text IS NULL OR span_id = ${params.spanId ?? null})
891
+ ORDER BY occurred_at ASC, id ASC
892
+ LIMIT ${limit}
893
+ `;
894
+ return rows.map(mapEventRow);
895
+ };
896
+
897
+ const sources = async (): Promise<string[]> => {
898
+ const rows = await sql<{ source: string }[]>`
899
+ SELECT DISTINCT source FROM logging.trace_spans ORDER BY source
900
+ `;
901
+ return rows.map((row) => row.source);
902
+ };
903
+
904
+ const summary = async (options?: { sinceHours?: number }): Promise<TraceSummary> => {
905
+ const sinceHours = normalizeSinceHours(options?.sinceHours) ?? 24;
906
+ const [row] = await sql<
907
+ {
908
+ total: number;
909
+ total_window: number;
910
+ running: number;
911
+ succeeded_window: number;
912
+ failed_window: number;
913
+ sources: number;
914
+ avg_duration_ms: number | string | null;
915
+ p95_duration_ms: number | string | null;
916
+ }[]
917
+ >`
918
+ SELECT
919
+ (SELECT COUNT(*)::int FROM logging.trace_spans) AS total,
920
+ (SELECT COUNT(*)::int FROM logging.trace_spans WHERE started_at >= now() - (${sinceHours}::int * INTERVAL '1 hour')) AS total_window,
921
+ (SELECT COUNT(*)::int FROM logging.trace_spans WHERE ended_at IS NULL) AS running,
922
+ (SELECT COUNT(*)::int FROM logging.trace_spans WHERE status = 'ok' AND started_at >= now() - (${sinceHours}::int * INTERVAL '1 hour')) AS succeeded_window,
923
+ (SELECT COUNT(*)::int FROM logging.trace_spans WHERE status = 'error' AND started_at >= now() - (${sinceHours}::int * INTERVAL '1 hour')) AS failed_window,
924
+ (SELECT COUNT(*)::int FROM (SELECT DISTINCT source FROM logging.trace_spans) s) AS sources,
925
+ (SELECT AVG(duration_ms)::float FROM logging.trace_spans WHERE duration_ms IS NOT NULL AND started_at >= now() - (${sinceHours}::int * INTERVAL '1 hour')) AS avg_duration_ms,
926
+ (
927
+ SELECT percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms)::float
928
+ FROM logging.trace_spans
929
+ WHERE duration_ms IS NOT NULL AND started_at >= now() - (${sinceHours}::int * INTERVAL '1 hour')
930
+ ) AS p95_duration_ms
931
+ `;
932
+
933
+ return {
934
+ total: row?.total ?? 0,
935
+ totalWindow: row?.total_window ?? 0,
936
+ running: row?.running ?? 0,
937
+ succeededWindow: row?.succeeded_window ?? 0,
938
+ failedWindow: row?.failed_window ?? 0,
939
+ sources: row?.sources ?? 0,
940
+ avgDurationMs: toNumberOrNull(row?.avg_duration_ms),
941
+ p95DurationMs: toNumberOrNull(row?.p95_duration_ms),
942
+ };
943
+ };
944
+
945
+ const cleanup = async (options: { days: number; source?: string }): Promise<number> => {
946
+ const days = Math.max(1, Math.trunc(options.days));
947
+ const result = await sql`
948
+ DELETE FROM logging.trace_spans
949
+ WHERE ended_at IS NOT NULL
950
+ AND ended_at < now() - (${days}::int * INTERVAL '1 day')
951
+ AND (${options.source ?? null}::text IS NULL OR source = ${options.source ?? null})
952
+ `;
953
+ return result.count;
954
+ };
955
+
956
+ // ==========================
957
+ // Sync observe adapter
958
+ // ==========================
959
+
960
+ /**
961
+ * Sync v6 emits ids, keys and statuses only — never handler results — so a
962
+ * run span carries the run identity and outcome. A handler that wants a
963
+ * summary attaches it itself with `trace.end({ spanKey, summary })`, using
964
+ * `trace.syncSpanKey(kind, resource, runId)` for the same span.
965
+ *
966
+ * Topic consumer runs are traced only when they fail: a successful first
967
+ * attempt writes nothing. Telemetry consumers process one event per request,
968
+ * and two span writes per event would multiply the write load and saturate
969
+ * the bounded observer buffer, dropping the process's other Sync diagnostics.
970
+ * A retry materializes the run as an error span, so a later successful
971
+ * attempt is traced too (one extra write only for runs that retried) and
972
+ * closes that span as `ok` instead of leaving it `error` forever. Topic
973
+ * redeliveries carry no run id and are dropped as well; the retry span
974
+ * already records them.
975
+ */
976
+
977
+ type SyncRunKind = "queue" | "job" | "topic" | "scheduler" | "pump";
978
+
979
+ const SYNC_CATEGORY: Record<SyncRunKind, TraceCategory> = {
980
+ queue: "job",
981
+ job: "job",
982
+ topic: "sync",
983
+ scheduler: "schedule",
984
+ pump: "backfill",
985
+ };
986
+
987
+ /** Span key of one Sync run. Topic handlers also pass their independent consumer name. */
988
+ const syncSpanKey = (kind: SyncRunKind, resource: string, runId: string, consumer?: string): string =>
989
+ kind === "topic" && consumer !== undefined
990
+ ? `sync:topic:${JSON.stringify([resource, runId, consumer])}`
991
+ : `sync:${kind}:${resource}:${runId}`;
992
+
993
+ const isSyncRunKind = (kind: string): kind is SyncRunKind => kind in SYNC_CATEGORY;
994
+
995
+ const TRACED_SYNC_EVENT_TYPES = new Set([
996
+ "handler_started",
997
+ "handler_settled",
998
+ "dead_letter",
999
+ "redelivery",
1000
+ "pump_run_started",
1001
+ "pump_run_settled",
1002
+ ]);
1003
+
1004
+ /** Whether the adapter records this event at all (see the topic policy above). */
1005
+ const isTracedSyncEvent = (event: SyncEvent): boolean => {
1006
+ if (!event.resource || !event.kind || !isSyncRunKind(event.kind) || !TRACED_SYNC_EVENT_TYPES.has(event.type)) return false;
1007
+ if (event.kind !== "topic") return true;
1008
+ if (event.type === "handler_started" || event.type === "redelivery") return false;
1009
+ if (event.type !== "handler_settled") return true;
1010
+ const status = event.detail?.status;
1011
+ if (status === "retry" || status === "dead_letter") return true;
1012
+ const attempt = event.detail?.attempt;
1013
+ return status === "success" && typeof attempt === "number" && attempt > 1;
1014
+ };
1015
+
1016
+ const detailString = (detail: Record<string, unknown>, key: string): string | undefined => {
1017
+ const value = detail[key];
1018
+ return typeof value === "string" ? value : undefined;
1019
+ };
1020
+
1021
+ const detailNumber = (detail: Record<string, unknown>, key: string): number | undefined => {
1022
+ const value = detail[key];
1023
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1024
+ };
1025
+
1026
+ const detailAttributes = (detail: Record<string, unknown>): TraceAttributes => {
1027
+ const out: TraceAttributes = {};
1028
+ for (const [key, value] of Object.entries(detail)) {
1029
+ if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1030
+ out[`sync.${key}`] = value;
1031
+ }
1032
+ }
1033
+ return out;
1034
+ };
1035
+
1036
+ /** The app hosting this process; Sync events carry no app name. */
1037
+ const processAppId = (): string | undefined => process.env.APP_ID?.trim() || undefined;
1038
+
1039
+ const traceSyncEvent = async (event: SyncEvent, application?: string): Promise<void> => {
1040
+ const resource = event.resource;
1041
+ const kind = event.kind;
1042
+ if (!resource || !kind || !isSyncRunKind(kind) || !isTracedSyncEvent(event)) return;
1043
+ const detail: Record<string, unknown> = event.detail ?? {};
1044
+ const appId = application ?? processAppId();
1045
+ const category = SYNC_CATEGORY[kind];
1046
+ const attributes: TraceAttributes = {
1047
+ "sync.kind": kind,
1048
+ "sync.resource": resource,
1049
+ ...detailAttributes(detail),
1050
+ ...(event.error ? { "sync.error": event.error } : {}),
1051
+ };
1052
+ // Topic dead letters carry the consumer instead of the run key.
1053
+ const key = detailString(detail, "key") ?? (kind === "topic" ? detailString(detail, "consumer") : undefined);
1054
+ const consumer = kind === "topic" ? key : undefined;
1055
+ // Per-schedule and per-consumer names stay readable; job keys are unbounded.
1056
+ const name = (kind === "scheduler" || kind === "topic") && key ? key : resource;
1057
+ const base = { name, source: resource, appId, category, kind: "consumer" as const };
1058
+
1059
+ switch (event.type) {
1060
+ case "handler_started": {
1061
+ const id = detailString(detail, "id");
1062
+ if (!id) return;
1063
+ await start({ ...base, spanKey: syncSpanKey(kind, resource, id, consumer), attributes, startedAt: event.at }, true);
1064
+ return;
1065
+ }
1066
+ case "handler_settled": {
1067
+ const id = detailString(detail, "id");
1068
+ if (!id) return;
1069
+ const spanKey = syncSpanKey(kind, resource, id, consumer);
1070
+ const status = detailString(detail, "status");
1071
+ const attempt = detailNumber(detail, "attempt");
1072
+ const durationMs = detailNumber(detail, "durationMs") ?? 0;
1073
+ if (status === "retry") {
1074
+ // Topic runs have no started span: materialize the failed run first.
1075
+ if (kind === "topic") {
1076
+ await complete(
1077
+ {
1078
+ ...base,
1079
+ spanKey,
1080
+ status: "error",
1081
+ statusMessage: `Retry scheduled after ${attempt ?? "?"} attempt(s)`,
1082
+ attributes,
1083
+ startedAt: event.at.getTime() - durationMs,
1084
+ endedAt: event.at,
1085
+ },
1086
+ true,
1087
+ );
1088
+ }
1089
+ await record(
1090
+ {
1091
+ ...base,
1092
+ spanKey,
1093
+ event: "sync.retry",
1094
+ severity: "warn",
1095
+ status: "error",
1096
+ attributes,
1097
+ occurredAt: event.at,
1098
+ },
1099
+ true,
1100
+ );
1101
+ return;
1102
+ }
1103
+ await complete(
1104
+ {
1105
+ ...base,
1106
+ spanKey,
1107
+ status: status === "success" ? "ok" : "error",
1108
+ statusMessage: status === "success" ? undefined : `Dead-lettered after ${attempt ?? "?"} attempt(s)`,
1109
+ attributes,
1110
+ startedAt: event.at.getTime() - durationMs,
1111
+ endedAt: event.at,
1112
+ },
1113
+ true,
1114
+ );
1115
+ return;
1116
+ }
1117
+ case "dead_letter": {
1118
+ const id = detailString(detail, "messageId") ?? detailString(detail, "eventId");
1119
+ if (!id) return;
1120
+ const spanKey = syncSpanKey(kind, resource, id, consumer);
1121
+ const reason = detailString(detail, "reason") ?? "dead letter";
1122
+ // A message can be dead-lettered without a handler run (attempts
1123
+ // exhausted on delivery, undecodable payload): the upsert closes the
1124
+ // span either way, and the event records the reason.
1125
+ await complete(
1126
+ {
1127
+ ...base,
1128
+ spanKey,
1129
+ status: "error",
1130
+ statusMessage: reason,
1131
+ attributes,
1132
+ startedAt: event.at,
1133
+ endedAt: event.at,
1134
+ },
1135
+ true,
1136
+ );
1137
+ await record({ context: newContext(spanKey), event: "sync.dead_letter", severity: "error", attributes, occurredAt: event.at });
1138
+ return;
1139
+ }
1140
+ case "redelivery": {
1141
+ // Carries no run id: recorded as its own zero-length span next to the runs.
1142
+ await record({ ...base, category: "sync", event: "sync.redelivery", severity: "warn", attributes, occurredAt: event.at });
1143
+ return;
1144
+ }
1145
+ case "pump_run_started": {
1146
+ if (!key) return;
1147
+ await start({ ...base, spanKey: syncSpanKey("pump", resource, key), attributes, startedAt: event.at }, true);
1148
+ return;
1149
+ }
1150
+ case "pump_run_settled": {
1151
+ if (!key) return;
1152
+ const status = detailString(detail, "status");
1153
+ const durationMs = detailNumber(detail, "durationMs") ?? 0;
1154
+ await complete(
1155
+ {
1156
+ ...base,
1157
+ spanKey: syncSpanKey("pump", resource, key),
1158
+ status: status === "failed" ? "error" : "ok",
1159
+ statusMessage: detailString(detail, "error"),
1160
+ attributes,
1161
+ summary: { status, dispatched: detailNumber(detail, "dispatched"), failureCount: detailNumber(detail, "failureCount") },
1162
+ startedAt: event.at.getTime() - durationMs,
1163
+ endedAt: event.at,
1164
+ },
1165
+ true,
1166
+ );
1167
+ return;
1168
+ }
1169
+ default:
1170
+ return;
1171
+ }
1172
+ };
1173
+
1174
+ // Match Sync's event subscription buffer budget. Observer overload drops
1175
+ // diagnostics rather than retaining unbounded SQL promises or delaying jobs.
1176
+ const SYNC_TRACE_PENDING_LIMIT = 1024;
1177
+ const pendingSyncWrites = new Map<string, Promise<void>>();
1178
+ let pendingSyncWriteCount = 0;
1179
+ let reportedSyncOverflow = false;
1180
+
1181
+ /** Ordered per-run, bounded, non-blocking adapter for createSync({ observe }). */
1182
+ export const observeSyncEvent = (event: SyncEvent, application?: string): void => {
1183
+ if (!event.resource || !event.kind || !isSyncRunKind(event.kind) || !isTracedSyncEvent(event)) return;
1184
+ if (pendingSyncWriteCount >= SYNC_TRACE_PENDING_LIMIT) {
1185
+ if (!reportedSyncOverflow) console.error("[logging:trace] sync trace buffer full; dropping diagnostics");
1186
+ reportedSyncOverflow = true;
1187
+ return;
1188
+ }
1189
+ const detail = event.detail ?? {};
1190
+ const id =
1191
+ detailString(detail, "id") ?? detailString(detail, "messageId") ?? detailString(detail, "eventId") ?? detailString(detail, "key") ?? "";
1192
+ const consumer = event.kind === "topic" ? (detailString(detail, "key") ?? detailString(detail, "consumer")) : undefined;
1193
+ const key = syncSpanKey(event.kind, event.resource, id, consumer);
1194
+ pendingSyncWriteCount++;
1195
+ const write = (pendingSyncWrites.get(key) ?? Promise.resolve())
1196
+ .then(() => traceSyncEvent(event, application))
1197
+ .catch((error: unknown) => {
1198
+ console.error("[logging:trace] sync event failed:", error instanceof Error ? error.message : String(error));
1199
+ })
1200
+ .finally(() => {
1201
+ pendingSyncWriteCount--;
1202
+ if (pendingSyncWrites.get(key) === write) pendingSyncWrites.delete(key);
1203
+ if (pendingSyncWriteCount === 0) reportedSyncOverflow = false;
1204
+ });
1205
+ pendingSyncWrites.set(key, write);
1206
+ };
1207
+
1208
+ /** Flush diagnostics after Sync workers stop producing events during shutdown. */
1209
+ export const flushSyncTraceEvents = async (): Promise<void> => {
1210
+ while (pendingSyncWrites.size > 0) await Promise.all(pendingSyncWrites.values());
1211
+ };
1212
+
1213
+ /** Awaitable form of {@link observeSyncEvent} for tests. */
1214
+ export { traceSyncEvent };
1215
+
1216
+ export const trace = {
1217
+ start,
1218
+ complete,
1219
+ record,
1220
+ end,
1221
+ withSpan,
1222
+ list,
1223
+ getSpan,
1224
+ stats,
1225
+ sourceGroups,
1226
+ events,
1227
+ sources,
1228
+ summary,
1229
+ cleanup,
1230
+ syncSpanKey,
1231
+ };