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.
- package/.adal/skills/stripe-best-practices/SKILL.md +42 -0
- package/.adal/skills/stripe-best-practices/references/billing.md +36 -0
- package/.adal/skills/stripe-best-practices/references/connect.md +48 -0
- package/.adal/skills/stripe-best-practices/references/payments.md +79 -0
- package/.adal/skills/stripe-best-practices/references/security.md +109 -0
- package/.adal/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.adal/skills/stripe-projects/SKILL.md +139 -0
- package/.adal/skills/upgrade-stripe/SKILL.md +185 -0
- package/.agents/skills/stripe-best-practices/SKILL.md +42 -0
- package/.agents/skills/stripe-best-practices/references/billing.md +36 -0
- package/.agents/skills/stripe-best-practices/references/connect.md +48 -0
- package/.agents/skills/stripe-best-practices/references/payments.md +79 -0
- package/.agents/skills/stripe-best-practices/references/security.md +109 -0
- package/.agents/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.agents/skills/stripe-projects/SKILL.md +139 -0
- package/.agents/skills/upgrade-stripe/SKILL.md +185 -0
- package/.augment/skills/stripe-best-practices/SKILL.md +42 -0
- package/.augment/skills/stripe-best-practices/references/billing.md +36 -0
- package/.augment/skills/stripe-best-practices/references/connect.md +48 -0
- package/.augment/skills/stripe-best-practices/references/payments.md +79 -0
- package/.augment/skills/stripe-best-practices/references/security.md +109 -0
- package/.augment/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.augment/skills/stripe-projects/SKILL.md +139 -0
- package/.augment/skills/upgrade-stripe/SKILL.md +185 -0
- package/.bob/skills/stripe-best-practices/SKILL.md +42 -0
- package/.bob/skills/stripe-best-practices/references/billing.md +36 -0
- package/.bob/skills/stripe-best-practices/references/connect.md +48 -0
- package/.bob/skills/stripe-best-practices/references/payments.md +79 -0
- package/.bob/skills/stripe-best-practices/references/security.md +109 -0
- package/.bob/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.bob/skills/stripe-projects/SKILL.md +139 -0
- package/.bob/skills/upgrade-stripe/SKILL.md +185 -0
- package/.claude/settings.local.json +7 -0
- package/.claude/skills/stripe-best-practices/SKILL.md +42 -0
- package/.claude/skills/stripe-best-practices/references/billing.md +36 -0
- package/.claude/skills/stripe-best-practices/references/connect.md +48 -0
- package/.claude/skills/stripe-best-practices/references/payments.md +79 -0
- package/.claude/skills/stripe-best-practices/references/security.md +109 -0
- package/.claude/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.claude/skills/stripe-projects/SKILL.md +139 -0
- package/.claude/skills/upgrade-stripe/SKILL.md +185 -0
- package/.codebuddy/skills/stripe-best-practices/SKILL.md +42 -0
- package/.codebuddy/skills/stripe-best-practices/references/billing.md +36 -0
- package/.codebuddy/skills/stripe-best-practices/references/connect.md +48 -0
- package/.codebuddy/skills/stripe-best-practices/references/payments.md +79 -0
- package/.codebuddy/skills/stripe-best-practices/references/security.md +109 -0
- package/.codebuddy/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.codebuddy/skills/stripe-projects/SKILL.md +139 -0
- package/.codebuddy/skills/upgrade-stripe/SKILL.md +185 -0
- package/.commandcode/skills/stripe-best-practices/SKILL.md +42 -0
- package/.commandcode/skills/stripe-best-practices/references/billing.md +36 -0
- package/.commandcode/skills/stripe-best-practices/references/connect.md +48 -0
- package/.commandcode/skills/stripe-best-practices/references/payments.md +79 -0
- package/.commandcode/skills/stripe-best-practices/references/security.md +109 -0
- package/.commandcode/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.commandcode/skills/stripe-projects/SKILL.md +139 -0
- package/.commandcode/skills/upgrade-stripe/SKILL.md +185 -0
- package/.continue/skills/stripe-best-practices/SKILL.md +42 -0
- package/.continue/skills/stripe-best-practices/references/billing.md +36 -0
- package/.continue/skills/stripe-best-practices/references/connect.md +48 -0
- package/.continue/skills/stripe-best-practices/references/payments.md +79 -0
- package/.continue/skills/stripe-best-practices/references/security.md +109 -0
- package/.continue/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.continue/skills/stripe-projects/SKILL.md +139 -0
- package/.continue/skills/upgrade-stripe/SKILL.md +185 -0
- package/.cortex/skills/stripe-best-practices/SKILL.md +42 -0
- package/.cortex/skills/stripe-best-practices/references/billing.md +36 -0
- package/.cortex/skills/stripe-best-practices/references/connect.md +48 -0
- package/.cortex/skills/stripe-best-practices/references/payments.md +79 -0
- package/.cortex/skills/stripe-best-practices/references/security.md +109 -0
- package/.cortex/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.cortex/skills/stripe-projects/SKILL.md +139 -0
- package/.cortex/skills/upgrade-stripe/SKILL.md +185 -0
- package/.crush/skills/stripe-best-practices/SKILL.md +42 -0
- package/.crush/skills/stripe-best-practices/references/billing.md +36 -0
- package/.crush/skills/stripe-best-practices/references/connect.md +48 -0
- package/.crush/skills/stripe-best-practices/references/payments.md +79 -0
- package/.crush/skills/stripe-best-practices/references/security.md +109 -0
- package/.crush/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.crush/skills/stripe-projects/SKILL.md +139 -0
- package/.crush/skills/upgrade-stripe/SKILL.md +185 -0
- package/.env.example +20 -0
- package/.factory/skills/stripe-best-practices/SKILL.md +42 -0
- package/.factory/skills/stripe-best-practices/references/billing.md +36 -0
- package/.factory/skills/stripe-best-practices/references/connect.md +48 -0
- package/.factory/skills/stripe-best-practices/references/payments.md +79 -0
- package/.factory/skills/stripe-best-practices/references/security.md +109 -0
- package/.factory/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.factory/skills/stripe-projects/SKILL.md +139 -0
- package/.factory/skills/upgrade-stripe/SKILL.md +185 -0
- package/.goose/skills/stripe-best-practices/SKILL.md +42 -0
- package/.goose/skills/stripe-best-practices/references/billing.md +36 -0
- package/.goose/skills/stripe-best-practices/references/connect.md +48 -0
- package/.goose/skills/stripe-best-practices/references/payments.md +79 -0
- package/.goose/skills/stripe-best-practices/references/security.md +109 -0
- package/.goose/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.goose/skills/stripe-projects/SKILL.md +139 -0
- package/.goose/skills/upgrade-stripe/SKILL.md +185 -0
- package/.iflow/skills/stripe-best-practices/SKILL.md +42 -0
- package/.iflow/skills/stripe-best-practices/references/billing.md +36 -0
- package/.iflow/skills/stripe-best-practices/references/connect.md +48 -0
- package/.iflow/skills/stripe-best-practices/references/payments.md +79 -0
- package/.iflow/skills/stripe-best-practices/references/security.md +109 -0
- package/.iflow/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.iflow/skills/stripe-projects/SKILL.md +139 -0
- package/.iflow/skills/upgrade-stripe/SKILL.md +185 -0
- package/.junie/skills/stripe-best-practices/SKILL.md +42 -0
- package/.junie/skills/stripe-best-practices/references/billing.md +36 -0
- package/.junie/skills/stripe-best-practices/references/connect.md +48 -0
- package/.junie/skills/stripe-best-practices/references/payments.md +79 -0
- package/.junie/skills/stripe-best-practices/references/security.md +109 -0
- package/.junie/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.junie/skills/stripe-projects/SKILL.md +139 -0
- package/.junie/skills/upgrade-stripe/SKILL.md +185 -0
- package/.kilocode/skills/stripe-best-practices/SKILL.md +42 -0
- package/.kilocode/skills/stripe-best-practices/references/billing.md +36 -0
- package/.kilocode/skills/stripe-best-practices/references/connect.md +48 -0
- package/.kilocode/skills/stripe-best-practices/references/payments.md +79 -0
- package/.kilocode/skills/stripe-best-practices/references/security.md +109 -0
- package/.kilocode/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.kilocode/skills/stripe-projects/SKILL.md +139 -0
- package/.kilocode/skills/upgrade-stripe/SKILL.md +185 -0
- package/.kiro/skills/stripe-best-practices/SKILL.md +42 -0
- package/.kiro/skills/stripe-best-practices/references/billing.md +36 -0
- package/.kiro/skills/stripe-best-practices/references/connect.md +48 -0
- package/.kiro/skills/stripe-best-practices/references/payments.md +79 -0
- package/.kiro/skills/stripe-best-practices/references/security.md +109 -0
- package/.kiro/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.kiro/skills/stripe-projects/SKILL.md +139 -0
- package/.kiro/skills/upgrade-stripe/SKILL.md +185 -0
- package/.kode/skills/stripe-best-practices/SKILL.md +42 -0
- package/.kode/skills/stripe-best-practices/references/billing.md +36 -0
- package/.kode/skills/stripe-best-practices/references/connect.md +48 -0
- package/.kode/skills/stripe-best-practices/references/payments.md +79 -0
- package/.kode/skills/stripe-best-practices/references/security.md +109 -0
- package/.kode/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.kode/skills/stripe-projects/SKILL.md +139 -0
- package/.kode/skills/upgrade-stripe/SKILL.md +185 -0
- package/.mcpjam/skills/stripe-best-practices/SKILL.md +42 -0
- package/.mcpjam/skills/stripe-best-practices/references/billing.md +36 -0
- package/.mcpjam/skills/stripe-best-practices/references/connect.md +48 -0
- package/.mcpjam/skills/stripe-best-practices/references/payments.md +79 -0
- package/.mcpjam/skills/stripe-best-practices/references/security.md +109 -0
- package/.mcpjam/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.mcpjam/skills/stripe-projects/SKILL.md +139 -0
- package/.mcpjam/skills/upgrade-stripe/SKILL.md +185 -0
- package/.mux/skills/stripe-best-practices/SKILL.md +42 -0
- package/.mux/skills/stripe-best-practices/references/billing.md +36 -0
- package/.mux/skills/stripe-best-practices/references/connect.md +48 -0
- package/.mux/skills/stripe-best-practices/references/payments.md +79 -0
- package/.mux/skills/stripe-best-practices/references/security.md +109 -0
- package/.mux/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.mux/skills/stripe-projects/SKILL.md +139 -0
- package/.mux/skills/upgrade-stripe/SKILL.md +185 -0
- package/.neovate/skills/stripe-best-practices/SKILL.md +42 -0
- package/.neovate/skills/stripe-best-practices/references/billing.md +36 -0
- package/.neovate/skills/stripe-best-practices/references/connect.md +48 -0
- package/.neovate/skills/stripe-best-practices/references/payments.md +79 -0
- package/.neovate/skills/stripe-best-practices/references/security.md +109 -0
- package/.neovate/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.neovate/skills/stripe-projects/SKILL.md +139 -0
- package/.neovate/skills/upgrade-stripe/SKILL.md +185 -0
- package/.nixpacksignore +14 -0
- package/.openhands/skills/stripe-best-practices/SKILL.md +42 -0
- package/.openhands/skills/stripe-best-practices/references/billing.md +36 -0
- package/.openhands/skills/stripe-best-practices/references/connect.md +48 -0
- package/.openhands/skills/stripe-best-practices/references/payments.md +79 -0
- package/.openhands/skills/stripe-best-practices/references/security.md +109 -0
- package/.openhands/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.openhands/skills/stripe-projects/SKILL.md +139 -0
- package/.openhands/skills/upgrade-stripe/SKILL.md +185 -0
- package/.pi/skills/stripe-best-practices/SKILL.md +42 -0
- package/.pi/skills/stripe-best-practices/references/billing.md +36 -0
- package/.pi/skills/stripe-best-practices/references/connect.md +48 -0
- package/.pi/skills/stripe-best-practices/references/payments.md +79 -0
- package/.pi/skills/stripe-best-practices/references/security.md +109 -0
- package/.pi/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.pi/skills/stripe-projects/SKILL.md +139 -0
- package/.pi/skills/upgrade-stripe/SKILL.md +185 -0
- package/.pochi/skills/stripe-best-practices/SKILL.md +42 -0
- package/.pochi/skills/stripe-best-practices/references/billing.md +36 -0
- package/.pochi/skills/stripe-best-practices/references/connect.md +48 -0
- package/.pochi/skills/stripe-best-practices/references/payments.md +79 -0
- package/.pochi/skills/stripe-best-practices/references/security.md +109 -0
- package/.pochi/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.pochi/skills/stripe-projects/SKILL.md +139 -0
- package/.pochi/skills/upgrade-stripe/SKILL.md +185 -0
- package/.qoder/skills/stripe-best-practices/SKILL.md +42 -0
- package/.qoder/skills/stripe-best-practices/references/billing.md +36 -0
- package/.qoder/skills/stripe-best-practices/references/connect.md +48 -0
- package/.qoder/skills/stripe-best-practices/references/payments.md +79 -0
- package/.qoder/skills/stripe-best-practices/references/security.md +109 -0
- package/.qoder/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.qoder/skills/stripe-projects/SKILL.md +139 -0
- package/.qoder/skills/upgrade-stripe/SKILL.md +185 -0
- package/.qwen/skills/stripe-best-practices/SKILL.md +42 -0
- package/.qwen/skills/stripe-best-practices/references/billing.md +36 -0
- package/.qwen/skills/stripe-best-practices/references/connect.md +48 -0
- package/.qwen/skills/stripe-best-practices/references/payments.md +79 -0
- package/.qwen/skills/stripe-best-practices/references/security.md +109 -0
- package/.qwen/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.qwen/skills/stripe-projects/SKILL.md +139 -0
- package/.qwen/skills/upgrade-stripe/SKILL.md +185 -0
- package/.roo/skills/stripe-best-practices/SKILL.md +42 -0
- package/.roo/skills/stripe-best-practices/references/billing.md +36 -0
- package/.roo/skills/stripe-best-practices/references/connect.md +48 -0
- package/.roo/skills/stripe-best-practices/references/payments.md +79 -0
- package/.roo/skills/stripe-best-practices/references/security.md +109 -0
- package/.roo/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.roo/skills/stripe-projects/SKILL.md +139 -0
- package/.roo/skills/upgrade-stripe/SKILL.md +185 -0
- package/.trae/skills/stripe-best-practices/SKILL.md +42 -0
- package/.trae/skills/stripe-best-practices/references/billing.md +36 -0
- package/.trae/skills/stripe-best-practices/references/connect.md +48 -0
- package/.trae/skills/stripe-best-practices/references/payments.md +79 -0
- package/.trae/skills/stripe-best-practices/references/security.md +109 -0
- package/.trae/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.trae/skills/stripe-projects/SKILL.md +139 -0
- package/.trae/skills/upgrade-stripe/SKILL.md +185 -0
- package/.vibe/skills/stripe-best-practices/SKILL.md +42 -0
- package/.vibe/skills/stripe-best-practices/references/billing.md +36 -0
- package/.vibe/skills/stripe-best-practices/references/connect.md +48 -0
- package/.vibe/skills/stripe-best-practices/references/payments.md +79 -0
- package/.vibe/skills/stripe-best-practices/references/security.md +109 -0
- package/.vibe/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.vibe/skills/stripe-projects/SKILL.md +139 -0
- package/.vibe/skills/upgrade-stripe/SKILL.md +185 -0
- package/.windsurf/skills/stripe-best-practices/SKILL.md +42 -0
- package/.windsurf/skills/stripe-best-practices/references/billing.md +36 -0
- package/.windsurf/skills/stripe-best-practices/references/connect.md +48 -0
- package/.windsurf/skills/stripe-best-practices/references/payments.md +79 -0
- package/.windsurf/skills/stripe-best-practices/references/security.md +109 -0
- package/.windsurf/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.windsurf/skills/stripe-projects/SKILL.md +139 -0
- package/.windsurf/skills/upgrade-stripe/SKILL.md +185 -0
- package/.zencoder/skills/stripe-best-practices/SKILL.md +42 -0
- package/.zencoder/skills/stripe-best-practices/references/billing.md +36 -0
- package/.zencoder/skills/stripe-best-practices/references/connect.md +48 -0
- package/.zencoder/skills/stripe-best-practices/references/payments.md +79 -0
- package/.zencoder/skills/stripe-best-practices/references/security.md +109 -0
- package/.zencoder/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.zencoder/skills/stripe-projects/SKILL.md +139 -0
- package/.zencoder/skills/upgrade-stripe/SKILL.md +185 -0
- package/AUDIT.md +95 -0
- package/BLOCKERS.md +0 -0
- package/COOLIFY.md +51 -0
- package/MCP_SETUP.md +23 -0
- package/PRODUCTION_CHECKLIST.md +246 -0
- package/README.md +47 -0
- package/ROADMAP.md +91 -0
- package/docs/superpowers/plans/2026-05-11-availsync-frontend-sales-flow.md +2445 -0
- package/frontend/.env.example +2 -0
- package/frontend/app/admin/layout.tsx +13 -0
- package/frontend/app/admin/page.tsx +747 -0
- package/frontend/app/app/activity/page.tsx +257 -0
- package/frontend/app/app/agents/[agentId]/page.tsx +21 -0
- package/frontend/app/app/agents/page.tsx +1155 -0
- package/frontend/app/app/audit/page.tsx +225 -0
- package/frontend/app/app/availability/page.tsx +840 -0
- package/frontend/app/app/holds/page.tsx +262 -0
- package/frontend/app/app/layout.tsx +19 -0
- package/frontend/app/app/onboarding/page.tsx +10 -0
- package/frontend/app/app/onboarding/verify/page.tsx +309 -0
- package/frontend/app/app/page.tsx +508 -0
- package/frontend/app/app/settings/page.tsx +399 -0
- package/frontend/app/app/work/page.tsx +426 -0
- package/frontend/app/changelog/page.tsx +93 -0
- package/frontend/app/checkout/page.tsx +25 -0
- package/frontend/app/docs/api/page.tsx +157 -0
- package/frontend/app/docs/page.tsx +296 -0
- package/frontend/app/docs/pilot/page.tsx +127 -0
- package/frontend/app/docs/quickstart/page.tsx +318 -0
- package/frontend/app/docs/reliability/page.tsx +78 -0
- package/frontend/app/docs/sdk/node/page.tsx +166 -0
- package/frontend/app/globals.css +57 -0
- package/frontend/app/icon.png +0 -0
- package/frontend/app/layout.tsx +87 -0
- package/frontend/app/login/page.tsx +14 -0
- package/frontend/app/page.tsx +47 -0
- package/frontend/app/pricing/page.tsx +66 -0
- package/frontend/app/privacy/page.tsx +52 -0
- package/frontend/app/robots.ts +26 -0
- package/frontend/app/security/page.tsx +74 -0
- package/frontend/app/signup/page.tsx +14 -0
- package/frontend/app/sitemap.ts +14 -0
- package/frontend/app/terms/page.tsx +51 -0
- package/frontend/components/brand/AvailsyncLogo.tsx +56 -0
- package/frontend/components/checkout/CheckoutClient.tsx +100 -0
- package/frontend/components/dashboard/AgentForm.tsx +59 -0
- package/frontend/components/dashboard/AppShell.tsx +291 -0
- package/frontend/components/dashboard/AvailabilityChecker.tsx +117 -0
- package/frontend/components/dashboard/AvailabilityWindowForm.tsx +40 -0
- package/frontend/components/dashboard/HoldForm.tsx +133 -0
- package/frontend/components/dashboard/MetricCard.tsx +10 -0
- package/frontend/components/login/LoginForm.tsx +95 -0
- package/frontend/components/marketing/AgentCoordinationStory.tsx +1530 -0
- package/frontend/components/marketing/Faq.tsx +41 -0
- package/frontend/components/marketing/Hero.tsx +73 -0
- package/frontend/components/marketing/HowItWorks.tsx +28 -0
- package/frontend/components/marketing/ObserveModeTeaser.tsx +41 -0
- package/frontend/components/marketing/PricingTeaser.tsx +23 -0
- package/frontend/components/marketing/ProblemSolution.tsx +36 -0
- package/frontend/components/marketing/SiteFooter.tsx +59 -0
- package/frontend/components/marketing/SiteHeader.tsx +45 -0
- package/frontend/components/marketing/UseCases.tsx +27 -0
- package/frontend/components/onboarding/OnboardingClient.tsx +278 -0
- package/frontend/components/pricing/PricingCards.tsx +65 -0
- package/frontend/components/privacy/CookieConsent.tsx +230 -0
- package/frontend/components/privacy/CookieSettingsButton.tsx +15 -0
- package/frontend/components/seo/JsonLd.tsx +10 -0
- package/frontend/components/signup/SignupForm.tsx +55 -0
- package/frontend/components/ui/Badge.tsx +23 -0
- package/frontend/components/ui/Button.tsx +37 -0
- package/frontend/components/ui/Card.tsx +11 -0
- package/frontend/components/ui/ConfirmDialog.tsx +77 -0
- package/frontend/components/ui/EmptyState.tsx +24 -0
- package/frontend/components/ui/Input.tsx +14 -0
- package/frontend/components/ui/KeyDisplay.tsx +49 -0
- package/frontend/components/ui/Select.tsx +14 -0
- package/frontend/components/ui/Skeleton.tsx +24 -0
- package/frontend/components/ui/Tabs.tsx +19 -0
- package/frontend/components/ui/Textarea.tsx +14 -0
- package/frontend/components/ui/Toast.tsx +78 -0
- package/frontend/components/waitlist/WaitlistDialog.tsx +128 -0
- package/frontend/lib/api.ts +1282 -0
- package/frontend/lib/billing.ts +6 -0
- package/frontend/lib/cookieConsent.ts +113 -0
- package/frontend/lib/format.ts +16 -0
- package/frontend/lib/plans.ts +62 -0
- package/frontend/lib/schemas.ts +108 -0
- package/frontend/lib/seo.ts +376 -0
- package/frontend/lib/setupGuides.ts +630 -0
- package/frontend/lib/storage.ts +30 -0
- package/frontend/next-env.d.ts +6 -0
- package/frontend/next.config.mjs +13 -0
- package/frontend/package-lock.json +14409 -0
- package/frontend/package.json +41 -0
- package/frontend/playwright.config.ts +20 -0
- package/frontend/postcss.config.mjs +8 -0
- package/frontend/public/.gitkeep +0 -0
- package/frontend/public/brand/availsync-logo-board.png +0 -0
- package/frontend/public/brand/availsync-logo-dark.png +0 -0
- package/frontend/public/brand/availsync-mark-dark.png +0 -0
- package/frontend/public/brand/availsync-wordmark-dark.png +0 -0
- package/frontend/public/marketing/hero-agent-coordination.png +0 -0
- package/frontend/tailwind.config.ts +53 -0
- package/frontend/tests/smoke.spec.ts +89 -0
- package/frontend/tsconfig.json +23 -0
- package/jest.config.js +7 -0
- package/nixpacks.toml +11 -0
- package/package.json +53 -0
- package/packages/mcp/LICENSE +21 -0
- package/packages/mcp/README.md +60 -0
- package/packages/mcp/jest.config.cjs +8 -0
- package/packages/mcp/package.json +54 -0
- package/packages/mcp/src/helpers.ts +38 -0
- package/packages/mcp/src/index.test.ts +60 -0
- package/packages/mcp/src/index.ts +387 -0
- package/packages/mcp/tsconfig.json +20 -0
- package/packages/mcp/tsconfig.test.json +12 -0
- package/packages/node/LICENSE +21 -0
- package/packages/node/README.md +120 -0
- package/packages/node/jest.config.cjs +8 -0
- package/packages/node/package.json +46 -0
- package/packages/node/src/index.test.ts +360 -0
- package/packages/node/src/index.ts +402 -0
- package/packages/node/tsconfig.json +20 -0
- package/packages/node/tsconfig.test.json +12 -0
- package/plan.md +923 -0
- package/skills/stripe-best-practices/SKILL.md +42 -0
- package/skills/stripe-best-practices/references/billing.md +36 -0
- package/skills/stripe-best-practices/references/connect.md +48 -0
- package/skills/stripe-best-practices/references/payments.md +79 -0
- package/skills/stripe-best-practices/references/security.md +109 -0
- package/skills/stripe-best-practices/references/treasury.md +16 -0
- package/skills/stripe-projects/SKILL.md +139 -0
- package/skills/upgrade-stripe/SKILL.md +185 -0
- package/skills-lock.json +20 -0
- package/src/core/availability.ts +178 -0
- package/src/core/conflict.ts +209 -0
- package/src/core/work.ts +490 -0
- package/src/db/client.ts +17 -0
- package/src/db/migrations/001_init.sql +88 -0
- package/src/db/migrations/002_stripe.sql +2 -0
- package/src/db/migrations/003_workspace_auth.sql +19 -0
- package/src/db/migrations/004_agent_mcp_status.sql +2 -0
- package/src/db/migrations/005_hold_event_actor.sql +4 -0
- package/src/db/migrations/006_agent_activity.sql +35 -0
- package/src/db/migrations/007_work_coordination.sql +60 -0
- package/src/db/migrations/008_work_claim_leases.sql +20 -0
- package/src/db/migrations/009_billing_subscription_state.sql +23 -0
- package/src/db/migrations/010_agent_api_key_prefix.sql +10 -0
- package/src/db/migrations/011_org_verified_and_work_event_retention.sql +11 -0
- package/src/db/migrations/012_agent_enforcement_mode.sql +12 -0
- package/src/db/migrations/013_support_tickets.sql +21 -0
- package/src/db/migrations/014_paid_plan_waitlist.sql +23 -0
- package/src/db/migrations/015_agent_last_seen.sql +2 -0
- package/src/db/migrations.ts +164 -0
- package/src/db/run-migrations.ts +13 -0
- package/src/index.ts +183 -0
- package/src/lib/activity.ts +137 -0
- package/src/lib/apiKeys.ts +32 -0
- package/src/lib/appInfo.ts +26 -0
- package/src/lib/billingConfig.ts +3 -0
- package/src/lib/env.ts +75 -0
- package/src/lib/logger.ts +8 -0
- package/src/lib/plans.ts +204 -0
- package/src/mcp/server.js +5 -0
- package/src/mcp/server.ts +350 -0
- package/src/middleware/auth.ts +342 -0
- package/src/middleware/requestId.ts +16 -0
- package/src/routes/account.ts +168 -0
- package/src/routes/activity.ts +126 -0
- package/src/routes/admin.ts +514 -0
- package/src/routes/audit.ts +68 -0
- package/src/routes/auth.ts +203 -0
- package/src/routes/availability.ts +325 -0
- package/src/routes/billing.ts +406 -0
- package/src/routes/conflicts.ts +131 -0
- package/src/routes/holds.ts +437 -0
- package/src/routes/mcp.ts +57 -0
- package/src/routes/metrics.ts +39 -0
- package/src/routes/onboarding.ts +273 -0
- package/src/routes/orgs.ts +981 -0
- package/src/routes/preferences.ts +132 -0
- package/src/routes/session.ts +16 -0
- package/src/routes/support.ts +77 -0
- package/src/routes/value.ts +186 -0
- package/src/routes/waitlist.ts +63 -0
- package/src/routes/work.ts +1578 -0
- package/src/server.ts +36 -0
- package/src/types/index.ts +109 -0
- package/tests/integration/activity.route.test.ts +103 -0
- package/tests/integration/admin.route.test.ts +143 -0
- package/tests/integration/agent-keys.route.test.ts +237 -0
- package/tests/integration/availability.route.test.ts +125 -0
- package/tests/integration/billing.route.test.ts +393 -0
- package/tests/integration/conflicts.route.test.ts +131 -0
- package/tests/integration/flows.test.ts +154 -0
- package/tests/integration/helpers.ts +134 -0
- package/tests/integration/holds.route.test.ts +185 -0
- package/tests/integration/metrics.route.test.ts +100 -0
- package/tests/integration/onboarding.verify.route.test.ts +163 -0
- package/tests/integration/preferences.route.test.ts +53 -0
- package/tests/integration/session.route.test.ts +97 -0
- package/tests/integration/system.route.test.ts +92 -0
- package/tests/integration/value.route.test.ts +235 -0
- package/tests/integration/work.route.test.ts +745 -0
- package/tests/setup.ts +4 -0
- package/tests/smoke.sh +62 -0
- package/tests/unit/auth.test.ts +114 -0
- package/tests/unit/availability.test.ts +149 -0
- package/tests/unit/conflict.test.ts +118 -0
- package/tests/unit/env.test.ts +69 -0
- package/tests/unit/migrations.test.ts +135 -0
- package/tests/unit/request-id.test.ts +37 -0
- package/tmp-mobile-agents.png +0 -0
- package/tmp-next-mobile.err.log +10 -0
- package/tmp-next-mobile.log +5 -0
- 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;
|