availsync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (460) hide show
  1. package/.adal/skills/stripe-best-practices/SKILL.md +42 -0
  2. package/.adal/skills/stripe-best-practices/references/billing.md +36 -0
  3. package/.adal/skills/stripe-best-practices/references/connect.md +48 -0
  4. package/.adal/skills/stripe-best-practices/references/payments.md +79 -0
  5. package/.adal/skills/stripe-best-practices/references/security.md +109 -0
  6. package/.adal/skills/stripe-best-practices/references/treasury.md +16 -0
  7. package/.adal/skills/stripe-projects/SKILL.md +139 -0
  8. package/.adal/skills/upgrade-stripe/SKILL.md +185 -0
  9. package/.agents/skills/stripe-best-practices/SKILL.md +42 -0
  10. package/.agents/skills/stripe-best-practices/references/billing.md +36 -0
  11. package/.agents/skills/stripe-best-practices/references/connect.md +48 -0
  12. package/.agents/skills/stripe-best-practices/references/payments.md +79 -0
  13. package/.agents/skills/stripe-best-practices/references/security.md +109 -0
  14. package/.agents/skills/stripe-best-practices/references/treasury.md +16 -0
  15. package/.agents/skills/stripe-projects/SKILL.md +139 -0
  16. package/.agents/skills/upgrade-stripe/SKILL.md +185 -0
  17. package/.augment/skills/stripe-best-practices/SKILL.md +42 -0
  18. package/.augment/skills/stripe-best-practices/references/billing.md +36 -0
  19. package/.augment/skills/stripe-best-practices/references/connect.md +48 -0
  20. package/.augment/skills/stripe-best-practices/references/payments.md +79 -0
  21. package/.augment/skills/stripe-best-practices/references/security.md +109 -0
  22. package/.augment/skills/stripe-best-practices/references/treasury.md +16 -0
  23. package/.augment/skills/stripe-projects/SKILL.md +139 -0
  24. package/.augment/skills/upgrade-stripe/SKILL.md +185 -0
  25. package/.bob/skills/stripe-best-practices/SKILL.md +42 -0
  26. package/.bob/skills/stripe-best-practices/references/billing.md +36 -0
  27. package/.bob/skills/stripe-best-practices/references/connect.md +48 -0
  28. package/.bob/skills/stripe-best-practices/references/payments.md +79 -0
  29. package/.bob/skills/stripe-best-practices/references/security.md +109 -0
  30. package/.bob/skills/stripe-best-practices/references/treasury.md +16 -0
  31. package/.bob/skills/stripe-projects/SKILL.md +139 -0
  32. package/.bob/skills/upgrade-stripe/SKILL.md +185 -0
  33. package/.claude/settings.local.json +7 -0
  34. package/.claude/skills/stripe-best-practices/SKILL.md +42 -0
  35. package/.claude/skills/stripe-best-practices/references/billing.md +36 -0
  36. package/.claude/skills/stripe-best-practices/references/connect.md +48 -0
  37. package/.claude/skills/stripe-best-practices/references/payments.md +79 -0
  38. package/.claude/skills/stripe-best-practices/references/security.md +109 -0
  39. package/.claude/skills/stripe-best-practices/references/treasury.md +16 -0
  40. package/.claude/skills/stripe-projects/SKILL.md +139 -0
  41. package/.claude/skills/upgrade-stripe/SKILL.md +185 -0
  42. package/.codebuddy/skills/stripe-best-practices/SKILL.md +42 -0
  43. package/.codebuddy/skills/stripe-best-practices/references/billing.md +36 -0
  44. package/.codebuddy/skills/stripe-best-practices/references/connect.md +48 -0
  45. package/.codebuddy/skills/stripe-best-practices/references/payments.md +79 -0
  46. package/.codebuddy/skills/stripe-best-practices/references/security.md +109 -0
  47. package/.codebuddy/skills/stripe-best-practices/references/treasury.md +16 -0
  48. package/.codebuddy/skills/stripe-projects/SKILL.md +139 -0
  49. package/.codebuddy/skills/upgrade-stripe/SKILL.md +185 -0
  50. package/.commandcode/skills/stripe-best-practices/SKILL.md +42 -0
  51. package/.commandcode/skills/stripe-best-practices/references/billing.md +36 -0
  52. package/.commandcode/skills/stripe-best-practices/references/connect.md +48 -0
  53. package/.commandcode/skills/stripe-best-practices/references/payments.md +79 -0
  54. package/.commandcode/skills/stripe-best-practices/references/security.md +109 -0
  55. package/.commandcode/skills/stripe-best-practices/references/treasury.md +16 -0
  56. package/.commandcode/skills/stripe-projects/SKILL.md +139 -0
  57. package/.commandcode/skills/upgrade-stripe/SKILL.md +185 -0
  58. package/.continue/skills/stripe-best-practices/SKILL.md +42 -0
  59. package/.continue/skills/stripe-best-practices/references/billing.md +36 -0
  60. package/.continue/skills/stripe-best-practices/references/connect.md +48 -0
  61. package/.continue/skills/stripe-best-practices/references/payments.md +79 -0
  62. package/.continue/skills/stripe-best-practices/references/security.md +109 -0
  63. package/.continue/skills/stripe-best-practices/references/treasury.md +16 -0
  64. package/.continue/skills/stripe-projects/SKILL.md +139 -0
  65. package/.continue/skills/upgrade-stripe/SKILL.md +185 -0
  66. package/.cortex/skills/stripe-best-practices/SKILL.md +42 -0
  67. package/.cortex/skills/stripe-best-practices/references/billing.md +36 -0
  68. package/.cortex/skills/stripe-best-practices/references/connect.md +48 -0
  69. package/.cortex/skills/stripe-best-practices/references/payments.md +79 -0
  70. package/.cortex/skills/stripe-best-practices/references/security.md +109 -0
  71. package/.cortex/skills/stripe-best-practices/references/treasury.md +16 -0
  72. package/.cortex/skills/stripe-projects/SKILL.md +139 -0
  73. package/.cortex/skills/upgrade-stripe/SKILL.md +185 -0
  74. package/.crush/skills/stripe-best-practices/SKILL.md +42 -0
  75. package/.crush/skills/stripe-best-practices/references/billing.md +36 -0
  76. package/.crush/skills/stripe-best-practices/references/connect.md +48 -0
  77. package/.crush/skills/stripe-best-practices/references/payments.md +79 -0
  78. package/.crush/skills/stripe-best-practices/references/security.md +109 -0
  79. package/.crush/skills/stripe-best-practices/references/treasury.md +16 -0
  80. package/.crush/skills/stripe-projects/SKILL.md +139 -0
  81. package/.crush/skills/upgrade-stripe/SKILL.md +185 -0
  82. package/.env.example +20 -0
  83. package/.factory/skills/stripe-best-practices/SKILL.md +42 -0
  84. package/.factory/skills/stripe-best-practices/references/billing.md +36 -0
  85. package/.factory/skills/stripe-best-practices/references/connect.md +48 -0
  86. package/.factory/skills/stripe-best-practices/references/payments.md +79 -0
  87. package/.factory/skills/stripe-best-practices/references/security.md +109 -0
  88. package/.factory/skills/stripe-best-practices/references/treasury.md +16 -0
  89. package/.factory/skills/stripe-projects/SKILL.md +139 -0
  90. package/.factory/skills/upgrade-stripe/SKILL.md +185 -0
  91. package/.goose/skills/stripe-best-practices/SKILL.md +42 -0
  92. package/.goose/skills/stripe-best-practices/references/billing.md +36 -0
  93. package/.goose/skills/stripe-best-practices/references/connect.md +48 -0
  94. package/.goose/skills/stripe-best-practices/references/payments.md +79 -0
  95. package/.goose/skills/stripe-best-practices/references/security.md +109 -0
  96. package/.goose/skills/stripe-best-practices/references/treasury.md +16 -0
  97. package/.goose/skills/stripe-projects/SKILL.md +139 -0
  98. package/.goose/skills/upgrade-stripe/SKILL.md +185 -0
  99. package/.iflow/skills/stripe-best-practices/SKILL.md +42 -0
  100. package/.iflow/skills/stripe-best-practices/references/billing.md +36 -0
  101. package/.iflow/skills/stripe-best-practices/references/connect.md +48 -0
  102. package/.iflow/skills/stripe-best-practices/references/payments.md +79 -0
  103. package/.iflow/skills/stripe-best-practices/references/security.md +109 -0
  104. package/.iflow/skills/stripe-best-practices/references/treasury.md +16 -0
  105. package/.iflow/skills/stripe-projects/SKILL.md +139 -0
  106. package/.iflow/skills/upgrade-stripe/SKILL.md +185 -0
  107. package/.junie/skills/stripe-best-practices/SKILL.md +42 -0
  108. package/.junie/skills/stripe-best-practices/references/billing.md +36 -0
  109. package/.junie/skills/stripe-best-practices/references/connect.md +48 -0
  110. package/.junie/skills/stripe-best-practices/references/payments.md +79 -0
  111. package/.junie/skills/stripe-best-practices/references/security.md +109 -0
  112. package/.junie/skills/stripe-best-practices/references/treasury.md +16 -0
  113. package/.junie/skills/stripe-projects/SKILL.md +139 -0
  114. package/.junie/skills/upgrade-stripe/SKILL.md +185 -0
  115. package/.kilocode/skills/stripe-best-practices/SKILL.md +42 -0
  116. package/.kilocode/skills/stripe-best-practices/references/billing.md +36 -0
  117. package/.kilocode/skills/stripe-best-practices/references/connect.md +48 -0
  118. package/.kilocode/skills/stripe-best-practices/references/payments.md +79 -0
  119. package/.kilocode/skills/stripe-best-practices/references/security.md +109 -0
  120. package/.kilocode/skills/stripe-best-practices/references/treasury.md +16 -0
  121. package/.kilocode/skills/stripe-projects/SKILL.md +139 -0
  122. package/.kilocode/skills/upgrade-stripe/SKILL.md +185 -0
  123. package/.kiro/skills/stripe-best-practices/SKILL.md +42 -0
  124. package/.kiro/skills/stripe-best-practices/references/billing.md +36 -0
  125. package/.kiro/skills/stripe-best-practices/references/connect.md +48 -0
  126. package/.kiro/skills/stripe-best-practices/references/payments.md +79 -0
  127. package/.kiro/skills/stripe-best-practices/references/security.md +109 -0
  128. package/.kiro/skills/stripe-best-practices/references/treasury.md +16 -0
  129. package/.kiro/skills/stripe-projects/SKILL.md +139 -0
  130. package/.kiro/skills/upgrade-stripe/SKILL.md +185 -0
  131. package/.kode/skills/stripe-best-practices/SKILL.md +42 -0
  132. package/.kode/skills/stripe-best-practices/references/billing.md +36 -0
  133. package/.kode/skills/stripe-best-practices/references/connect.md +48 -0
  134. package/.kode/skills/stripe-best-practices/references/payments.md +79 -0
  135. package/.kode/skills/stripe-best-practices/references/security.md +109 -0
  136. package/.kode/skills/stripe-best-practices/references/treasury.md +16 -0
  137. package/.kode/skills/stripe-projects/SKILL.md +139 -0
  138. package/.kode/skills/upgrade-stripe/SKILL.md +185 -0
  139. package/.mcpjam/skills/stripe-best-practices/SKILL.md +42 -0
  140. package/.mcpjam/skills/stripe-best-practices/references/billing.md +36 -0
  141. package/.mcpjam/skills/stripe-best-practices/references/connect.md +48 -0
  142. package/.mcpjam/skills/stripe-best-practices/references/payments.md +79 -0
  143. package/.mcpjam/skills/stripe-best-practices/references/security.md +109 -0
  144. package/.mcpjam/skills/stripe-best-practices/references/treasury.md +16 -0
  145. package/.mcpjam/skills/stripe-projects/SKILL.md +139 -0
  146. package/.mcpjam/skills/upgrade-stripe/SKILL.md +185 -0
  147. package/.mux/skills/stripe-best-practices/SKILL.md +42 -0
  148. package/.mux/skills/stripe-best-practices/references/billing.md +36 -0
  149. package/.mux/skills/stripe-best-practices/references/connect.md +48 -0
  150. package/.mux/skills/stripe-best-practices/references/payments.md +79 -0
  151. package/.mux/skills/stripe-best-practices/references/security.md +109 -0
  152. package/.mux/skills/stripe-best-practices/references/treasury.md +16 -0
  153. package/.mux/skills/stripe-projects/SKILL.md +139 -0
  154. package/.mux/skills/upgrade-stripe/SKILL.md +185 -0
  155. package/.neovate/skills/stripe-best-practices/SKILL.md +42 -0
  156. package/.neovate/skills/stripe-best-practices/references/billing.md +36 -0
  157. package/.neovate/skills/stripe-best-practices/references/connect.md +48 -0
  158. package/.neovate/skills/stripe-best-practices/references/payments.md +79 -0
  159. package/.neovate/skills/stripe-best-practices/references/security.md +109 -0
  160. package/.neovate/skills/stripe-best-practices/references/treasury.md +16 -0
  161. package/.neovate/skills/stripe-projects/SKILL.md +139 -0
  162. package/.neovate/skills/upgrade-stripe/SKILL.md +185 -0
  163. package/.nixpacksignore +14 -0
  164. package/.openhands/skills/stripe-best-practices/SKILL.md +42 -0
  165. package/.openhands/skills/stripe-best-practices/references/billing.md +36 -0
  166. package/.openhands/skills/stripe-best-practices/references/connect.md +48 -0
  167. package/.openhands/skills/stripe-best-practices/references/payments.md +79 -0
  168. package/.openhands/skills/stripe-best-practices/references/security.md +109 -0
  169. package/.openhands/skills/stripe-best-practices/references/treasury.md +16 -0
  170. package/.openhands/skills/stripe-projects/SKILL.md +139 -0
  171. package/.openhands/skills/upgrade-stripe/SKILL.md +185 -0
  172. package/.pi/skills/stripe-best-practices/SKILL.md +42 -0
  173. package/.pi/skills/stripe-best-practices/references/billing.md +36 -0
  174. package/.pi/skills/stripe-best-practices/references/connect.md +48 -0
  175. package/.pi/skills/stripe-best-practices/references/payments.md +79 -0
  176. package/.pi/skills/stripe-best-practices/references/security.md +109 -0
  177. package/.pi/skills/stripe-best-practices/references/treasury.md +16 -0
  178. package/.pi/skills/stripe-projects/SKILL.md +139 -0
  179. package/.pi/skills/upgrade-stripe/SKILL.md +185 -0
  180. package/.pochi/skills/stripe-best-practices/SKILL.md +42 -0
  181. package/.pochi/skills/stripe-best-practices/references/billing.md +36 -0
  182. package/.pochi/skills/stripe-best-practices/references/connect.md +48 -0
  183. package/.pochi/skills/stripe-best-practices/references/payments.md +79 -0
  184. package/.pochi/skills/stripe-best-practices/references/security.md +109 -0
  185. package/.pochi/skills/stripe-best-practices/references/treasury.md +16 -0
  186. package/.pochi/skills/stripe-projects/SKILL.md +139 -0
  187. package/.pochi/skills/upgrade-stripe/SKILL.md +185 -0
  188. package/.qoder/skills/stripe-best-practices/SKILL.md +42 -0
  189. package/.qoder/skills/stripe-best-practices/references/billing.md +36 -0
  190. package/.qoder/skills/stripe-best-practices/references/connect.md +48 -0
  191. package/.qoder/skills/stripe-best-practices/references/payments.md +79 -0
  192. package/.qoder/skills/stripe-best-practices/references/security.md +109 -0
  193. package/.qoder/skills/stripe-best-practices/references/treasury.md +16 -0
  194. package/.qoder/skills/stripe-projects/SKILL.md +139 -0
  195. package/.qoder/skills/upgrade-stripe/SKILL.md +185 -0
  196. package/.qwen/skills/stripe-best-practices/SKILL.md +42 -0
  197. package/.qwen/skills/stripe-best-practices/references/billing.md +36 -0
  198. package/.qwen/skills/stripe-best-practices/references/connect.md +48 -0
  199. package/.qwen/skills/stripe-best-practices/references/payments.md +79 -0
  200. package/.qwen/skills/stripe-best-practices/references/security.md +109 -0
  201. package/.qwen/skills/stripe-best-practices/references/treasury.md +16 -0
  202. package/.qwen/skills/stripe-projects/SKILL.md +139 -0
  203. package/.qwen/skills/upgrade-stripe/SKILL.md +185 -0
  204. package/.roo/skills/stripe-best-practices/SKILL.md +42 -0
  205. package/.roo/skills/stripe-best-practices/references/billing.md +36 -0
  206. package/.roo/skills/stripe-best-practices/references/connect.md +48 -0
  207. package/.roo/skills/stripe-best-practices/references/payments.md +79 -0
  208. package/.roo/skills/stripe-best-practices/references/security.md +109 -0
  209. package/.roo/skills/stripe-best-practices/references/treasury.md +16 -0
  210. package/.roo/skills/stripe-projects/SKILL.md +139 -0
  211. package/.roo/skills/upgrade-stripe/SKILL.md +185 -0
  212. package/.trae/skills/stripe-best-practices/SKILL.md +42 -0
  213. package/.trae/skills/stripe-best-practices/references/billing.md +36 -0
  214. package/.trae/skills/stripe-best-practices/references/connect.md +48 -0
  215. package/.trae/skills/stripe-best-practices/references/payments.md +79 -0
  216. package/.trae/skills/stripe-best-practices/references/security.md +109 -0
  217. package/.trae/skills/stripe-best-practices/references/treasury.md +16 -0
  218. package/.trae/skills/stripe-projects/SKILL.md +139 -0
  219. package/.trae/skills/upgrade-stripe/SKILL.md +185 -0
  220. package/.vibe/skills/stripe-best-practices/SKILL.md +42 -0
  221. package/.vibe/skills/stripe-best-practices/references/billing.md +36 -0
  222. package/.vibe/skills/stripe-best-practices/references/connect.md +48 -0
  223. package/.vibe/skills/stripe-best-practices/references/payments.md +79 -0
  224. package/.vibe/skills/stripe-best-practices/references/security.md +109 -0
  225. package/.vibe/skills/stripe-best-practices/references/treasury.md +16 -0
  226. package/.vibe/skills/stripe-projects/SKILL.md +139 -0
  227. package/.vibe/skills/upgrade-stripe/SKILL.md +185 -0
  228. package/.windsurf/skills/stripe-best-practices/SKILL.md +42 -0
  229. package/.windsurf/skills/stripe-best-practices/references/billing.md +36 -0
  230. package/.windsurf/skills/stripe-best-practices/references/connect.md +48 -0
  231. package/.windsurf/skills/stripe-best-practices/references/payments.md +79 -0
  232. package/.windsurf/skills/stripe-best-practices/references/security.md +109 -0
  233. package/.windsurf/skills/stripe-best-practices/references/treasury.md +16 -0
  234. package/.windsurf/skills/stripe-projects/SKILL.md +139 -0
  235. package/.windsurf/skills/upgrade-stripe/SKILL.md +185 -0
  236. package/.zencoder/skills/stripe-best-practices/SKILL.md +42 -0
  237. package/.zencoder/skills/stripe-best-practices/references/billing.md +36 -0
  238. package/.zencoder/skills/stripe-best-practices/references/connect.md +48 -0
  239. package/.zencoder/skills/stripe-best-practices/references/payments.md +79 -0
  240. package/.zencoder/skills/stripe-best-practices/references/security.md +109 -0
  241. package/.zencoder/skills/stripe-best-practices/references/treasury.md +16 -0
  242. package/.zencoder/skills/stripe-projects/SKILL.md +139 -0
  243. package/.zencoder/skills/upgrade-stripe/SKILL.md +185 -0
  244. package/AUDIT.md +95 -0
  245. package/BLOCKERS.md +0 -0
  246. package/COOLIFY.md +51 -0
  247. package/MCP_SETUP.md +23 -0
  248. package/PRODUCTION_CHECKLIST.md +246 -0
  249. package/README.md +47 -0
  250. package/ROADMAP.md +91 -0
  251. package/docs/superpowers/plans/2026-05-11-availsync-frontend-sales-flow.md +2445 -0
  252. package/frontend/.env.example +2 -0
  253. package/frontend/app/admin/layout.tsx +13 -0
  254. package/frontend/app/admin/page.tsx +747 -0
  255. package/frontend/app/app/activity/page.tsx +257 -0
  256. package/frontend/app/app/agents/[agentId]/page.tsx +21 -0
  257. package/frontend/app/app/agents/page.tsx +1155 -0
  258. package/frontend/app/app/audit/page.tsx +225 -0
  259. package/frontend/app/app/availability/page.tsx +840 -0
  260. package/frontend/app/app/holds/page.tsx +262 -0
  261. package/frontend/app/app/layout.tsx +19 -0
  262. package/frontend/app/app/onboarding/page.tsx +10 -0
  263. package/frontend/app/app/onboarding/verify/page.tsx +309 -0
  264. package/frontend/app/app/page.tsx +508 -0
  265. package/frontend/app/app/settings/page.tsx +399 -0
  266. package/frontend/app/app/work/page.tsx +426 -0
  267. package/frontend/app/changelog/page.tsx +93 -0
  268. package/frontend/app/checkout/page.tsx +25 -0
  269. package/frontend/app/docs/api/page.tsx +157 -0
  270. package/frontend/app/docs/page.tsx +296 -0
  271. package/frontend/app/docs/pilot/page.tsx +127 -0
  272. package/frontend/app/docs/quickstart/page.tsx +318 -0
  273. package/frontend/app/docs/reliability/page.tsx +78 -0
  274. package/frontend/app/docs/sdk/node/page.tsx +166 -0
  275. package/frontend/app/globals.css +57 -0
  276. package/frontend/app/icon.png +0 -0
  277. package/frontend/app/layout.tsx +87 -0
  278. package/frontend/app/login/page.tsx +14 -0
  279. package/frontend/app/page.tsx +47 -0
  280. package/frontend/app/pricing/page.tsx +66 -0
  281. package/frontend/app/privacy/page.tsx +52 -0
  282. package/frontend/app/robots.ts +26 -0
  283. package/frontend/app/security/page.tsx +74 -0
  284. package/frontend/app/signup/page.tsx +14 -0
  285. package/frontend/app/sitemap.ts +14 -0
  286. package/frontend/app/terms/page.tsx +51 -0
  287. package/frontend/components/brand/AvailsyncLogo.tsx +56 -0
  288. package/frontend/components/checkout/CheckoutClient.tsx +100 -0
  289. package/frontend/components/dashboard/AgentForm.tsx +59 -0
  290. package/frontend/components/dashboard/AppShell.tsx +291 -0
  291. package/frontend/components/dashboard/AvailabilityChecker.tsx +117 -0
  292. package/frontend/components/dashboard/AvailabilityWindowForm.tsx +40 -0
  293. package/frontend/components/dashboard/HoldForm.tsx +133 -0
  294. package/frontend/components/dashboard/MetricCard.tsx +10 -0
  295. package/frontend/components/login/LoginForm.tsx +95 -0
  296. package/frontend/components/marketing/AgentCoordinationStory.tsx +1530 -0
  297. package/frontend/components/marketing/Faq.tsx +41 -0
  298. package/frontend/components/marketing/Hero.tsx +73 -0
  299. package/frontend/components/marketing/HowItWorks.tsx +28 -0
  300. package/frontend/components/marketing/ObserveModeTeaser.tsx +41 -0
  301. package/frontend/components/marketing/PricingTeaser.tsx +23 -0
  302. package/frontend/components/marketing/ProblemSolution.tsx +36 -0
  303. package/frontend/components/marketing/SiteFooter.tsx +59 -0
  304. package/frontend/components/marketing/SiteHeader.tsx +45 -0
  305. package/frontend/components/marketing/UseCases.tsx +27 -0
  306. package/frontend/components/onboarding/OnboardingClient.tsx +278 -0
  307. package/frontend/components/pricing/PricingCards.tsx +65 -0
  308. package/frontend/components/privacy/CookieConsent.tsx +230 -0
  309. package/frontend/components/privacy/CookieSettingsButton.tsx +15 -0
  310. package/frontend/components/seo/JsonLd.tsx +10 -0
  311. package/frontend/components/signup/SignupForm.tsx +55 -0
  312. package/frontend/components/ui/Badge.tsx +23 -0
  313. package/frontend/components/ui/Button.tsx +37 -0
  314. package/frontend/components/ui/Card.tsx +11 -0
  315. package/frontend/components/ui/ConfirmDialog.tsx +77 -0
  316. package/frontend/components/ui/EmptyState.tsx +24 -0
  317. package/frontend/components/ui/Input.tsx +14 -0
  318. package/frontend/components/ui/KeyDisplay.tsx +49 -0
  319. package/frontend/components/ui/Select.tsx +14 -0
  320. package/frontend/components/ui/Skeleton.tsx +24 -0
  321. package/frontend/components/ui/Tabs.tsx +19 -0
  322. package/frontend/components/ui/Textarea.tsx +14 -0
  323. package/frontend/components/ui/Toast.tsx +78 -0
  324. package/frontend/components/waitlist/WaitlistDialog.tsx +128 -0
  325. package/frontend/lib/api.ts +1282 -0
  326. package/frontend/lib/billing.ts +6 -0
  327. package/frontend/lib/cookieConsent.ts +113 -0
  328. package/frontend/lib/format.ts +16 -0
  329. package/frontend/lib/plans.ts +62 -0
  330. package/frontend/lib/schemas.ts +108 -0
  331. package/frontend/lib/seo.ts +376 -0
  332. package/frontend/lib/setupGuides.ts +630 -0
  333. package/frontend/lib/storage.ts +30 -0
  334. package/frontend/next-env.d.ts +6 -0
  335. package/frontend/next.config.mjs +13 -0
  336. package/frontend/package-lock.json +14409 -0
  337. package/frontend/package.json +41 -0
  338. package/frontend/playwright.config.ts +20 -0
  339. package/frontend/postcss.config.mjs +8 -0
  340. package/frontend/public/.gitkeep +0 -0
  341. package/frontend/public/brand/availsync-logo-board.png +0 -0
  342. package/frontend/public/brand/availsync-logo-dark.png +0 -0
  343. package/frontend/public/brand/availsync-mark-dark.png +0 -0
  344. package/frontend/public/brand/availsync-wordmark-dark.png +0 -0
  345. package/frontend/public/marketing/hero-agent-coordination.png +0 -0
  346. package/frontend/tailwind.config.ts +53 -0
  347. package/frontend/tests/smoke.spec.ts +89 -0
  348. package/frontend/tsconfig.json +23 -0
  349. package/jest.config.js +7 -0
  350. package/nixpacks.toml +11 -0
  351. package/package.json +53 -0
  352. package/packages/mcp/LICENSE +21 -0
  353. package/packages/mcp/README.md +60 -0
  354. package/packages/mcp/jest.config.cjs +8 -0
  355. package/packages/mcp/package.json +54 -0
  356. package/packages/mcp/src/helpers.ts +38 -0
  357. package/packages/mcp/src/index.test.ts +60 -0
  358. package/packages/mcp/src/index.ts +387 -0
  359. package/packages/mcp/tsconfig.json +20 -0
  360. package/packages/mcp/tsconfig.test.json +12 -0
  361. package/packages/node/LICENSE +21 -0
  362. package/packages/node/README.md +120 -0
  363. package/packages/node/jest.config.cjs +8 -0
  364. package/packages/node/package.json +46 -0
  365. package/packages/node/src/index.test.ts +360 -0
  366. package/packages/node/src/index.ts +402 -0
  367. package/packages/node/tsconfig.json +20 -0
  368. package/packages/node/tsconfig.test.json +12 -0
  369. package/plan.md +923 -0
  370. package/skills/stripe-best-practices/SKILL.md +42 -0
  371. package/skills/stripe-best-practices/references/billing.md +36 -0
  372. package/skills/stripe-best-practices/references/connect.md +48 -0
  373. package/skills/stripe-best-practices/references/payments.md +79 -0
  374. package/skills/stripe-best-practices/references/security.md +109 -0
  375. package/skills/stripe-best-practices/references/treasury.md +16 -0
  376. package/skills/stripe-projects/SKILL.md +139 -0
  377. package/skills/upgrade-stripe/SKILL.md +185 -0
  378. package/skills-lock.json +20 -0
  379. package/src/core/availability.ts +178 -0
  380. package/src/core/conflict.ts +209 -0
  381. package/src/core/work.ts +490 -0
  382. package/src/db/client.ts +17 -0
  383. package/src/db/migrations/001_init.sql +88 -0
  384. package/src/db/migrations/002_stripe.sql +2 -0
  385. package/src/db/migrations/003_workspace_auth.sql +19 -0
  386. package/src/db/migrations/004_agent_mcp_status.sql +2 -0
  387. package/src/db/migrations/005_hold_event_actor.sql +4 -0
  388. package/src/db/migrations/006_agent_activity.sql +35 -0
  389. package/src/db/migrations/007_work_coordination.sql +60 -0
  390. package/src/db/migrations/008_work_claim_leases.sql +20 -0
  391. package/src/db/migrations/009_billing_subscription_state.sql +23 -0
  392. package/src/db/migrations/010_agent_api_key_prefix.sql +10 -0
  393. package/src/db/migrations/011_org_verified_and_work_event_retention.sql +11 -0
  394. package/src/db/migrations/012_agent_enforcement_mode.sql +12 -0
  395. package/src/db/migrations/013_support_tickets.sql +21 -0
  396. package/src/db/migrations/014_paid_plan_waitlist.sql +23 -0
  397. package/src/db/migrations/015_agent_last_seen.sql +2 -0
  398. package/src/db/migrations.ts +164 -0
  399. package/src/db/run-migrations.ts +13 -0
  400. package/src/index.ts +183 -0
  401. package/src/lib/activity.ts +137 -0
  402. package/src/lib/apiKeys.ts +32 -0
  403. package/src/lib/appInfo.ts +26 -0
  404. package/src/lib/billingConfig.ts +3 -0
  405. package/src/lib/env.ts +75 -0
  406. package/src/lib/logger.ts +8 -0
  407. package/src/lib/plans.ts +204 -0
  408. package/src/mcp/server.js +5 -0
  409. package/src/mcp/server.ts +350 -0
  410. package/src/middleware/auth.ts +342 -0
  411. package/src/middleware/requestId.ts +16 -0
  412. package/src/routes/account.ts +168 -0
  413. package/src/routes/activity.ts +126 -0
  414. package/src/routes/admin.ts +514 -0
  415. package/src/routes/audit.ts +68 -0
  416. package/src/routes/auth.ts +203 -0
  417. package/src/routes/availability.ts +325 -0
  418. package/src/routes/billing.ts +406 -0
  419. package/src/routes/conflicts.ts +131 -0
  420. package/src/routes/holds.ts +437 -0
  421. package/src/routes/mcp.ts +57 -0
  422. package/src/routes/metrics.ts +39 -0
  423. package/src/routes/onboarding.ts +273 -0
  424. package/src/routes/orgs.ts +981 -0
  425. package/src/routes/preferences.ts +132 -0
  426. package/src/routes/session.ts +16 -0
  427. package/src/routes/support.ts +77 -0
  428. package/src/routes/value.ts +186 -0
  429. package/src/routes/waitlist.ts +63 -0
  430. package/src/routes/work.ts +1578 -0
  431. package/src/server.ts +36 -0
  432. package/src/types/index.ts +109 -0
  433. package/tests/integration/activity.route.test.ts +103 -0
  434. package/tests/integration/admin.route.test.ts +143 -0
  435. package/tests/integration/agent-keys.route.test.ts +237 -0
  436. package/tests/integration/availability.route.test.ts +125 -0
  437. package/tests/integration/billing.route.test.ts +393 -0
  438. package/tests/integration/conflicts.route.test.ts +131 -0
  439. package/tests/integration/flows.test.ts +154 -0
  440. package/tests/integration/helpers.ts +134 -0
  441. package/tests/integration/holds.route.test.ts +185 -0
  442. package/tests/integration/metrics.route.test.ts +100 -0
  443. package/tests/integration/onboarding.verify.route.test.ts +163 -0
  444. package/tests/integration/preferences.route.test.ts +53 -0
  445. package/tests/integration/session.route.test.ts +97 -0
  446. package/tests/integration/system.route.test.ts +92 -0
  447. package/tests/integration/value.route.test.ts +235 -0
  448. package/tests/integration/work.route.test.ts +745 -0
  449. package/tests/setup.ts +4 -0
  450. package/tests/smoke.sh +62 -0
  451. package/tests/unit/auth.test.ts +114 -0
  452. package/tests/unit/availability.test.ts +149 -0
  453. package/tests/unit/conflict.test.ts +118 -0
  454. package/tests/unit/env.test.ts +69 -0
  455. package/tests/unit/migrations.test.ts +135 -0
  456. package/tests/unit/request-id.test.ts +37 -0
  457. package/tmp-mobile-agents.png +0 -0
  458. package/tmp-next-mobile.err.log +10 -0
  459. package/tmp-next-mobile.log +5 -0
  460. package/tsconfig.json +16 -0
@@ -0,0 +1,1578 @@
1
+ import express from 'express';
2
+ import { z } from 'zod';
3
+ import type { Pool, PoolClient } from 'pg';
4
+ import pool from '../db/client';
5
+ import { log } from '../lib/logger';
6
+ import { actorFromAuth, durationSince, logActivity } from '../lib/activity';
7
+ import { enforcePlanLimit, getPlanUsage } from '../lib/plans';
8
+ import {
9
+ canAccessAgent,
10
+ isAgentAuth,
11
+ requireWorkspaceOrAgent,
12
+ type AnyAuthenticatedRequest,
13
+ } from '../middleware/auth';
14
+ import {
15
+ DEFAULT_WORK_LEASE_MINUTES,
16
+ MAX_WORK_EXTENSION_MINUTES,
17
+ MAX_WORK_LOCK_MINUTES,
18
+ claimWorkSlot,
19
+ expireOldWorkClaims,
20
+ initialWorkExpiresAt,
21
+ maxWorkExpiresAt,
22
+ previewWorkClaim,
23
+ secondsUntilExpiry,
24
+ upsertWorkResource,
25
+ workWindow,
26
+ } from '../core/work';
27
+ import type { WorkClaim, WorkResource } from '../types';
28
+
29
+ const router = express.Router();
30
+
31
+ const WorkRequestSchema = z.object({
32
+ agent_id: z.string().uuid(),
33
+ resource_type: z.enum(['project', 'repo']),
34
+ resource_key: z.string().min(1).max(300),
35
+ label: z.string().min(1).max(300).optional(),
36
+ start_at: z.string().datetime({ offset: true }).optional(),
37
+ duration_minutes: z.coerce.number().int().min(1).max(24 * 60).optional().default(45),
38
+ reason: z.string().max(500).optional(),
39
+ metadata: z.record(z.string(), z.unknown()).optional(),
40
+ });
41
+
42
+ const WorkRunStartSchema = WorkRequestSchema.extend({
43
+ idempotency_key: z.string().trim().min(1).max(200).optional(),
44
+ client_name: z.string().trim().min(1).max(100).optional(),
45
+ });
46
+
47
+ const WorkClaimsQuerySchema = z.object({
48
+ agent_id: z.string().uuid().optional(),
49
+ resource_type: z.enum(['project', 'repo']).optional(),
50
+ resource_key: z.string().min(1).max(300).optional(),
51
+ status: z.enum(['active', 'superseded', 'released', 'expired', 'blocked']).optional().default('active'),
52
+ limit: z.coerce.number().int().min(1).max(200).optional().default(100),
53
+ });
54
+
55
+ const ExtendWorkClaimSchema = z.object({
56
+ duration_minutes: z.coerce
57
+ .number()
58
+ .int()
59
+ .min(1)
60
+ .max(MAX_WORK_EXTENSION_MINUTES)
61
+ .optional()
62
+ .default(DEFAULT_WORK_LEASE_MINUTES),
63
+ });
64
+
65
+ const RunExtendWorkClaimSchema = ExtendWorkClaimSchema.extend({
66
+ claim_id: z.string().uuid(),
67
+ client_name: z.string().trim().min(1).max(100).optional(),
68
+ });
69
+
70
+ const RunFinishWorkClaimSchema = z.object({
71
+ claim_id: z.string().uuid(),
72
+ outcome: z.string().trim().min(1).max(100).optional(),
73
+ reason: z.string().trim().max(500).optional(),
74
+ metadata: z.record(z.string(), z.unknown()).optional(),
75
+ client_name: z.string().trim().min(1).max(100).optional(),
76
+ });
77
+
78
+ const IdempotencyKeySchema = z.string().trim().min(1).max(200);
79
+
80
+ async function getAgentInOrg(agentId: string, orgId: string) {
81
+ const result = await pool.query('SELECT * FROM agents WHERE id = $1 AND org_id = $2', [agentId, orgId]);
82
+ return result.rows[0];
83
+ }
84
+
85
+ function explainWorkResult(status: 'available' | 'would_win' | 'would_lose', resourceKey: string) {
86
+ if (status === 'available') {
87
+ return `No active work claim is blocking ${resourceKey}.`;
88
+ }
89
+ if (status === 'would_win') {
90
+ return `This agent has priority and would supersede lower-priority work on ${resourceKey}.`;
91
+ }
92
+ return `Another active work claim has priority on ${resourceKey}. The automation should skip with this reason.`;
93
+ }
94
+
95
+ async function findWorkResource(input: {
96
+ org_id: string;
97
+ resource_type: string;
98
+ resource_key: string;
99
+ }): Promise<WorkResource | null> {
100
+ const result = await pool.query<WorkResource>(
101
+ `SELECT *
102
+ FROM work_resources
103
+ WHERE org_id = $1
104
+ AND resource_type = $2
105
+ AND resource_key = $3`,
106
+ [input.org_id, input.resource_type, input.resource_key],
107
+ );
108
+ return result.rows[0] ?? null;
109
+ }
110
+
111
+ function publicResource(resource: WorkResource | null, input: z.infer<typeof WorkRequestSchema>) {
112
+ return {
113
+ id: resource?.id ?? null,
114
+ resource_type: resource?.resource_type ?? input.resource_type,
115
+ resource_key: resource?.resource_key ?? input.resource_key,
116
+ label: resource?.label ?? input.label ?? input.resource_key,
117
+ };
118
+ }
119
+
120
+ function publicClaim<T extends Record<string, unknown>>(claim: T): Omit<T, 'idempotency_key'> & {
121
+ seconds_until_expiry: number | null;
122
+ } {
123
+ const { idempotency_key: _idempotencyKey, ...rest } = claim;
124
+ const expiresAt = rest.expires_at ? new Date(String(rest.expires_at)) : null;
125
+ return {
126
+ ...rest,
127
+ seconds_until_expiry: expiresAt ? secondsUntilExpiry(expiresAt) : null,
128
+ };
129
+ }
130
+
131
+ function automationMetadata(metadata?: Record<string, unknown>) {
132
+ return {
133
+ ...(metadata ?? {}),
134
+ source: 'automation_guardrail',
135
+ };
136
+ }
137
+
138
+ function parseIdempotencyKey(headerValue: string | undefined, bodyValue?: string | null) {
139
+ if (headerValue) {
140
+ const parsed = IdempotencyKeySchema.safeParse(headerValue);
141
+ return parsed.success ? { key: headerValue.trim() } : { error: 'invalid_idempotency_key' };
142
+ }
143
+ if (bodyValue) {
144
+ const parsed = IdempotencyKeySchema.safeParse(bodyValue);
145
+ return parsed.success ? { key: bodyValue.trim() } : { error: 'invalid_idempotency_key' };
146
+ }
147
+ return { key: null };
148
+ }
149
+
150
+ async function insertWorkEvent(input: {
151
+ db?: Pool | PoolClient;
152
+ claim_id?: string | null;
153
+ event_type: 'created' | 'blocked' | 'superseded' | 'released' | 'expired' | 'extended' | 'shadow_blocked' | 'shadow_allowed';
154
+ agent_id: string;
155
+ org_id: string;
156
+ resource_id?: string | null;
157
+ conflicting_claim_id?: string | null;
158
+ rule_applied?: string | null;
159
+ metadata?: Record<string, unknown> | null;
160
+ actor_type?: string | null;
161
+ actor_id?: string | null;
162
+ actor_label?: string | null;
163
+ }) {
164
+ const db = input.db ?? pool;
165
+ await db.query(
166
+ `INSERT INTO work_claim_events (
167
+ claim_id,
168
+ event_type,
169
+ agent_id,
170
+ org_id,
171
+ resource_id,
172
+ conflicting_claim_id,
173
+ rule_applied,
174
+ metadata,
175
+ actor_type,
176
+ actor_id,
177
+ actor_label
178
+ )
179
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
180
+ [
181
+ input.claim_id ?? null,
182
+ input.event_type,
183
+ input.agent_id,
184
+ input.org_id,
185
+ input.resource_id ?? null,
186
+ input.conflicting_claim_id ?? null,
187
+ input.rule_applied ?? null,
188
+ JSON.stringify(input.metadata ?? {}),
189
+ input.actor_type ?? null,
190
+ input.actor_id ?? null,
191
+ input.actor_label ?? null,
192
+ ],
193
+ );
194
+ }
195
+
196
+ async function expireAndLogWorkClaims(db: Pool | PoolClient, orgId: string) {
197
+ const expired = await expireOldWorkClaims(db, orgId);
198
+ for (const claim of expired) {
199
+ await insertWorkEvent({
200
+ db,
201
+ claim_id: claim.id,
202
+ event_type: 'expired',
203
+ agent_id: claim.agent_id,
204
+ org_id: claim.org_id,
205
+ resource_id: claim.resource_id,
206
+ actor_type: 'system',
207
+ actor_label: 'Availsync',
208
+ metadata: {
209
+ expired_at: new Date().toISOString(),
210
+ expired_by: 'expires_at',
211
+ previous_expires_at: claim.expires_at.toISOString(),
212
+ planned_end_at: claim.end_at.toISOString(),
213
+ },
214
+ });
215
+ }
216
+ }
217
+
218
+ router.post('/work/check', requireWorkspaceOrAgent, async (req, res) => {
219
+ const startedAt = Date.now();
220
+ const parsed = WorkRequestSchema.safeParse(req.body);
221
+ if (!parsed.success) {
222
+ return res.status(422).json({ error: 'validation_error', details: parsed.error.flatten() });
223
+ }
224
+
225
+ const authReq = req as AnyAuthenticatedRequest;
226
+ const { startAt, endAt } = workWindow(parsed.data);
227
+
228
+ try {
229
+ const agent = await getAgentInOrg(parsed.data.agent_id, authReq.org.id);
230
+ if (!agent) {
231
+ return res.status(403).json({ error: 'forbidden', message: 'Agent is outside your org' });
232
+ }
233
+ if (!canAccessAgent(authReq, parsed.data.agent_id)) {
234
+ return res.status(403).json({ error: 'forbidden', message: 'Agent key cannot act for another agent' });
235
+ }
236
+ if (!(await enforcePlanLimit({
237
+ org_id: authReq.org.id,
238
+ limit_type: 'api_calls_month',
239
+ res,
240
+ }))) {
241
+ return;
242
+ }
243
+
244
+ await expireAndLogWorkClaims(pool, authReq.org.id);
245
+
246
+ const resource = await findWorkResource({
247
+ org_id: authReq.org.id,
248
+ resource_type: parsed.data.resource_type,
249
+ resource_key: parsed.data.resource_key,
250
+ });
251
+
252
+ const result = resource
253
+ ? await previewWorkClaim(pool, {
254
+ org_id: authReq.org.id,
255
+ agent_id: parsed.data.agent_id,
256
+ resource_id: resource.id,
257
+ start_at: startAt,
258
+ end_at: endAt,
259
+ reason: parsed.data.reason,
260
+ metadata: parsed.data.metadata,
261
+ })
262
+ : {
263
+ status: 'available' as const,
264
+ winning_claim_id: null,
265
+ losing_claim_ids: [],
266
+ rule_applied: 'none' as const,
267
+ suggested_retry_at: null,
268
+ };
269
+
270
+ await logActivity({
271
+ org_id: authReq.org.id,
272
+ agent_id: parsed.data.agent_id,
273
+ activity_type: 'work_check',
274
+ endpoint: '/v1/work/check',
275
+ method: 'POST',
276
+ status_code: 200,
277
+ status: 'success',
278
+ ...actorFromAuth(authReq),
279
+ latency_ms: durationSince(startedAt),
280
+ metadata: {
281
+ resource_type: parsed.data.resource_type,
282
+ resource_key: parsed.data.resource_key,
283
+ status: result.status,
284
+ },
285
+ });
286
+
287
+ return res.json({
288
+ status: result.status,
289
+ resource: publicResource(resource, parsed.data),
290
+ winning_claim_id: result.winning_claim_id,
291
+ losing_claim_ids: result.losing_claim_ids,
292
+ rule_applied: result.rule_applied,
293
+ explanation: explainWorkResult(result.status, parsed.data.resource_key),
294
+ suggested_retry_at: result.suggested_retry_at?.toISOString() ?? null,
295
+ });
296
+ } catch (err) {
297
+ log.error('[work] check error', { error: (err as Error).message });
298
+ return res.status(500).json({ error: 'internal_error', message: 'An unexpected error occurred' });
299
+ }
300
+ });
301
+
302
+ router.post('/work/claim', requireWorkspaceOrAgent, async (req, res) => {
303
+ const startedAt = Date.now();
304
+ const parsed = WorkRequestSchema.safeParse(req.body);
305
+ if (!parsed.success) {
306
+ return res.status(422).json({ error: 'validation_error', details: parsed.error.flatten() });
307
+ }
308
+
309
+ const authReq = req as AnyAuthenticatedRequest;
310
+ const actor = actorFromAuth(authReq);
311
+ const { startAt, endAt } = workWindow(parsed.data);
312
+ const idempotencyHeader = req.header('Idempotency-Key');
313
+ const idempotencyKey = idempotencyHeader
314
+ ? IdempotencyKeySchema.safeParse(idempotencyHeader).success
315
+ ? idempotencyHeader.trim()
316
+ : ''
317
+ : null;
318
+
319
+ if (idempotencyHeader && !idempotencyKey) {
320
+ return res.status(400).json({ error: 'invalid_idempotency_key', message: 'Idempotency-Key must be 1-200 characters' });
321
+ }
322
+
323
+ try {
324
+ const agent = await getAgentInOrg(parsed.data.agent_id, authReq.org.id);
325
+ if (!agent) {
326
+ return res.status(403).json({ error: 'forbidden', message: 'Agent is outside your org' });
327
+ }
328
+ if (!canAccessAgent(authReq, parsed.data.agent_id)) {
329
+ return res.status(403).json({ error: 'forbidden', message: 'Agent key cannot act for another agent' });
330
+ }
331
+ if (!(await enforcePlanLimit({
332
+ org_id: authReq.org.id,
333
+ limit_type: 'api_calls_month',
334
+ res,
335
+ }))) {
336
+ return;
337
+ }
338
+
339
+ const client = await pool.connect();
340
+ try {
341
+ await client.query('BEGIN');
342
+ await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
343
+ `${authReq.org.id}:${parsed.data.resource_type}:${parsed.data.resource_key}`,
344
+ ]);
345
+
346
+ await expireAndLogWorkClaims(client, authReq.org.id);
347
+
348
+ if (idempotencyKey) {
349
+ const existing = await client.query(
350
+ `SELECT
351
+ work_claims.*,
352
+ work_resources.resource_type,
353
+ work_resources.resource_key,
354
+ work_resources.label AS resource_label
355
+ FROM work_claims
356
+ JOIN work_resources ON work_resources.id = work_claims.resource_id
357
+ WHERE work_claims.org_id = $1
358
+ AND work_claims.agent_id = $2
359
+ AND work_claims.idempotency_key = $3
360
+ LIMIT 1`,
361
+ [authReq.org.id, parsed.data.agent_id, idempotencyKey],
362
+ );
363
+
364
+ if (existing.rows.length > 0) {
365
+ await client.query('COMMIT');
366
+ const existingClaim = existing.rows[0];
367
+ if (existingClaim.status === 'active' && new Date(existingClaim.expires_at) >= new Date()) {
368
+ await logActivity({
369
+ org_id: authReq.org.id,
370
+ agent_id: parsed.data.agent_id,
371
+ activity_type: 'work_claim',
372
+ endpoint: '/v1/work/claim',
373
+ method: 'POST',
374
+ status_code: 200,
375
+ status: 'success',
376
+ ...actor,
377
+ latency_ms: durationSince(startedAt),
378
+ metadata: {
379
+ claim_id: existingClaim.id,
380
+ idempotent_replay: true,
381
+ resource_type: existingClaim.resource_type,
382
+ resource_key: existingClaim.resource_key,
383
+ },
384
+ });
385
+ return res.status(200).json({
386
+ claim: publicClaim(existingClaim),
387
+ resource: {
388
+ id: existingClaim.resource_id,
389
+ resource_type: existingClaim.resource_type,
390
+ resource_key: existingClaim.resource_key,
391
+ label: existingClaim.resource_label,
392
+ },
393
+ status: 'available',
394
+ superseded_claim_ids: [],
395
+ idempotent_replay: true,
396
+ });
397
+ }
398
+
399
+ await logActivity({
400
+ org_id: authReq.org.id,
401
+ agent_id: parsed.data.agent_id,
402
+ activity_type: 'work_claim',
403
+ endpoint: '/v1/work/claim',
404
+ method: 'POST',
405
+ status_code: 409,
406
+ status: 'error',
407
+ ...actor,
408
+ latency_ms: durationSince(startedAt),
409
+ error_code: 'idempotency_key_reused',
410
+ error_message: 'Idempotency-Key was already used for an inactive work claim',
411
+ metadata: { previous_claim_id: existingClaim.id, previous_status: existingClaim.status },
412
+ });
413
+ return res.status(409).json({
414
+ error: 'idempotency_key_reused',
415
+ message: 'Idempotency-Key was already used for an inactive work claim',
416
+ previous_claim_id: existingClaim.id,
417
+ previous_status: existingClaim.status,
418
+ });
419
+ }
420
+ }
421
+
422
+ const claimResult = await claimWorkSlot(client, {
423
+ org_id: authReq.org.id,
424
+ agent_id: parsed.data.agent_id,
425
+ resource_type: parsed.data.resource_type,
426
+ resource_key: parsed.data.resource_key,
427
+ label: parsed.data.label,
428
+ start_at: startAt,
429
+ end_at: endAt,
430
+ reason: parsed.data.reason,
431
+ metadata: parsed.data.metadata,
432
+ idempotency_key: idempotencyKey,
433
+ actor,
434
+ beforeMutation: async ({ resource_existed, result }) => {
435
+ if (!resource_existed) {
436
+ const resourceAllowed = await enforcePlanLimit({
437
+ org_id: authReq.org.id,
438
+ limit_type: 'protected_resources',
439
+ db: client,
440
+ res,
441
+ });
442
+ if (!resourceAllowed) return false;
443
+ }
444
+
445
+ if (result.status === 'would_lose') return true;
446
+
447
+ const netActiveIncrease = result.status === 'would_win' ? Math.max(0, 1 - result.losing_claim_ids.length) : 1;
448
+ if (netActiveIncrease <= 0) return true;
449
+
450
+ const usage = await getPlanUsage(authReq.org.id, client);
451
+ return enforcePlanLimit({
452
+ org_id: authReq.org.id,
453
+ limit_type: 'active_work_claims',
454
+ increment: netActiveIncrease,
455
+ usage_override: usage.active_work_claims,
456
+ db: client,
457
+ res,
458
+ });
459
+ },
460
+ });
461
+
462
+ if (claimResult.outcome === 'aborted') {
463
+ await client.query('ROLLBACK');
464
+ return;
465
+ }
466
+
467
+ if (claimResult.outcome === 'blocked') {
468
+ await client.query('COMMIT');
469
+ await logActivity({
470
+ org_id: authReq.org.id,
471
+ agent_id: parsed.data.agent_id,
472
+ activity_type: 'work_blocked',
473
+ endpoint: '/v1/work/claim',
474
+ method: 'POST',
475
+ status_code: 409,
476
+ status: 'error',
477
+ ...actor,
478
+ latency_ms: durationSince(startedAt),
479
+ error_code: 'work_claim_blocked',
480
+ error_message: 'Another active work claim has priority for this resource',
481
+ metadata: {
482
+ resource_type: claimResult.resource.resource_type,
483
+ resource_key: claimResult.resource.resource_key,
484
+ winning_claim_id: claimResult.winning_claim_id,
485
+ },
486
+ });
487
+ return res.status(409).json({
488
+ error: 'work_claim_blocked',
489
+ message: 'Another active work claim has priority for this resource',
490
+ action: 'skip_run',
491
+ reason: explainWorkResult(claimResult.status, claimResult.resource.resource_key),
492
+ retry_after: claimResult.suggested_retry_at?.toISOString() ?? null,
493
+ blocking_claim: claimResult.blocking_claim ? publicClaim(claimResult.blocking_claim) : null,
494
+ status: claimResult.status,
495
+ resource: claimResult.resource,
496
+ winning_claim_id: claimResult.winning_claim_id,
497
+ losing_claim_ids: claimResult.losing_claim_ids,
498
+ rule_applied: claimResult.rule_applied,
499
+ explanation: explainWorkResult(claimResult.status, claimResult.resource.resource_key),
500
+ suggested_retry_at: claimResult.suggested_retry_at?.toISOString() ?? null,
501
+ });
502
+ }
503
+
504
+ await client.query('COMMIT');
505
+ await logActivity({
506
+ org_id: authReq.org.id,
507
+ agent_id: parsed.data.agent_id,
508
+ activity_type: 'work_claim',
509
+ endpoint: '/v1/work/claim',
510
+ method: 'POST',
511
+ status_code: 201,
512
+ status: 'success',
513
+ ...actor,
514
+ latency_ms: durationSince(startedAt),
515
+ metadata: {
516
+ claim_id: claimResult.claim.id,
517
+ resource_type: claimResult.resource.resource_type,
518
+ resource_key: claimResult.resource.resource_key,
519
+ superseded_claims: claimResult.losing_claim_ids,
520
+ },
521
+ });
522
+ return res.status(201).json({
523
+ claim: publicClaim(claimResult.claim as unknown as Record<string, unknown>),
524
+ resource: claimResult.resource,
525
+ status: claimResult.status,
526
+ superseded_claim_ids: claimResult.losing_claim_ids,
527
+ idempotent_replay: false,
528
+ });
529
+ } catch (err) {
530
+ await client.query('ROLLBACK');
531
+ throw err;
532
+ } finally {
533
+ client.release();
534
+ }
535
+ } catch (err) {
536
+ log.error('[work] claim error', { error: (err as Error).message });
537
+ return res.status(500).json({ error: 'internal_error', message: 'An unexpected error occurred' });
538
+ }
539
+ });
540
+
541
+ router.post('/work/run/start', requireWorkspaceOrAgent, async (req, res) => {
542
+ const startedAt = Date.now();
543
+ const parsed = WorkRunStartSchema.safeParse(req.body);
544
+ if (!parsed.success) {
545
+ return res.status(422).json({ error: 'validation_error', details: parsed.error.flatten() });
546
+ }
547
+
548
+ const authReq = req as AnyAuthenticatedRequest;
549
+ const actor = actorFromAuth(authReq);
550
+ const { startAt, endAt } = workWindow(parsed.data);
551
+ const idempotency = parseIdempotencyKey(req.header('Idempotency-Key'), parsed.data.idempotency_key);
552
+
553
+ if ('error' in idempotency) {
554
+ return res.status(400).json({ error: 'invalid_idempotency_key', message: 'Idempotency-Key must be 1-200 characters' });
555
+ }
556
+
557
+ try {
558
+ const agent = await getAgentInOrg(parsed.data.agent_id, authReq.org.id);
559
+ if (!agent) {
560
+ return res.status(403).json({ error: 'forbidden', message: 'Agent is outside your org' });
561
+ }
562
+ if (!canAccessAgent(authReq, parsed.data.agent_id)) {
563
+ return res.status(403).json({ error: 'forbidden', message: 'Agent key cannot act for another agent' });
564
+ }
565
+ if (!(await enforcePlanLimit({
566
+ org_id: authReq.org.id,
567
+ limit_type: 'api_calls_month',
568
+ res,
569
+ }))) {
570
+ return;
571
+ }
572
+
573
+ const client = await pool.connect();
574
+ try {
575
+ await client.query('BEGIN');
576
+ await client.query('SELECT pg_advisory_xact_lock(hashtext($1))', [
577
+ `${authReq.org.id}:${parsed.data.resource_type}:${parsed.data.resource_key}`,
578
+ ]);
579
+
580
+ await expireAndLogWorkClaims(client, authReq.org.id);
581
+
582
+ if (agent.enforcement_mode === 'observe') {
583
+ const existingResource = await client.query<WorkResource>(
584
+ `SELECT *
585
+ FROM work_resources
586
+ WHERE org_id = $1
587
+ AND resource_type = $2
588
+ AND resource_key = $3
589
+ LIMIT 1`,
590
+ [authReq.org.id, parsed.data.resource_type, parsed.data.resource_key],
591
+ );
592
+ const resource = existingResource.rows[0] ?? null;
593
+ const result = resource
594
+ ? await previewWorkClaim(client, {
595
+ org_id: authReq.org.id,
596
+ agent_id: parsed.data.agent_id,
597
+ resource_id: resource.id,
598
+ start_at: startAt,
599
+ end_at: endAt,
600
+ reason: parsed.data.reason,
601
+ metadata: automationMetadata(parsed.data.metadata),
602
+ })
603
+ : {
604
+ status: 'available' as const,
605
+ winning_claim_id: null,
606
+ losing_claim_ids: [],
607
+ rule_applied: 'none' as const,
608
+ suggested_retry_at: null,
609
+ };
610
+
611
+ const blocking = result.winning_claim_id
612
+ ? await client.query(
613
+ `SELECT
614
+ work_claims.*,
615
+ work_resources.resource_type,
616
+ work_resources.resource_key,
617
+ work_resources.label AS resource_label,
618
+ agents.name AS agent_name
619
+ FROM work_claims
620
+ JOIN work_resources ON work_resources.id = work_claims.resource_id
621
+ JOIN agents ON agents.id = work_claims.agent_id
622
+ WHERE work_claims.id = $1`,
623
+ [result.winning_claim_id],
624
+ )
625
+ : { rows: [] };
626
+ const wouldHaveBlocked = result.status === 'would_lose';
627
+ const publicWorkResource = publicResource(resource, parsed.data);
628
+
629
+ await insertWorkEvent({
630
+ db: client,
631
+ event_type: wouldHaveBlocked ? 'shadow_blocked' : 'shadow_allowed',
632
+ agent_id: parsed.data.agent_id,
633
+ org_id: authReq.org.id,
634
+ resource_id: resource?.id ?? null,
635
+ conflicting_claim_id: result.winning_claim_id,
636
+ rule_applied: result.rule_applied,
637
+ metadata: {
638
+ source: 'shadow_mode',
639
+ enforcement_mode: 'observe',
640
+ resource_type: parsed.data.resource_type,
641
+ resource_key: parsed.data.resource_key,
642
+ start_at: startAt.toISOString(),
643
+ end_at: endAt.toISOString(),
644
+ would_have_blocked: wouldHaveBlocked,
645
+ idempotency_key_present: Boolean(idempotency.key),
646
+ },
647
+ ...actor,
648
+ });
649
+
650
+ await client.query('COMMIT');
651
+ await logActivity({
652
+ org_id: authReq.org.id,
653
+ agent_id: parsed.data.agent_id,
654
+ activity_type: wouldHaveBlocked ? 'automation_shadow_blocked' : 'automation_shadow_allowed',
655
+ endpoint: '/v1/work/run/start',
656
+ method: 'POST',
657
+ status_code: 200,
658
+ status: 'success',
659
+ ...actor,
660
+ client_name: parsed.data.client_name ?? null,
661
+ latency_ms: durationSince(startedAt),
662
+ metadata: {
663
+ source: 'shadow_mode',
664
+ enforcement_mode: 'observe',
665
+ resource_type: parsed.data.resource_type,
666
+ resource_key: parsed.data.resource_key,
667
+ winning_claim_id: result.winning_claim_id,
668
+ would_have_blocked: wouldHaveBlocked,
669
+ },
670
+ });
671
+ return res.status(200).json({
672
+ action: 'proceed',
673
+ claim: null,
674
+ resource: publicWorkResource,
675
+ reason: wouldHaveBlocked
676
+ ? 'Observe-only mode would have skipped this run, but allowed it to proceed'
677
+ : 'Observe-only mode allowed this run; no active conflict was found',
678
+ retry_after: null,
679
+ blocking_claim: null,
680
+ status: result.status,
681
+ winning_claim_id: result.winning_claim_id,
682
+ losing_claim_ids: result.losing_claim_ids,
683
+ rule_applied: result.rule_applied,
684
+ explanation: explainWorkResult(result.status, parsed.data.resource_key),
685
+ suggested_retry_at: result.suggested_retry_at?.toISOString() ?? null,
686
+ shadow_mode: true,
687
+ would_have_blocked: wouldHaveBlocked,
688
+ shadow_decision: wouldHaveBlocked
689
+ ? {
690
+ action: 'skip_run',
691
+ reason: explainWorkResult(result.status, parsed.data.resource_key),
692
+ retry_after: result.suggested_retry_at?.toISOString() ?? null,
693
+ blocking_claim: blocking.rows[0] ? publicClaim(blocking.rows[0]) : null,
694
+ rule_applied: result.rule_applied,
695
+ winning_claim_id: result.winning_claim_id,
696
+ }
697
+ : null,
698
+ });
699
+ }
700
+
701
+ if (idempotency.key) {
702
+ const existing = await client.query(
703
+ `SELECT
704
+ work_claims.*,
705
+ work_resources.resource_type,
706
+ work_resources.resource_key,
707
+ work_resources.label AS resource_label
708
+ FROM work_claims
709
+ JOIN work_resources ON work_resources.id = work_claims.resource_id
710
+ WHERE work_claims.org_id = $1
711
+ AND work_claims.agent_id = $2
712
+ AND work_claims.idempotency_key = $3
713
+ LIMIT 1`,
714
+ [authReq.org.id, parsed.data.agent_id, idempotency.key],
715
+ );
716
+
717
+ if (existing.rows.length > 0) {
718
+ await client.query('COMMIT');
719
+ const existingClaim = existing.rows[0];
720
+ if (existingClaim.status === 'active' && new Date(existingClaim.expires_at) >= new Date()) {
721
+ await logActivity({
722
+ org_id: authReq.org.id,
723
+ agent_id: parsed.data.agent_id,
724
+ activity_type: 'automation_start',
725
+ endpoint: '/v1/work/run/start',
726
+ method: 'POST',
727
+ status_code: 200,
728
+ status: 'success',
729
+ ...actor,
730
+ client_name: parsed.data.client_name ?? null,
731
+ latency_ms: durationSince(startedAt),
732
+ metadata: {
733
+ source: 'automation_guardrail',
734
+ claim_id: existingClaim.id,
735
+ idempotent_replay: true,
736
+ resource_type: existingClaim.resource_type,
737
+ resource_key: existingClaim.resource_key,
738
+ },
739
+ });
740
+ return res.status(200).json({
741
+ action: 'proceed',
742
+ claim: publicClaim(existingClaim),
743
+ resource: {
744
+ id: existingClaim.resource_id,
745
+ resource_type: existingClaim.resource_type,
746
+ resource_key: existingClaim.resource_key,
747
+ label: existingClaim.resource_label,
748
+ },
749
+ reason: 'Existing active work slot returned for idempotent retry',
750
+ retry_after: null,
751
+ blocking_claim: null,
752
+ status: 'available',
753
+ superseded_claim_ids: [],
754
+ idempotent_replay: true,
755
+ extend_url: '/v1/work/run/extend',
756
+ finish_url: '/v1/work/run/finish',
757
+ });
758
+ }
759
+
760
+ await logActivity({
761
+ org_id: authReq.org.id,
762
+ agent_id: parsed.data.agent_id,
763
+ activity_type: 'automation_error',
764
+ endpoint: '/v1/work/run/start',
765
+ method: 'POST',
766
+ status_code: 409,
767
+ status: 'error',
768
+ ...actor,
769
+ client_name: parsed.data.client_name ?? null,
770
+ latency_ms: durationSince(startedAt),
771
+ error_code: 'idempotency_key_reused',
772
+ error_message: 'Idempotency-Key was already used for an inactive work claim',
773
+ metadata: {
774
+ source: 'automation_guardrail',
775
+ previous_claim_id: existingClaim.id,
776
+ previous_status: existingClaim.status,
777
+ },
778
+ });
779
+ return res.status(409).json({
780
+ error: 'idempotency_key_reused',
781
+ message: 'Idempotency-Key was already used for an inactive work claim',
782
+ previous_claim_id: existingClaim.id,
783
+ previous_status: existingClaim.status,
784
+ });
785
+ }
786
+ }
787
+
788
+ const existingResource = await client.query(
789
+ `SELECT id
790
+ FROM work_resources
791
+ WHERE org_id = $1
792
+ AND resource_type = $2
793
+ AND resource_key = $3
794
+ LIMIT 1`,
795
+ [authReq.org.id, parsed.data.resource_type, parsed.data.resource_key],
796
+ );
797
+ if (existingResource.rows.length === 0) {
798
+ const resourceAllowed = await enforcePlanLimit({
799
+ org_id: authReq.org.id,
800
+ limit_type: 'protected_resources',
801
+ db: client,
802
+ res,
803
+ });
804
+ if (!resourceAllowed) {
805
+ await client.query('ROLLBACK');
806
+ return;
807
+ }
808
+ const usage = await getPlanUsage(authReq.org.id, client);
809
+ const claimAllowed = await enforcePlanLimit({
810
+ org_id: authReq.org.id,
811
+ limit_type: 'active_work_claims',
812
+ increment: 1,
813
+ usage_override: usage.active_work_claims,
814
+ db: client,
815
+ res,
816
+ });
817
+ if (!claimAllowed) {
818
+ await client.query('ROLLBACK');
819
+ return;
820
+ }
821
+ }
822
+
823
+ const resource = await upsertWorkResource(client, {
824
+ org_id: authReq.org.id,
825
+ resource_type: parsed.data.resource_type,
826
+ resource_key: parsed.data.resource_key,
827
+ label: parsed.data.label,
828
+ });
829
+ const result = await previewWorkClaim(client, {
830
+ org_id: authReq.org.id,
831
+ agent_id: parsed.data.agent_id,
832
+ resource_id: resource.id,
833
+ start_at: startAt,
834
+ end_at: endAt,
835
+ reason: parsed.data.reason,
836
+ metadata: automationMetadata(parsed.data.metadata),
837
+ });
838
+
839
+ if (result.status === 'would_lose') {
840
+ const blocking = result.winning_claim_id
841
+ ? await client.query(
842
+ `SELECT
843
+ work_claims.*,
844
+ work_resources.resource_type,
845
+ work_resources.resource_key,
846
+ work_resources.label AS resource_label,
847
+ agents.name AS agent_name
848
+ FROM work_claims
849
+ JOIN work_resources ON work_resources.id = work_claims.resource_id
850
+ JOIN agents ON agents.id = work_claims.agent_id
851
+ WHERE work_claims.id = $1`,
852
+ [result.winning_claim_id],
853
+ )
854
+ : { rows: [] };
855
+ await insertWorkEvent({
856
+ db: client,
857
+ event_type: 'blocked',
858
+ agent_id: parsed.data.agent_id,
859
+ org_id: authReq.org.id,
860
+ resource_id: resource.id,
861
+ conflicting_claim_id: result.winning_claim_id,
862
+ rule_applied: result.rule_applied,
863
+ metadata: {
864
+ source: 'automation_guardrail',
865
+ resource_type: resource.resource_type,
866
+ resource_key: resource.resource_key,
867
+ start_at: startAt.toISOString(),
868
+ end_at: endAt.toISOString(),
869
+ },
870
+ ...actor,
871
+ });
872
+ await client.query('COMMIT');
873
+ await logActivity({
874
+ org_id: authReq.org.id,
875
+ agent_id: parsed.data.agent_id,
876
+ activity_type: 'automation_skip',
877
+ endpoint: '/v1/work/run/start',
878
+ method: 'POST',
879
+ status_code: 200,
880
+ status: 'success',
881
+ ...actor,
882
+ client_name: parsed.data.client_name ?? null,
883
+ latency_ms: durationSince(startedAt),
884
+ metadata: {
885
+ source: 'automation_guardrail',
886
+ resource_type: resource.resource_type,
887
+ resource_key: resource.resource_key,
888
+ winning_claim_id: result.winning_claim_id,
889
+ },
890
+ });
891
+ return res.status(200).json({
892
+ action: 'skip_run',
893
+ claim: null,
894
+ resource,
895
+ reason: explainWorkResult(result.status, resource.resource_key),
896
+ retry_after: result.suggested_retry_at?.toISOString() ?? null,
897
+ blocking_claim: blocking.rows[0] ? publicClaim(blocking.rows[0]) : null,
898
+ status: result.status,
899
+ winning_claim_id: result.winning_claim_id,
900
+ losing_claim_ids: result.losing_claim_ids,
901
+ rule_applied: result.rule_applied,
902
+ explanation: explainWorkResult(result.status, resource.resource_key),
903
+ suggested_retry_at: result.suggested_retry_at?.toISOString() ?? null,
904
+ });
905
+ }
906
+
907
+ const netActiveIncrease =
908
+ result.status === 'would_win' ? Math.max(0, 1 - result.losing_claim_ids.length) : 1;
909
+ if (existingResource.rows.length > 0 && netActiveIncrease > 0) {
910
+ const usage = await getPlanUsage(authReq.org.id, client);
911
+ const claimAllowed = await enforcePlanLimit({
912
+ org_id: authReq.org.id,
913
+ limit_type: 'active_work_claims',
914
+ increment: netActiveIncrease,
915
+ usage_override: usage.active_work_claims,
916
+ db: client,
917
+ res,
918
+ });
919
+ if (!claimAllowed) {
920
+ await client.query('ROLLBACK');
921
+ return;
922
+ }
923
+ }
924
+
925
+ if (result.losing_claim_ids.length > 0) {
926
+ await client.query(
927
+ `UPDATE work_claims
928
+ SET status = 'superseded'
929
+ WHERE id = ANY($1::uuid[])
930
+ AND org_id = $2`,
931
+ [result.losing_claim_ids, authReq.org.id],
932
+ );
933
+ }
934
+
935
+ const created = await client.query<WorkClaim>(
936
+ `INSERT INTO work_claims (
937
+ org_id,
938
+ agent_id,
939
+ resource_id,
940
+ start_at,
941
+ end_at,
942
+ status,
943
+ reason,
944
+ metadata,
945
+ expires_at,
946
+ last_renewed_at,
947
+ renewal_count,
948
+ idempotency_key
949
+ )
950
+ VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8, NOW(), 0, $9)
951
+ RETURNING *`,
952
+ [
953
+ authReq.org.id,
954
+ parsed.data.agent_id,
955
+ resource.id,
956
+ startAt,
957
+ endAt,
958
+ parsed.data.reason ?? null,
959
+ JSON.stringify(automationMetadata(parsed.data.metadata)),
960
+ initialWorkExpiresAt(startAt, endAt),
961
+ idempotency.key,
962
+ ],
963
+ );
964
+
965
+ await insertWorkEvent({
966
+ db: client,
967
+ claim_id: created.rows[0].id,
968
+ event_type: 'created',
969
+ agent_id: parsed.data.agent_id,
970
+ org_id: authReq.org.id,
971
+ resource_id: resource.id,
972
+ rule_applied: result.rule_applied,
973
+ metadata: {
974
+ source: 'automation_guardrail',
975
+ resource_type: resource.resource_type,
976
+ resource_key: resource.resource_key,
977
+ start_at: startAt.toISOString(),
978
+ end_at: endAt.toISOString(),
979
+ },
980
+ ...actor,
981
+ });
982
+
983
+ for (const losingClaimId of result.losing_claim_ids) {
984
+ await insertWorkEvent({
985
+ db: client,
986
+ claim_id: losingClaimId,
987
+ event_type: 'superseded',
988
+ agent_id: parsed.data.agent_id,
989
+ org_id: authReq.org.id,
990
+ resource_id: resource.id,
991
+ conflicting_claim_id: created.rows[0].id,
992
+ rule_applied: result.rule_applied,
993
+ metadata: { source: 'automation_guardrail', superseded_by_claim_id: created.rows[0].id },
994
+ ...actor,
995
+ });
996
+ }
997
+
998
+ await client.query('COMMIT');
999
+ await logActivity({
1000
+ org_id: authReq.org.id,
1001
+ agent_id: parsed.data.agent_id,
1002
+ activity_type: 'automation_start',
1003
+ endpoint: '/v1/work/run/start',
1004
+ method: 'POST',
1005
+ status_code: 200,
1006
+ status: 'success',
1007
+ ...actor,
1008
+ client_name: parsed.data.client_name ?? null,
1009
+ latency_ms: durationSince(startedAt),
1010
+ metadata: {
1011
+ source: 'automation_guardrail',
1012
+ claim_id: created.rows[0].id,
1013
+ resource_type: resource.resource_type,
1014
+ resource_key: resource.resource_key,
1015
+ superseded_claims: result.losing_claim_ids,
1016
+ },
1017
+ });
1018
+ return res.status(200).json({
1019
+ action: 'proceed',
1020
+ claim: publicClaim(created.rows[0] as unknown as Record<string, unknown>),
1021
+ resource,
1022
+ reason: 'Work slot claimed',
1023
+ retry_after: null,
1024
+ blocking_claim: null,
1025
+ status: result.status,
1026
+ superseded_claim_ids: result.losing_claim_ids,
1027
+ idempotent_replay: false,
1028
+ extend_url: '/v1/work/run/extend',
1029
+ finish_url: '/v1/work/run/finish',
1030
+ });
1031
+ } catch (err) {
1032
+ await client.query('ROLLBACK');
1033
+ throw err;
1034
+ } finally {
1035
+ client.release();
1036
+ }
1037
+ } catch (err) {
1038
+ log.error('[work] automation start error', { error: (err as Error).message });
1039
+ await logActivity({
1040
+ org_id: authReq.org.id,
1041
+ agent_id: parsed.data.agent_id,
1042
+ activity_type: 'automation_error',
1043
+ endpoint: '/v1/work/run/start',
1044
+ method: 'POST',
1045
+ status_code: 500,
1046
+ status: 'error',
1047
+ ...actor,
1048
+ client_name: parsed.data.client_name ?? null,
1049
+ latency_ms: durationSince(startedAt),
1050
+ error_code: 'internal_error',
1051
+ error_message: 'An unexpected error occurred',
1052
+ metadata: { source: 'automation_guardrail' },
1053
+ });
1054
+ return res.status(500).json({ error: 'internal_error', message: 'An unexpected error occurred' });
1055
+ }
1056
+ });
1057
+
1058
+ router.post('/work/run/extend', requireWorkspaceOrAgent, async (req, res) => {
1059
+ const startedAt = Date.now();
1060
+ const parsed = RunExtendWorkClaimSchema.safeParse(req.body);
1061
+ if (!parsed.success) {
1062
+ return res.status(422).json({ error: 'validation_error', details: parsed.error.flatten() });
1063
+ }
1064
+
1065
+ const authReq = req as AnyAuthenticatedRequest;
1066
+ const actor = actorFromAuth(authReq);
1067
+
1068
+ try {
1069
+ if (!(await enforcePlanLimit({
1070
+ org_id: authReq.org.id,
1071
+ limit_type: 'api_calls_month',
1072
+ res,
1073
+ }))) {
1074
+ return;
1075
+ }
1076
+
1077
+ const client = await pool.connect();
1078
+ try {
1079
+ await client.query('BEGIN');
1080
+ await expireAndLogWorkClaims(client, authReq.org.id);
1081
+
1082
+ const claim = await client.query<WorkClaim>(
1083
+ `SELECT *
1084
+ FROM work_claims
1085
+ WHERE id = $1
1086
+ AND org_id = $2
1087
+ AND status = 'active'
1088
+ AND ($3::uuid IS NULL OR agent_id = $3)
1089
+ FOR UPDATE`,
1090
+ [parsed.data.claim_id, authReq.org.id, isAgentAuth(authReq) ? authReq.agent.id : null],
1091
+ );
1092
+
1093
+ if (claim.rows.length === 0) {
1094
+ await client.query('ROLLBACK');
1095
+ return res.status(404).json({ error: 'claim_not_found', message: 'Active work claim not found' });
1096
+ }
1097
+
1098
+ const current = claim.rows[0];
1099
+ const maxExpires = maxWorkExpiresAt(current.start_at);
1100
+ if (current.expires_at >= maxExpires) {
1101
+ await client.query('ROLLBACK');
1102
+ await logActivity({
1103
+ org_id: authReq.org.id,
1104
+ agent_id: current.agent_id,
1105
+ activity_type: 'automation_error',
1106
+ endpoint: '/v1/work/run/extend',
1107
+ method: 'POST',
1108
+ status_code: 409,
1109
+ status: 'error',
1110
+ ...actor,
1111
+ client_name: parsed.data.client_name ?? null,
1112
+ latency_ms: durationSince(startedAt),
1113
+ error_code: 'max_lock_reached',
1114
+ error_message: 'Work claim is already at the maximum 4 hour lock window',
1115
+ metadata: { source: 'automation_guardrail', claim_id: current.id, max_expires_at: maxExpires.toISOString() },
1116
+ });
1117
+ return res.status(409).json({
1118
+ error: 'max_lock_reached',
1119
+ message: `Work claims cannot be extended beyond ${MAX_WORK_LOCK_MINUTES} minutes from start_at`,
1120
+ max_expires_at: maxExpires.toISOString(),
1121
+ });
1122
+ }
1123
+
1124
+ const requested = new Date(Date.now() + parsed.data.duration_minutes * 60_000);
1125
+ const capped = requested < maxExpires ? requested : maxExpires;
1126
+ const nextExpires = capped > current.expires_at ? capped : current.expires_at;
1127
+
1128
+ const updated = await client.query<WorkClaim>(
1129
+ `UPDATE work_claims
1130
+ SET expires_at = $1,
1131
+ last_renewed_at = NOW(),
1132
+ renewal_count = renewal_count + 1
1133
+ WHERE id = $2
1134
+ RETURNING *`,
1135
+ [nextExpires, current.id],
1136
+ );
1137
+
1138
+ await insertWorkEvent({
1139
+ db: client,
1140
+ claim_id: updated.rows[0].id,
1141
+ event_type: 'extended',
1142
+ agent_id: updated.rows[0].agent_id,
1143
+ org_id: authReq.org.id,
1144
+ resource_id: updated.rows[0].resource_id,
1145
+ metadata: {
1146
+ source: 'automation_guardrail',
1147
+ previous_expires_at: current.expires_at.toISOString(),
1148
+ expires_at: updated.rows[0].expires_at.toISOString(),
1149
+ max_expires_at: maxExpires.toISOString(),
1150
+ duration_minutes: parsed.data.duration_minutes,
1151
+ },
1152
+ ...actor,
1153
+ });
1154
+
1155
+ await client.query('COMMIT');
1156
+ await logActivity({
1157
+ org_id: authReq.org.id,
1158
+ agent_id: updated.rows[0].agent_id,
1159
+ activity_type: 'automation_extend',
1160
+ endpoint: '/v1/work/run/extend',
1161
+ method: 'POST',
1162
+ status_code: 200,
1163
+ status: 'success',
1164
+ ...actor,
1165
+ client_name: parsed.data.client_name ?? null,
1166
+ latency_ms: durationSince(startedAt),
1167
+ metadata: {
1168
+ source: 'automation_guardrail',
1169
+ claim_id: updated.rows[0].id,
1170
+ expires_at: updated.rows[0].expires_at.toISOString(),
1171
+ renewal_count: updated.rows[0].renewal_count,
1172
+ },
1173
+ });
1174
+
1175
+ return res.json({
1176
+ action: 'extended',
1177
+ claim: publicClaim(updated.rows[0] as unknown as Record<string, unknown>),
1178
+ expires_at: updated.rows[0].expires_at,
1179
+ seconds_until_expiry: secondsUntilExpiry(updated.rows[0].expires_at),
1180
+ last_renewed_at: updated.rows[0].last_renewed_at,
1181
+ renewal_count: updated.rows[0].renewal_count,
1182
+ max_expires_at: maxExpires.toISOString(),
1183
+ });
1184
+ } catch (err) {
1185
+ await client.query('ROLLBACK');
1186
+ throw err;
1187
+ } finally {
1188
+ client.release();
1189
+ }
1190
+ } catch (err) {
1191
+ log.error('[work] automation extend error', { error: (err as Error).message });
1192
+ return res.status(500).json({ error: 'internal_error', message: 'An unexpected error occurred' });
1193
+ }
1194
+ });
1195
+
1196
+ router.post('/work/run/finish', requireWorkspaceOrAgent, async (req, res) => {
1197
+ const startedAt = Date.now();
1198
+ const parsed = RunFinishWorkClaimSchema.safeParse(req.body);
1199
+ if (!parsed.success) {
1200
+ return res.status(422).json({ error: 'validation_error', details: parsed.error.flatten() });
1201
+ }
1202
+
1203
+ const authReq = req as AnyAuthenticatedRequest;
1204
+ const actor = actorFromAuth(authReq);
1205
+
1206
+ try {
1207
+ if (!(await enforcePlanLimit({
1208
+ org_id: authReq.org.id,
1209
+ limit_type: 'api_calls_month',
1210
+ res,
1211
+ }))) {
1212
+ return;
1213
+ }
1214
+
1215
+ await expireAndLogWorkClaims(pool, authReq.org.id);
1216
+ const updated = await pool.query<WorkClaim>(
1217
+ `UPDATE work_claims
1218
+ SET status = 'released'
1219
+ WHERE id = $1
1220
+ AND org_id = $2
1221
+ AND status = 'active'
1222
+ AND ($3::uuid IS NULL OR agent_id = $3)
1223
+ RETURNING *`,
1224
+ [parsed.data.claim_id, authReq.org.id, isAgentAuth(authReq) ? authReq.agent.id : null],
1225
+ );
1226
+
1227
+ if (updated.rows.length === 0) {
1228
+ const existing = await pool.query<WorkClaim>(
1229
+ `SELECT *
1230
+ FROM work_claims
1231
+ WHERE id = $1
1232
+ AND org_id = $2
1233
+ AND ($3::uuid IS NULL OR agent_id = $3)
1234
+ LIMIT 1`,
1235
+ [parsed.data.claim_id, authReq.org.id, isAgentAuth(authReq) ? authReq.agent.id : null],
1236
+ );
1237
+
1238
+ if (existing.rows.length === 0) {
1239
+ return res.status(404).json({ error: 'claim_not_found', message: 'Work claim not found' });
1240
+ }
1241
+
1242
+ await logActivity({
1243
+ org_id: authReq.org.id,
1244
+ agent_id: existing.rows[0].agent_id,
1245
+ activity_type: 'automation_finish',
1246
+ endpoint: '/v1/work/run/finish',
1247
+ method: 'POST',
1248
+ status_code: 200,
1249
+ status: 'success',
1250
+ ...actor,
1251
+ client_name: parsed.data.client_name ?? null,
1252
+ latency_ms: durationSince(startedAt),
1253
+ metadata: {
1254
+ source: 'automation_guardrail',
1255
+ claim_id: existing.rows[0].id,
1256
+ already_finished: true,
1257
+ previous_status: existing.rows[0].status,
1258
+ outcome: parsed.data.outcome ?? null,
1259
+ },
1260
+ });
1261
+
1262
+ return res.json({
1263
+ action: 'already_finished',
1264
+ claim: publicClaim(existing.rows[0] as unknown as Record<string, unknown>),
1265
+ });
1266
+ }
1267
+
1268
+ await insertWorkEvent({
1269
+ claim_id: updated.rows[0].id,
1270
+ event_type: 'released',
1271
+ agent_id: updated.rows[0].agent_id,
1272
+ org_id: authReq.org.id,
1273
+ resource_id: updated.rows[0].resource_id,
1274
+ metadata: automationMetadata({
1275
+ outcome: parsed.data.outcome ?? null,
1276
+ reason: parsed.data.reason ?? null,
1277
+ ...(parsed.data.metadata ?? {}),
1278
+ }),
1279
+ ...actor,
1280
+ });
1281
+ await logActivity({
1282
+ org_id: authReq.org.id,
1283
+ agent_id: updated.rows[0].agent_id,
1284
+ activity_type: 'automation_finish',
1285
+ endpoint: '/v1/work/run/finish',
1286
+ method: 'POST',
1287
+ status_code: 200,
1288
+ status: 'success',
1289
+ ...actor,
1290
+ client_name: parsed.data.client_name ?? null,
1291
+ latency_ms: durationSince(startedAt),
1292
+ metadata: {
1293
+ source: 'automation_guardrail',
1294
+ claim_id: updated.rows[0].id,
1295
+ outcome: parsed.data.outcome ?? null,
1296
+ },
1297
+ });
1298
+
1299
+ return res.json({
1300
+ action: 'finished',
1301
+ claim: publicClaim(updated.rows[0] as unknown as Record<string, unknown>),
1302
+ });
1303
+ } catch (err) {
1304
+ log.error('[work] automation finish error', { error: (err as Error).message });
1305
+ return res.status(500).json({ error: 'internal_error', message: 'An unexpected error occurred' });
1306
+ }
1307
+ });
1308
+
1309
+ router.post('/work/claims/:claim_id/extend', requireWorkspaceOrAgent, async (req, res) => {
1310
+ const startedAt = Date.now();
1311
+ const parsed = ExtendWorkClaimSchema.safeParse(req.body);
1312
+ if (!parsed.success) {
1313
+ return res.status(422).json({ error: 'validation_error', details: parsed.error.flatten() });
1314
+ }
1315
+
1316
+ const claimId = z.string().uuid().safeParse(req.params.claim_id);
1317
+ if (!claimId.success) {
1318
+ return res.status(400).json({ error: 'validation_error', message: 'Invalid claim id' });
1319
+ }
1320
+
1321
+ const authReq = req as AnyAuthenticatedRequest;
1322
+ const actor = actorFromAuth(authReq);
1323
+
1324
+ try {
1325
+ if (!(await enforcePlanLimit({
1326
+ org_id: authReq.org.id,
1327
+ limit_type: 'api_calls_month',
1328
+ res,
1329
+ }))) {
1330
+ return;
1331
+ }
1332
+
1333
+ const client = await pool.connect();
1334
+ try {
1335
+ await client.query('BEGIN');
1336
+ await expireAndLogWorkClaims(client, authReq.org.id);
1337
+
1338
+ const claim = await client.query<WorkClaim>(
1339
+ `SELECT *
1340
+ FROM work_claims
1341
+ WHERE id = $1
1342
+ AND org_id = $2
1343
+ AND status = 'active'
1344
+ AND ($3::uuid IS NULL OR agent_id = $3)
1345
+ FOR UPDATE`,
1346
+ [claimId.data, authReq.org.id, isAgentAuth(authReq) ? authReq.agent.id : null],
1347
+ );
1348
+
1349
+ if (claim.rows.length === 0) {
1350
+ await client.query('ROLLBACK');
1351
+ return res.status(404).json({ error: 'claim_not_found', message: 'Active work claim not found' });
1352
+ }
1353
+
1354
+ const current = claim.rows[0];
1355
+ const maxExpires = maxWorkExpiresAt(current.start_at);
1356
+ if (current.expires_at >= maxExpires) {
1357
+ await client.query('ROLLBACK');
1358
+ await logActivity({
1359
+ org_id: authReq.org.id,
1360
+ agent_id: current.agent_id,
1361
+ activity_type: 'work_extend',
1362
+ endpoint: '/v1/work/claims/:claim_id/extend',
1363
+ method: 'POST',
1364
+ status_code: 409,
1365
+ status: 'error',
1366
+ ...actor,
1367
+ latency_ms: durationSince(startedAt),
1368
+ error_code: 'max_lock_reached',
1369
+ error_message: 'Work claim is already at the maximum 4 hour lock window',
1370
+ metadata: { claim_id: current.id, max_expires_at: maxExpires.toISOString() },
1371
+ });
1372
+ return res.status(409).json({
1373
+ error: 'max_lock_reached',
1374
+ message: `Work claims cannot be extended beyond ${MAX_WORK_LOCK_MINUTES} minutes from start_at`,
1375
+ max_expires_at: maxExpires.toISOString(),
1376
+ });
1377
+ }
1378
+
1379
+ const requested = new Date(Date.now() + parsed.data.duration_minutes * 60_000);
1380
+ const capped = requested < maxExpires ? requested : maxExpires;
1381
+ const nextExpires = capped > current.expires_at ? capped : current.expires_at;
1382
+
1383
+ const updated = await client.query<WorkClaim>(
1384
+ `UPDATE work_claims
1385
+ SET expires_at = $1,
1386
+ last_renewed_at = NOW(),
1387
+ renewal_count = renewal_count + 1
1388
+ WHERE id = $2
1389
+ RETURNING *`,
1390
+ [nextExpires, current.id],
1391
+ );
1392
+
1393
+ await insertWorkEvent({
1394
+ db: client,
1395
+ claim_id: updated.rows[0].id,
1396
+ event_type: 'extended',
1397
+ agent_id: updated.rows[0].agent_id,
1398
+ org_id: authReq.org.id,
1399
+ resource_id: updated.rows[0].resource_id,
1400
+ metadata: {
1401
+ previous_expires_at: current.expires_at.toISOString(),
1402
+ expires_at: updated.rows[0].expires_at.toISOString(),
1403
+ max_expires_at: maxExpires.toISOString(),
1404
+ duration_minutes: parsed.data.duration_minutes,
1405
+ },
1406
+ ...actor,
1407
+ });
1408
+
1409
+ await client.query('COMMIT');
1410
+ await logActivity({
1411
+ org_id: authReq.org.id,
1412
+ agent_id: updated.rows[0].agent_id,
1413
+ activity_type: 'work_extend',
1414
+ endpoint: '/v1/work/claims/:claim_id/extend',
1415
+ method: 'POST',
1416
+ status_code: 200,
1417
+ status: 'success',
1418
+ ...actor,
1419
+ latency_ms: durationSince(startedAt),
1420
+ metadata: {
1421
+ claim_id: updated.rows[0].id,
1422
+ expires_at: updated.rows[0].expires_at.toISOString(),
1423
+ renewal_count: updated.rows[0].renewal_count,
1424
+ },
1425
+ });
1426
+
1427
+ return res.json({
1428
+ claim: publicClaim(updated.rows[0] as unknown as Record<string, unknown>),
1429
+ expires_at: updated.rows[0].expires_at,
1430
+ last_renewed_at: updated.rows[0].last_renewed_at,
1431
+ renewal_count: updated.rows[0].renewal_count,
1432
+ max_expires_at: maxExpires.toISOString(),
1433
+ });
1434
+ } catch (err) {
1435
+ await client.query('ROLLBACK');
1436
+ throw err;
1437
+ } finally {
1438
+ client.release();
1439
+ }
1440
+ } catch (err) {
1441
+ log.error('[work] extend error', { error: (err as Error).message });
1442
+ return res.status(500).json({ error: 'internal_error', message: 'An unexpected error occurred' });
1443
+ }
1444
+ });
1445
+
1446
+ router.delete('/work/claims/:claim_id', requireWorkspaceOrAgent, async (req, res) => {
1447
+ const startedAt = Date.now();
1448
+ const authReq = req as AnyAuthenticatedRequest;
1449
+ const actor = actorFromAuth(authReq);
1450
+
1451
+ try {
1452
+ if (!(await enforcePlanLimit({
1453
+ org_id: authReq.org.id,
1454
+ limit_type: 'api_calls_month',
1455
+ res,
1456
+ }))) {
1457
+ return;
1458
+ }
1459
+
1460
+ await expireAndLogWorkClaims(pool, authReq.org.id);
1461
+ const updated = await pool.query<WorkClaim>(
1462
+ `UPDATE work_claims
1463
+ SET status = 'released'
1464
+ WHERE id = $1
1465
+ AND org_id = $2
1466
+ AND status = 'active'
1467
+ AND ($3::uuid IS NULL OR agent_id = $3)
1468
+ RETURNING *`,
1469
+ [req.params.claim_id, authReq.org.id, isAgentAuth(authReq) ? authReq.agent.id : null],
1470
+ );
1471
+
1472
+ if (updated.rows.length === 0) {
1473
+ return res.status(404).json({ error: 'claim_not_found', message: 'Active work claim not found' });
1474
+ }
1475
+
1476
+ await insertWorkEvent({
1477
+ claim_id: updated.rows[0].id,
1478
+ event_type: 'released',
1479
+ agent_id: updated.rows[0].agent_id,
1480
+ org_id: authReq.org.id,
1481
+ resource_id: updated.rows[0].resource_id,
1482
+ ...actor,
1483
+ });
1484
+ await logActivity({
1485
+ org_id: authReq.org.id,
1486
+ agent_id: updated.rows[0].agent_id,
1487
+ activity_type: 'work_release',
1488
+ endpoint: '/v1/work/claims/:claim_id',
1489
+ method: 'DELETE',
1490
+ status_code: 200,
1491
+ status: 'success',
1492
+ ...actor,
1493
+ latency_ms: durationSince(startedAt),
1494
+ metadata: { claim_id: updated.rows[0].id },
1495
+ });
1496
+
1497
+ return res.json({ claim: publicClaim(updated.rows[0] as unknown as Record<string, unknown>) });
1498
+ } catch (err) {
1499
+ log.error('[work] release error', { error: (err as Error).message });
1500
+ return res.status(500).json({ error: 'internal_error', message: 'An unexpected error occurred' });
1501
+ }
1502
+ });
1503
+
1504
+ router.get('/work/claims', requireWorkspaceOrAgent, async (req, res) => {
1505
+ const parsed = WorkClaimsQuerySchema.safeParse(req.query);
1506
+ if (!parsed.success) {
1507
+ return res.status(400).json({ error: 'validation_error', details: parsed.error.flatten() });
1508
+ }
1509
+
1510
+ const authReq = req as AnyAuthenticatedRequest;
1511
+ if (isAgentAuth(authReq) && parsed.data.agent_id && parsed.data.agent_id !== authReq.agent.id) {
1512
+ return res.status(403).json({ error: 'forbidden', message: 'Agent key cannot list another agent claims' });
1513
+ }
1514
+
1515
+ try {
1516
+ await expireAndLogWorkClaims(pool, authReq.org.id);
1517
+
1518
+ const effectiveAgentId = isAgentAuth(authReq) ? authReq.agent.id : parsed.data.agent_id;
1519
+ const result = await pool.query(
1520
+ `SELECT
1521
+ work_claims.*,
1522
+ work_resources.resource_type,
1523
+ work_resources.resource_key,
1524
+ work_resources.label AS resource_label,
1525
+ agents.name AS agent_name
1526
+ FROM work_claims
1527
+ JOIN work_resources ON work_resources.id = work_claims.resource_id
1528
+ JOIN agents ON agents.id = work_claims.agent_id
1529
+ WHERE work_claims.org_id = $1
1530
+ AND work_claims.status = $2
1531
+ AND ($3::uuid IS NULL OR work_claims.agent_id = $3)
1532
+ AND ($4::text IS NULL OR work_resources.resource_type = $4)
1533
+ AND ($5::text IS NULL OR work_resources.resource_key = $5)
1534
+ ORDER BY work_claims.created_at DESC
1535
+ LIMIT $6`,
1536
+ [
1537
+ authReq.org.id,
1538
+ parsed.data.status,
1539
+ effectiveAgentId ?? null,
1540
+ parsed.data.resource_type ?? null,
1541
+ parsed.data.resource_key ?? null,
1542
+ parsed.data.limit,
1543
+ ],
1544
+ );
1545
+
1546
+ return res.json({ claims: result.rows.map((row) => publicClaim(row)) });
1547
+ } catch (err) {
1548
+ log.error('[work] list error', { error: (err as Error).message });
1549
+ return res.status(500).json({ error: 'internal_error', message: 'An unexpected error occurred' });
1550
+ }
1551
+ });
1552
+
1553
+ router.get('/work/resources', requireWorkspaceOrAgent, async (req, res) => {
1554
+ const authReq = req as AnyAuthenticatedRequest;
1555
+
1556
+ try {
1557
+ await expireAndLogWorkClaims(pool, authReq.org.id);
1558
+ const result = await pool.query(
1559
+ `SELECT
1560
+ work_resources.*,
1561
+ COUNT(work_claims.id) FILTER (WHERE work_claims.status = 'active' AND work_claims.expires_at >= NOW())::int AS active_claims_count,
1562
+ MAX(work_claims.created_at) AS last_claim_at
1563
+ FROM work_resources
1564
+ LEFT JOIN work_claims ON work_claims.resource_id = work_resources.id
1565
+ WHERE work_resources.org_id = $1
1566
+ GROUP BY work_resources.id
1567
+ ORDER BY COALESCE(MAX(work_claims.created_at), work_resources.created_at) DESC`,
1568
+ [authReq.org.id],
1569
+ );
1570
+
1571
+ return res.json({ resources: result.rows });
1572
+ } catch (err) {
1573
+ log.error('[work] resources error', { error: (err as Error).message });
1574
+ return res.status(500).json({ error: 'internal_error', message: 'An unexpected error occurred' });
1575
+ }
1576
+ });
1577
+
1578
+ export default router;