stagent 0.9.6 → 0.11.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 (396) hide show
  1. package/README.md +20 -44
  2. package/dist/cli.js +66 -18
  3. package/docs/.coverage-gaps.json +144 -56
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +20 -2
  12. package/docs/features/schedules.md +32 -4
  13. package/docs/features/settings.md +28 -5
  14. package/docs/features/shared-components.md +7 -3
  15. package/docs/features/tables.md +11 -2
  16. package/docs/features/tool-permissions.md +6 -2
  17. package/docs/features/workflows.md +14 -4
  18. package/docs/index.md +1 -1
  19. package/docs/journeys/developer.md +39 -2
  20. package/docs/journeys/personal-use.md +32 -8
  21. package/docs/journeys/power-user.md +45 -14
  22. package/docs/journeys/work-use.md +17 -8
  23. package/docs/manifest.json +15 -15
  24. package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
  25. package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
  26. package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
  27. package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
  28. package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
  29. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  30. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  31. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  32. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  33. package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
  34. package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
  35. package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
  36. package/next.config.mjs +1 -0
  37. package/package.json +3 -2
  38. package/src/__tests__/instrumentation-smoke.test.ts +15 -0
  39. package/src/app/analytics/page.tsx +1 -21
  40. package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
  41. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  42. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  43. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  44. package/src/app/api/chat/export/route.ts +52 -0
  45. package/src/app/api/chat/files/search/route.ts +50 -0
  46. package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
  47. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  48. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  49. package/src/app/api/environment/skills/route.ts +13 -0
  50. package/src/app/api/instance/config/route.ts +41 -0
  51. package/src/app/api/instance/init/route.ts +34 -0
  52. package/src/app/api/instance/upgrade/check/route.ts +26 -0
  53. package/src/app/api/instance/upgrade/route.ts +96 -0
  54. package/src/app/api/instance/upgrade/status/route.ts +35 -0
  55. package/src/app/api/memory/route.ts +0 -11
  56. package/src/app/api/notifications/route.ts +4 -2
  57. package/src/app/api/projects/[id]/route.ts +5 -155
  58. package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
  59. package/src/app/api/schedules/[id]/execute/route.ts +111 -0
  60. package/src/app/api/schedules/[id]/route.ts +9 -1
  61. package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
  62. package/src/app/api/schedules/route.ts +3 -12
  63. package/src/app/api/settings/chat/pins/route.ts +94 -0
  64. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  65. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  66. package/src/app/api/settings/environment/route.ts +26 -0
  67. package/src/app/api/settings/openai/login/route.ts +22 -0
  68. package/src/app/api/settings/openai/logout/route.ts +7 -0
  69. package/src/app/api/settings/openai/route.ts +21 -1
  70. package/src/app/api/settings/providers/route.ts +35 -8
  71. package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
  72. package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
  73. package/src/app/api/tables/[id]/enrich/route.ts +147 -0
  74. package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
  75. package/src/app/api/tasks/[id]/execute/route.ts +52 -33
  76. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  77. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  78. package/src/app/api/workflows/[id]/resume/route.ts +59 -0
  79. package/src/app/api/workflows/[id]/status/route.ts +22 -8
  80. package/src/app/api/workspace/context/route.ts +2 -0
  81. package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
  82. package/src/app/chat/page.tsx +11 -0
  83. package/src/app/documents/page.tsx +4 -1
  84. package/src/app/inbox/page.tsx +12 -5
  85. package/src/app/layout.tsx +42 -21
  86. package/src/app/page.tsx +0 -2
  87. package/src/app/settings/page.tsx +8 -9
  88. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  89. package/src/components/chat/__tests__/chat-session-provider.test.tsx +573 -0
  90. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  91. package/src/components/chat/capability-banner.tsx +68 -0
  92. package/src/components/chat/chat-command-popover.tsx +670 -49
  93. package/src/components/chat/chat-input.tsx +104 -10
  94. package/src/components/chat/chat-message.tsx +12 -3
  95. package/src/components/chat/chat-session-provider.tsx +790 -0
  96. package/src/components/chat/chat-shell.tsx +151 -401
  97. package/src/components/chat/command-tab-bar.tsx +68 -0
  98. package/src/components/chat/conversation-template-picker.tsx +421 -0
  99. package/src/components/chat/help-dialog.tsx +39 -0
  100. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  101. package/src/components/chat/skill-row.tsx +147 -0
  102. package/src/components/documents/document-browser.tsx +37 -19
  103. package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
  104. package/src/components/instance/instance-section.tsx +382 -0
  105. package/src/components/instance/upgrade-badge.tsx +219 -0
  106. package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
  107. package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
  108. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  109. package/src/components/notifications/batch-proposal-review.tsx +20 -5
  110. package/src/components/notifications/inbox-list.tsx +11 -2
  111. package/src/components/notifications/notification-item.tsx +56 -2
  112. package/src/components/notifications/pending-approval-host.tsx +56 -37
  113. package/src/components/notifications/permission-response-actions.tsx +155 -1
  114. package/src/components/schedules/schedule-create-sheet.tsx +19 -1
  115. package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
  116. package/src/components/schedules/schedule-form.tsx +31 -0
  117. package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
  118. package/src/components/settings/auth-method-selector.tsx +19 -4
  119. package/src/components/settings/auth-status-badge.tsx +28 -3
  120. package/src/components/settings/environment-section.tsx +102 -0
  121. package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
  122. package/src/components/settings/openai-runtime-section.tsx +7 -1
  123. package/src/components/settings/providers-runtimes-section.tsx +138 -19
  124. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  125. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  126. package/src/components/shared/app-sidebar.tsx +4 -3
  127. package/src/components/shared/command-palette.tsx +266 -7
  128. package/src/components/shared/filter-hint.tsx +70 -0
  129. package/src/components/shared/filter-input.tsx +59 -0
  130. package/src/components/shared/saved-searches-manager.tsx +199 -0
  131. package/src/components/shared/theme-toggle.tsx +5 -24
  132. package/src/components/shared/workspace-indicator.tsx +61 -2
  133. package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
  134. package/src/components/tables/table-create-sheet.tsx +4 -0
  135. package/src/components/tables/table-enrichment-runs.tsx +103 -0
  136. package/src/components/tables/table-enrichment-sheet.tsx +538 -0
  137. package/src/components/tables/table-spreadsheet.tsx +29 -5
  138. package/src/components/tables/table-toolbar.tsx +10 -1
  139. package/src/components/tasks/kanban-board.tsx +1 -0
  140. package/src/components/tasks/kanban-column.tsx +53 -14
  141. package/src/components/tasks/task-bento-grid.tsx +31 -2
  142. package/src/components/tasks/task-card.tsx +29 -3
  143. package/src/components/tasks/task-chip-bar.tsx +54 -1
  144. package/src/components/tasks/task-result-renderer.tsx +1 -1
  145. package/src/components/workflows/delay-step-body.tsx +109 -0
  146. package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
  147. package/src/components/workflows/loop-status-view.tsx +1 -1
  148. package/src/components/workflows/shared/step-result.tsx +78 -0
  149. package/src/components/workflows/shared/workflow-header.tsx +141 -0
  150. package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
  151. package/src/components/workflows/swarm-dashboard.tsx +2 -15
  152. package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
  153. package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
  154. package/src/components/workflows/workflow-form-view.tsx +133 -16
  155. package/src/components/workflows/workflow-status-view.tsx +30 -740
  156. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  157. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  158. package/src/hooks/use-active-skills.ts +110 -0
  159. package/src/hooks/use-chat-autocomplete.ts +120 -7
  160. package/src/hooks/use-enriched-skills.ts +19 -0
  161. package/src/hooks/use-pinned-entries.ts +104 -0
  162. package/src/hooks/use-recent-user-messages.ts +19 -0
  163. package/src/hooks/use-saved-searches.ts +142 -0
  164. package/src/instrumentation-node.ts +94 -0
  165. package/src/instrumentation.ts +4 -48
  166. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  167. package/src/lib/agents/__tests__/claude-agent.test.ts +212 -0
  168. package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
  169. package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
  170. package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
  171. package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
  172. package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
  173. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  174. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  175. package/src/lib/agents/claude-agent.ts +217 -21
  176. package/src/lib/agents/execution-manager.ts +0 -35
  177. package/src/lib/agents/handoff/bus.ts +2 -2
  178. package/src/lib/agents/learned-context.ts +0 -12
  179. package/src/lib/agents/learning-session.ts +18 -5
  180. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  181. package/src/lib/agents/profiles/__tests__/registry.test.ts +53 -4
  182. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +97 -0
  183. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +36 -0
  184. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  185. package/src/lib/agents/profiles/registry.ts +18 -0
  186. package/src/lib/agents/profiles/types.ts +7 -1
  187. package/src/lib/agents/router.ts +3 -6
  188. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  189. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  190. package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
  191. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  192. package/src/lib/agents/runtime/catalog.ts +121 -0
  193. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  194. package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
  195. package/src/lib/agents/runtime/execution-target.ts +456 -0
  196. package/src/lib/agents/runtime/index.ts +4 -0
  197. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  198. package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
  199. package/src/lib/agents/runtime/openai-codex.ts +64 -60
  200. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  201. package/src/lib/agents/runtime/types.ts +8 -0
  202. package/src/lib/agents/task-dispatch.ts +220 -0
  203. package/src/lib/agents/tool-permissions.ts +16 -1
  204. package/src/lib/book/chapter-mapping.ts +11 -0
  205. package/src/lib/book/content.ts +10 -0
  206. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  207. package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
  208. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  209. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  210. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  211. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  212. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  213. package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
  214. package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
  215. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  216. package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
  217. package/src/lib/chat/__tests__/types.test.ts +28 -0
  218. package/src/lib/chat/active-skills.ts +31 -0
  219. package/src/lib/chat/active-streams.ts +27 -0
  220. package/src/lib/chat/clean-filter-input.ts +30 -0
  221. package/src/lib/chat/codex-engine.ts +46 -24
  222. package/src/lib/chat/command-tabs.ts +61 -0
  223. package/src/lib/chat/context-builder.ts +146 -4
  224. package/src/lib/chat/dismissals.ts +73 -0
  225. package/src/lib/chat/engine.ts +159 -18
  226. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  227. package/src/lib/chat/files/expand-mention.ts +76 -0
  228. package/src/lib/chat/files/search.ts +99 -0
  229. package/src/lib/chat/reconcile.ts +117 -0
  230. package/src/lib/chat/skill-composition.ts +210 -0
  231. package/src/lib/chat/skill-conflict.ts +105 -0
  232. package/src/lib/chat/stagent-tools.ts +7 -19
  233. package/src/lib/chat/stream-telemetry.ts +137 -0
  234. package/src/lib/chat/suggested-prompts.ts +28 -1
  235. package/src/lib/chat/system-prompt.ts +48 -1
  236. package/src/lib/chat/tool-catalog.ts +35 -4
  237. package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
  238. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  239. package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
  240. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  241. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  242. package/src/lib/chat/tools/__tests__/task-tools.test.ts +399 -0
  243. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +351 -0
  244. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  245. package/src/lib/chat/tools/document-tools.ts +29 -13
  246. package/src/lib/chat/tools/helpers.ts +41 -0
  247. package/src/lib/chat/tools/notification-tools.ts +9 -5
  248. package/src/lib/chat/tools/profile-tools.ts +120 -23
  249. package/src/lib/chat/tools/project-tools.ts +33 -0
  250. package/src/lib/chat/tools/schedule-tools.ts +44 -11
  251. package/src/lib/chat/tools/skill-tools.ts +183 -0
  252. package/src/lib/chat/tools/table-tools.ts +71 -0
  253. package/src/lib/chat/tools/task-tools.ts +89 -21
  254. package/src/lib/chat/tools/workflow-tools.ts +275 -32
  255. package/src/lib/chat/types.ts +15 -0
  256. package/src/lib/constants/settings.ts +10 -18
  257. package/src/lib/data/__tests__/clear.test.ts +56 -2
  258. package/src/lib/data/clear.ts +17 -16
  259. package/src/lib/data/delete-project.ts +171 -0
  260. package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
  261. package/src/lib/db/bootstrap.ts +62 -16
  262. package/src/lib/db/index.ts +5 -0
  263. package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
  264. package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
  265. package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
  266. package/src/lib/db/migrations/0026_drop_license.sql +3 -0
  267. package/src/lib/db/migrations/meta/_journal.json +21 -0
  268. package/src/lib/db/schema.ts +94 -23
  269. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  270. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  271. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  272. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  273. package/src/lib/environment/data.ts +9 -0
  274. package/src/lib/environment/list-skills.ts +176 -0
  275. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  276. package/src/lib/environment/parsers/skill.ts +26 -5
  277. package/src/lib/environment/profile-generator.ts +54 -0
  278. package/src/lib/environment/skill-enrichment.ts +106 -0
  279. package/src/lib/environment/skill-recommendations.ts +66 -0
  280. package/src/lib/environment/workspace-context.ts +13 -1
  281. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  282. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  283. package/src/lib/filters/parse.ts +86 -0
  284. package/src/lib/import/dedup.ts +4 -54
  285. package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
  286. package/src/lib/instance/__tests__/detect.test.ts +115 -0
  287. package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
  288. package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
  289. package/src/lib/instance/__tests__/settings.test.ts +83 -0
  290. package/src/lib/instance/__tests__/upgrade-poller.test.ts +181 -0
  291. package/src/lib/instance/bootstrap.ts +270 -0
  292. package/src/lib/instance/detect.ts +49 -0
  293. package/src/lib/instance/fingerprint.ts +76 -0
  294. package/src/lib/instance/git-ops.ts +95 -0
  295. package/src/lib/instance/settings.ts +61 -0
  296. package/src/lib/instance/types.ts +77 -0
  297. package/src/lib/instance/upgrade-poller.ts +205 -0
  298. package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
  299. package/src/lib/notifications/visibility.ts +33 -0
  300. package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
  301. package/src/lib/schedules/__tests__/config.test.ts +62 -0
  302. package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
  303. package/src/lib/schedules/__tests__/integration.test.ts +82 -0
  304. package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
  305. package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
  306. package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
  307. package/src/lib/schedules/collision-check.ts +105 -0
  308. package/src/lib/schedules/config.ts +53 -0
  309. package/src/lib/schedules/scheduler.ts +236 -17
  310. package/src/lib/schedules/slot-claim.ts +105 -0
  311. package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
  312. package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
  313. package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
  314. package/src/lib/settings/openai-auth.ts +105 -10
  315. package/src/lib/settings/openai-login-manager.ts +260 -0
  316. package/src/lib/settings/runtime-setup.ts +14 -4
  317. package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
  318. package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
  319. package/src/lib/tables/enrichment-planner.ts +454 -0
  320. package/src/lib/tables/enrichment.ts +328 -0
  321. package/src/lib/tables/query-builder.ts +5 -2
  322. package/src/lib/tables/trigger-evaluator.ts +3 -2
  323. package/src/lib/theme.ts +71 -0
  324. package/src/lib/usage/ledger.ts +2 -18
  325. package/src/lib/util/__tests__/similarity.test.ts +106 -0
  326. package/src/lib/util/similarity.ts +77 -0
  327. package/src/lib/utils/format-timestamp.ts +24 -0
  328. package/src/lib/utils/stagent-paths.ts +12 -0
  329. package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
  330. package/src/lib/validators/__tests__/settings.test.ts +10 -0
  331. package/src/lib/validators/blueprint.ts +70 -9
  332. package/src/lib/validators/profile.ts +2 -2
  333. package/src/lib/validators/settings.ts +3 -1
  334. package/src/lib/workflows/__tests__/delay.test.ts +196 -0
  335. package/src/lib/workflows/__tests__/engine.test.ts +8 -0
  336. package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
  337. package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
  338. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  339. package/src/lib/workflows/blueprints/instantiator.ts +22 -1
  340. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  341. package/src/lib/workflows/blueprints/types.ts +16 -2
  342. package/src/lib/workflows/delay.ts +106 -0
  343. package/src/lib/workflows/engine.ts +212 -7
  344. package/src/lib/workflows/loop-executor.ts +349 -24
  345. package/src/lib/workflows/post-action.ts +91 -0
  346. package/src/lib/workflows/types.ts +166 -1
  347. package/src/test/setup.ts +10 -0
  348. package/src/app/api/license/checkout/route.ts +0 -28
  349. package/src/app/api/license/portal/route.ts +0 -26
  350. package/src/app/api/license/route.ts +0 -89
  351. package/src/app/api/license/usage/route.ts +0 -63
  352. package/src/app/api/marketplace/browse/route.ts +0 -15
  353. package/src/app/api/marketplace/import/route.ts +0 -28
  354. package/src/app/api/marketplace/publish/route.ts +0 -40
  355. package/src/app/api/onboarding/email/route.ts +0 -53
  356. package/src/app/api/settings/telemetry/route.ts +0 -14
  357. package/src/app/api/sync/export/route.ts +0 -54
  358. package/src/app/api/sync/restore/route.ts +0 -37
  359. package/src/app/api/sync/sessions/route.ts +0 -24
  360. package/src/app/auth/callback/route.ts +0 -73
  361. package/src/app/marketplace/page.tsx +0 -19
  362. package/src/components/analytics/analytics-gate-card.tsx +0 -101
  363. package/src/components/marketplace/blueprint-card.tsx +0 -61
  364. package/src/components/marketplace/marketplace-browser.tsx +0 -131
  365. package/src/components/onboarding/email-capture-card.tsx +0 -104
  366. package/src/components/settings/activation-form.tsx +0 -95
  367. package/src/components/settings/cloud-account-section.tsx +0 -147
  368. package/src/components/settings/cloud-sync-section.tsx +0 -155
  369. package/src/components/settings/subscription-section.tsx +0 -410
  370. package/src/components/settings/telemetry-section.tsx +0 -80
  371. package/src/components/shared/premium-gate-overlay.tsx +0 -50
  372. package/src/components/shared/schedule-gate-dialog.tsx +0 -64
  373. package/src/components/shared/upgrade-banner.tsx +0 -112
  374. package/src/hooks/use-supabase-auth.ts +0 -79
  375. package/src/lib/billing/email.ts +0 -54
  376. package/src/lib/billing/products.ts +0 -80
  377. package/src/lib/billing/stripe.ts +0 -101
  378. package/src/lib/cloud/supabase-browser.ts +0 -32
  379. package/src/lib/cloud/supabase-client.ts +0 -56
  380. package/src/lib/license/__tests__/features.test.ts +0 -56
  381. package/src/lib/license/__tests__/key-format.test.ts +0 -88
  382. package/src/lib/license/__tests__/manager.test.ts +0 -64
  383. package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
  384. package/src/lib/license/cloud-validation.ts +0 -60
  385. package/src/lib/license/features.ts +0 -44
  386. package/src/lib/license/key-format.ts +0 -101
  387. package/src/lib/license/limit-check.ts +0 -111
  388. package/src/lib/license/limit-queries.ts +0 -51
  389. package/src/lib/license/manager.ts +0 -345
  390. package/src/lib/license/notifications.ts +0 -59
  391. package/src/lib/license/tier-limits.ts +0 -71
  392. package/src/lib/marketplace/marketplace-client.ts +0 -107
  393. package/src/lib/sync/cloud-sync.ts +0 -235
  394. package/src/lib/telemetry/conversion-events.ts +0 -71
  395. package/src/lib/telemetry/queue.ts +0 -122
  396. package/src/lib/validators/license.ts +0 -33
@@ -0,0 +1,242 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { tasks, schedules, projects, settings } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { randomUUID } from "crypto";
6
+ import { claimSlot, countRunningScheduledSlots } from "../slot-claim";
7
+ import { reapExpiredLeases } from "../slot-claim";
8
+
9
+ function seedProject(): string {
10
+ const id = randomUUID();
11
+ const now = new Date();
12
+ db.insert(projects)
13
+ .values({ id, name: "test", status: "active", createdAt: now, updatedAt: now })
14
+ .run();
15
+ return id;
16
+ }
17
+
18
+ function seedSchedule(projectId: string): string {
19
+ const id = randomUUID();
20
+ const now = new Date();
21
+ db.insert(schedules)
22
+ .values({
23
+ id,
24
+ projectId,
25
+ name: `sched-${id.slice(0, 4)}`,
26
+ prompt: "test",
27
+ cronExpression: "* * * * *",
28
+ status: "active",
29
+ type: "scheduled",
30
+ firingCount: 0,
31
+ suppressionCount: 0,
32
+ heartbeatSpentToday: 0,
33
+ failureStreak: 0,
34
+ turnBudgetBreachStreak: 0,
35
+ createdAt: now,
36
+ updatedAt: now,
37
+ })
38
+ .run();
39
+ return id;
40
+ }
41
+
42
+ function seedQueuedTask(scheduleId: string): string {
43
+ const id = randomUUID();
44
+ const now = new Date();
45
+ db.insert(tasks)
46
+ .values({
47
+ id,
48
+ scheduleId,
49
+ title: "test firing",
50
+ status: "queued",
51
+ priority: 2,
52
+ sourceType: "scheduled",
53
+ resumeCount: 0,
54
+ createdAt: now,
55
+ updatedAt: now,
56
+ })
57
+ .run();
58
+ return id;
59
+ }
60
+
61
+ describe("claimSlot", () => {
62
+ beforeEach(() => {
63
+ db.delete(tasks).run();
64
+ db.delete(schedules).run();
65
+ db.delete(projects).run();
66
+ db.delete(settings).where(eq(settings.key, "schedule.maxConcurrent")).run();
67
+ });
68
+
69
+ it("claims a slot when capacity available, transitioning queued→running", () => {
70
+ const pid = seedProject();
71
+ const sid = seedSchedule(pid);
72
+ const tid = seedQueuedTask(sid);
73
+
74
+ const result = claimSlot(tid, 2, 1200);
75
+
76
+ expect(result.claimed).toBe(true);
77
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
78
+ expect(row?.status).toBe("running");
79
+ expect(row?.slotClaimedAt).not.toBeNull();
80
+ expect(row?.leaseExpiresAt).not.toBeNull();
81
+ });
82
+
83
+ it("refuses to claim when cap=0", () => {
84
+ const pid = seedProject();
85
+ const sid = seedSchedule(pid);
86
+ const tid = seedQueuedTask(sid);
87
+
88
+ const result = claimSlot(tid, 0, 1200);
89
+
90
+ expect(result.claimed).toBe(false);
91
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
92
+ expect(row?.status).toBe("queued");
93
+ });
94
+
95
+ it("refuses when cap already full", () => {
96
+ const pid = seedProject();
97
+ const sid1 = seedSchedule(pid);
98
+ const sid2 = seedSchedule(pid);
99
+ const tid1 = seedQueuedTask(sid1);
100
+ const tid2 = seedQueuedTask(sid2);
101
+
102
+ expect(claimSlot(tid1, 1, 1200).claimed).toBe(true);
103
+ expect(claimSlot(tid2, 1, 1200).claimed).toBe(false);
104
+
105
+ const row2 = db.select().from(tasks).where(eq(tasks.id, tid2)).get();
106
+ expect(row2?.status).toBe("queued");
107
+ });
108
+
109
+ it("two concurrent claim attempts for the same task yield exactly one winner", () => {
110
+ const pid = seedProject();
111
+ const sid = seedSchedule(pid);
112
+ const tid = seedQueuedTask(sid);
113
+
114
+ const first = claimSlot(tid, 10, 1200);
115
+ const second = claimSlot(tid, 10, 1200);
116
+
117
+ expect(first.claimed).toBe(true);
118
+ expect(second.claimed).toBe(false); // task already running, can't re-claim
119
+ });
120
+
121
+ it("respects cap across multiple tasks from different schedules", () => {
122
+ const pid = seedProject();
123
+ const tids: string[] = [];
124
+ for (let i = 0; i < 5; i++) {
125
+ const sid = seedSchedule(pid);
126
+ tids.push(seedQueuedTask(sid));
127
+ }
128
+
129
+ // Cap of 3 → first 3 claim, last 2 fail
130
+ const results = tids.map((tid) => claimSlot(tid, 3, 1200));
131
+ expect(results.filter((r) => r.claimed).length).toBe(3);
132
+ expect(results.filter((r) => !r.claimed).length).toBe(2);
133
+
134
+ expect(countRunningScheduledSlots()).toBe(3);
135
+ });
136
+
137
+ it("countRunningScheduledSlots ignores non-scheduled tasks", () => {
138
+ const pid = seedProject();
139
+ const sid = seedSchedule(pid);
140
+ const schedTid = seedQueuedTask(sid);
141
+ claimSlot(schedTid, 10, 1200);
142
+
143
+ // Insert a manual running task — must not count against scheduled cap
144
+ const manualId = randomUUID();
145
+ const now = new Date();
146
+ db.insert(tasks)
147
+ .values({
148
+ id: manualId,
149
+ title: "manual",
150
+ status: "running",
151
+ priority: 2,
152
+ sourceType: "manual",
153
+ resumeCount: 0,
154
+ createdAt: now,
155
+ updatedAt: now,
156
+ })
157
+ .run();
158
+
159
+ expect(countRunningScheduledSlots()).toBe(1);
160
+ });
161
+
162
+ it("writes leaseExpiresAt = slotClaimedAt + leaseSec", () => {
163
+ const pid = seedProject();
164
+ const sid = seedSchedule(pid);
165
+ const tid = seedQueuedTask(sid);
166
+
167
+ const before = Date.now();
168
+ claimSlot(tid, 10, 60);
169
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
170
+
171
+ expect(row?.slotClaimedAt?.getTime()).toBeGreaterThanOrEqual(before);
172
+ expect(
173
+ row!.leaseExpiresAt!.getTime() - row!.slotClaimedAt!.getTime(),
174
+ ).toBe(60 * 1000);
175
+ });
176
+ });
177
+
178
+ describe("reapExpiredLeases", () => {
179
+ beforeEach(() => {
180
+ db.delete(tasks).run();
181
+ db.delete(schedules).run();
182
+ db.delete(projects).run();
183
+ });
184
+
185
+ it("marks an expired running task as failed with failure_reason=lease_expired", () => {
186
+ const pid = seedProject();
187
+ const sid = seedSchedule(pid);
188
+ const tid = seedQueuedTask(sid);
189
+
190
+ // Claim with a 1-second lease, then fast-forward via direct DB edit
191
+ claimSlot(tid, 10, 1);
192
+ const past = new Date(Date.now() - 5000);
193
+ db.update(tasks)
194
+ .set({ leaseExpiresAt: past })
195
+ .where(eq(tasks.id, tid))
196
+ .run();
197
+
198
+ const reaped = reapExpiredLeases();
199
+
200
+ expect(reaped).toEqual([tid]);
201
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
202
+ expect(row?.status).toBe("failed");
203
+ expect(row?.failureReason).toBe("lease_expired");
204
+ });
205
+
206
+ it("leaves fresh running tasks alone", () => {
207
+ const pid = seedProject();
208
+ const sid = seedSchedule(pid);
209
+ const tid = seedQueuedTask(sid);
210
+
211
+ claimSlot(tid, 10, 3600); // 1-hour lease
212
+
213
+ const reaped = reapExpiredLeases();
214
+
215
+ expect(reaped).toEqual([]);
216
+ const row = db.select().from(tasks).where(eq(tasks.id, tid)).get();
217
+ expect(row?.status).toBe("running");
218
+ });
219
+
220
+ it("reaps multiple expired tasks in one sweep", () => {
221
+ const pid = seedProject();
222
+ const tids: string[] = [];
223
+ for (let i = 0; i < 3; i++) {
224
+ const sid = seedSchedule(pid);
225
+ const tid = seedQueuedTask(sid);
226
+ claimSlot(tid, 10, 1);
227
+ tids.push(tid);
228
+ }
229
+ const past = new Date(Date.now() - 5000);
230
+ for (const tid of tids) {
231
+ db.update(tasks)
232
+ .set({ leaseExpiresAt: past })
233
+ .where(eq(tasks.id, tid))
234
+ .run();
235
+ }
236
+
237
+ const reaped = reapExpiredLeases();
238
+
239
+ expect(reaped.sort()).toEqual([...tids].sort());
240
+ expect(countRunningScheduledSlots()).toBe(0);
241
+ });
242
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { tasks, schedules, projects, settings, scheduleFiringMetrics } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { randomUUID } from "crypto";
6
+ import { tickScheduler } from "../scheduler";
7
+ import { registerChatStream, unregisterChatStream } from "@/lib/chat/active-streams";
8
+
9
+ // Stub the runtime — we're testing coordination, not the SDK
10
+ vi.mock("@/lib/agents/runtime", () => ({
11
+ executeTaskWithRuntime: vi.fn().mockResolvedValue(undefined),
12
+ }));
13
+
14
+ function seedProject(): string {
15
+ const id = randomUUID();
16
+ const now = new Date();
17
+ db.insert(projects)
18
+ .values({ id, name: "test", status: "active", createdAt: now, updatedAt: now })
19
+ .run();
20
+ return id;
21
+ }
22
+
23
+ function seedScheduleDue(projectId: string, nextFireAt: Date): string {
24
+ const id = randomUUID();
25
+ const now = new Date();
26
+ db.insert(schedules)
27
+ .values({
28
+ id,
29
+ projectId,
30
+ name: `sched-${id.slice(0, 4)}`,
31
+ prompt: "test prompt",
32
+ cronExpression: "* * * * *",
33
+ status: "active",
34
+ type: "scheduled",
35
+ firingCount: 0,
36
+ suppressionCount: 0,
37
+ heartbeatSpentToday: 0,
38
+ failureStreak: 0,
39
+ turnBudgetBreachStreak: 0,
40
+ nextFireAt,
41
+ createdAt: now,
42
+ updatedAt: now,
43
+ })
44
+ .run();
45
+ return id;
46
+ }
47
+
48
+ describe("tickScheduler with concurrency cap", () => {
49
+ beforeEach(() => {
50
+ db.delete(scheduleFiringMetrics).run();
51
+ db.delete(tasks).run();
52
+ db.delete(schedules).run();
53
+ db.delete(projects).run();
54
+ db.delete(settings).where(eq(settings.key, "schedule.maxConcurrent")).run();
55
+ db.insert(settings)
56
+ .values({ key: "schedule.maxConcurrent", value: "2", updatedAt: new Date() })
57
+ .run();
58
+ for (const id of ["x", "y", "z"]) unregisterChatStream(id);
59
+ });
60
+
61
+ it("fires up to cap schedules, queues the rest", async () => {
62
+ const pid = seedProject();
63
+ const past = new Date(Date.now() - 10_000);
64
+ for (let i = 0; i < 5; i++) seedScheduleDue(pid, past);
65
+
66
+ await tickScheduler();
67
+
68
+ const runningCount = db
69
+ .select()
70
+ .from(tasks)
71
+ .where(eq(tasks.status, "running"))
72
+ .all().length;
73
+ const queuedCount = db
74
+ .select()
75
+ .from(tasks)
76
+ .where(eq(tasks.status, "queued"))
77
+ .all().length;
78
+
79
+ expect(runningCount).toBe(2); // cap=2
80
+ expect(queuedCount).toBe(3); // remaining 3 waiting
81
+ });
82
+
83
+ it("defers new firings when chat is active", async () => {
84
+ const pid = seedProject();
85
+ const past = new Date(Date.now() - 10_000);
86
+ const sid = seedScheduleDue(pid, past);
87
+
88
+ registerChatStream("x");
89
+
90
+ await tickScheduler();
91
+
92
+ // No task should have been created
93
+ const taskCount = db.select().from(tasks).all().length;
94
+ expect(taskCount).toBe(0);
95
+
96
+ // The schedule's next_fire_at should have been pushed forward ≥25s
97
+ const row = db.select().from(schedules).where(eq(schedules.id, sid)).get();
98
+ expect(row?.nextFireAt?.getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
99
+
100
+ unregisterChatStream("x");
101
+ });
102
+ });
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { db } from "@/lib/db";
3
+ import { tasks, schedules, projects, settings, scheduleFiringMetrics } from "@/lib/db/schema";
4
+ import { eq } from "drizzle-orm";
5
+ import { randomUUID } from "crypto";
6
+ import { tickScheduler, recordFiringMetrics } from "../scheduler";
7
+
8
+ vi.mock("@/lib/agents/runtime", () => ({
9
+ executeTaskWithRuntime: vi.fn().mockResolvedValue(undefined),
10
+ }));
11
+
12
+ describe("per-schedule turn budget propagation", () => {
13
+ beforeEach(() => {
14
+ db.delete(scheduleFiringMetrics).run();
15
+ db.delete(tasks).run();
16
+ db.delete(schedules).run();
17
+ db.delete(projects).run();
18
+ db.delete(settings).where(eq(settings.key, "schedule.maxConcurrent")).run();
19
+ db.insert(settings)
20
+ .values({ key: "schedule.maxConcurrent", value: "10", updatedAt: new Date() })
21
+ .run();
22
+ });
23
+
24
+ it("copies schedules.max_turns into tasks.max_turns at firing time", async () => {
25
+ const pid = randomUUID();
26
+ const sid = randomUUID();
27
+ const now = new Date();
28
+ db.insert(projects)
29
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
30
+ .run();
31
+ db.insert(schedules)
32
+ .values({
33
+ id: sid,
34
+ projectId: pid,
35
+ name: "bounded",
36
+ prompt: "test",
37
+ cronExpression: "* * * * *",
38
+ status: "active",
39
+ type: "scheduled",
40
+ firingCount: 0,
41
+ suppressionCount: 0,
42
+ heartbeatSpentToday: 0,
43
+ failureStreak: 0,
44
+ turnBudgetBreachStreak: 0,
45
+ nextFireAt: new Date(now.getTime() - 10_000),
46
+ maxTurns: 42,
47
+ createdAt: now,
48
+ updatedAt: now,
49
+ })
50
+ .run();
51
+
52
+ await tickScheduler();
53
+
54
+ const [task] = db.select().from(tasks).where(eq(tasks.scheduleId, sid)).all();
55
+ expect(task?.maxTurns).toBe(42);
56
+ });
57
+
58
+ it("leaves tasks.max_turns null when schedules.max_turns is null", async () => {
59
+ const pid = randomUUID();
60
+ const sid = randomUUID();
61
+ const now = new Date();
62
+ db.insert(projects)
63
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
64
+ .run();
65
+ db.insert(schedules)
66
+ .values({
67
+ id: sid,
68
+ projectId: pid,
69
+ name: "unbounded",
70
+ prompt: "test",
71
+ cronExpression: "* * * * *",
72
+ status: "active",
73
+ type: "scheduled",
74
+ firingCount: 0,
75
+ suppressionCount: 0,
76
+ heartbeatSpentToday: 0,
77
+ failureStreak: 0,
78
+ turnBudgetBreachStreak: 0,
79
+ nextFireAt: new Date(now.getTime() - 10_000),
80
+ createdAt: now,
81
+ updatedAt: now,
82
+ })
83
+ .run();
84
+
85
+ await tickScheduler();
86
+
87
+ const [task] = db.select().from(tasks).where(eq(tasks.scheduleId, sid)).all();
88
+ expect(task?.maxTurns).toBeNull();
89
+ });
90
+ });
91
+
92
+ async function seedBreachedTask(scheduleId: string): Promise<string> {
93
+ const id = randomUUID();
94
+ const now = new Date();
95
+ db.insert(tasks)
96
+ .values({
97
+ id,
98
+ scheduleId,
99
+ title: "firing",
100
+ status: "failed",
101
+ result: "Agent exhausted its turn limit (42 turns used)",
102
+ priority: 2,
103
+ sourceType: "scheduled",
104
+ resumeCount: 0,
105
+ failureReason: "turn_limit_exceeded",
106
+ createdAt: now,
107
+ updatedAt: now,
108
+ })
109
+ .run();
110
+ return id;
111
+ }
112
+
113
+ describe("turn_budget_breach_streak", () => {
114
+ beforeEach(() => {
115
+ db.delete(scheduleFiringMetrics).run();
116
+ db.delete(tasks).run();
117
+ db.delete(schedules).run();
118
+ db.delete(projects).run();
119
+ });
120
+
121
+ it("does NOT increment generic failureStreak on turn-budget breach", async () => {
122
+ const pid = randomUUID();
123
+ const sid = randomUUID();
124
+ const now = new Date();
125
+ db.insert(projects)
126
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
127
+ .run();
128
+ db.insert(schedules)
129
+ .values({
130
+ id: sid,
131
+ projectId: pid,
132
+ name: "bounded",
133
+ prompt: "test",
134
+ cronExpression: "* * * * *",
135
+ status: "active",
136
+ type: "scheduled",
137
+ firingCount: 1,
138
+ suppressionCount: 0,
139
+ heartbeatSpentToday: 0,
140
+ failureStreak: 0,
141
+ turnBudgetBreachStreak: 0,
142
+ maxTurns: 20,
143
+ maxTurnsSetAt: new Date(now.getTime() - 86400_000), // yesterday
144
+ createdAt: now,
145
+ updatedAt: now,
146
+ })
147
+ .run();
148
+
149
+ const tid = await seedBreachedTask(sid);
150
+ await recordFiringMetrics(sid, tid);
151
+
152
+ const row = db.select().from(schedules).where(eq(schedules.id, sid)).get();
153
+ expect(row?.failureStreak).toBe(0);
154
+ expect(row?.turnBudgetBreachStreak).toBe(1);
155
+ });
156
+
157
+ it("applies first-breach grace when maxTurns was set recently", async () => {
158
+ const pid = randomUUID();
159
+ const sid = randomUUID();
160
+ const now = new Date();
161
+ db.insert(projects)
162
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
163
+ .run();
164
+ db.insert(schedules)
165
+ .values({
166
+ id: sid,
167
+ projectId: pid,
168
+ name: "bounded",
169
+ prompt: "test",
170
+ cronExpression: "0 * * * *", // hourly
171
+ status: "active",
172
+ type: "scheduled",
173
+ firingCount: 1,
174
+ suppressionCount: 0,
175
+ heartbeatSpentToday: 0,
176
+ failureStreak: 0,
177
+ turnBudgetBreachStreak: 0,
178
+ maxTurns: 20,
179
+ // maxTurnsSetAt 30 min ago → first firing after edit → grace applies
180
+ maxTurnsSetAt: new Date(now.getTime() - 30 * 60 * 1000),
181
+ createdAt: now,
182
+ updatedAt: now,
183
+ })
184
+ .run();
185
+
186
+ const tid = await seedBreachedTask(sid);
187
+ await recordFiringMetrics(sid, tid);
188
+
189
+ const row = db.select().from(schedules).where(eq(schedules.id, sid)).get();
190
+ expect(row?.turnBudgetBreachStreak).toBe(0); // grace applied
191
+ });
192
+
193
+ it("auto-pauses at turn_budget_breach_streak >= 5", async () => {
194
+ const pid = randomUUID();
195
+ const sid = randomUUID();
196
+ const now = new Date();
197
+ db.insert(projects)
198
+ .values({ id: pid, name: "p", status: "active", createdAt: now, updatedAt: now })
199
+ .run();
200
+ db.insert(schedules)
201
+ .values({
202
+ id: sid,
203
+ projectId: pid,
204
+ name: "bounded",
205
+ prompt: "test",
206
+ cronExpression: "* * * * *",
207
+ status: "active",
208
+ type: "scheduled",
209
+ firingCount: 5,
210
+ suppressionCount: 0,
211
+ heartbeatSpentToday: 0,
212
+ failureStreak: 0,
213
+ turnBudgetBreachStreak: 4, // next breach trips the threshold
214
+ maxTurns: 20,
215
+ maxTurnsSetAt: new Date(now.getTime() - 86400_000),
216
+ createdAt: now,
217
+ updatedAt: now,
218
+ })
219
+ .run();
220
+
221
+ const tid = await seedBreachedTask(sid);
222
+ await recordFiringMetrics(sid, tid);
223
+
224
+ const row = db.select().from(schedules).where(eq(schedules.id, sid)).get();
225
+ expect(row?.status).toBe("paused");
226
+ expect(row?.turnBudgetBreachStreak).toBe(5);
227
+ });
228
+ });
@@ -0,0 +1,105 @@
1
+ import { db } from "@/lib/db";
2
+ import { schedules } from "@/lib/db/schema";
3
+ import { and, eq, ne } from "drizzle-orm";
4
+ import { expandCronMinutes } from "./interval-parser";
5
+
6
+ const BUCKET_SIZE_MIN = 5;
7
+ const COLLISION_THRESHOLD_TURNS = 3000;
8
+
9
+ export interface CronCollisionWarning {
10
+ type: "cron_collision";
11
+ overlappingSchedules: string[];
12
+ overlappingMinutes: number[];
13
+ estimatedConcurrentSteps: number;
14
+ }
15
+
16
+ /**
17
+ * Check if a candidate cron collides with existing active schedules in the
18
+ * same project inside a 5-minute bucket, weighted by the sum of their
19
+ * avgTurnsPerFiring. Warns only when combined weight exceeds 3000 steps.
20
+ *
21
+ * Passing an excludeScheduleId skips that schedule (for PATCH/PUT flows where a
22
+ * schedule should not collide with its own prior state).
23
+ *
24
+ * Deterministic — runs against nominal cron expansion, not chat-pressure
25
+ * adjusted times.
26
+ */
27
+ export function checkCollision(
28
+ candidateCron: string,
29
+ candidateAvgTurns: number,
30
+ projectId: string | null,
31
+ excludeScheduleId: string | null,
32
+ ): CronCollisionWarning[] {
33
+ let candidateMinutes: number[];
34
+ try {
35
+ candidateMinutes = expandCronMinutes(candidateCron);
36
+ } catch {
37
+ return [];
38
+ }
39
+
40
+ const candidateBuckets = new Set(
41
+ candidateMinutes.map((m) => Math.floor(m / BUCKET_SIZE_MIN)),
42
+ );
43
+
44
+ const conditions = [eq(schedules.status, "active")];
45
+ if (projectId !== null) {
46
+ conditions.push(eq(schedules.projectId, projectId));
47
+ }
48
+ if (excludeScheduleId !== null) {
49
+ conditions.push(ne(schedules.id, excludeScheduleId));
50
+ }
51
+
52
+ const others = db
53
+ .select({
54
+ id: schedules.id,
55
+ name: schedules.name,
56
+ cronExpression: schedules.cronExpression,
57
+ avgTurnsPerFiring: schedules.avgTurnsPerFiring,
58
+ })
59
+ .from(schedules)
60
+ .where(and(...conditions))
61
+ .all();
62
+
63
+ const overlappingNames: string[] = [];
64
+ const overlappingMinutesSet = new Set<number>();
65
+ let totalOtherTurns = 0;
66
+
67
+ for (const other of others) {
68
+ let otherMinutes: number[];
69
+ try {
70
+ otherMinutes = expandCronMinutes(other.cronExpression);
71
+ } catch {
72
+ continue;
73
+ }
74
+ const otherBuckets = new Set(
75
+ otherMinutes.map((m) => Math.floor(m / BUCKET_SIZE_MIN)),
76
+ );
77
+ const sharedBuckets = [...otherBuckets].filter((b) =>
78
+ candidateBuckets.has(b),
79
+ );
80
+ if (sharedBuckets.length > 0) {
81
+ overlappingNames.push(other.name);
82
+ totalOtherTurns += other.avgTurnsPerFiring ?? 0;
83
+ for (const b of sharedBuckets) {
84
+ overlappingMinutesSet.add(b * BUCKET_SIZE_MIN);
85
+ }
86
+ }
87
+ }
88
+
89
+ const combinedTurns = candidateAvgTurns + totalOtherTurns;
90
+ if (
91
+ overlappingNames.length === 0 ||
92
+ combinedTurns < COLLISION_THRESHOLD_TURNS
93
+ ) {
94
+ return [];
95
+ }
96
+
97
+ return [
98
+ {
99
+ type: "cron_collision",
100
+ overlappingSchedules: overlappingNames,
101
+ overlappingMinutes: [...overlappingMinutesSet].sort((a, b) => a - b),
102
+ estimatedConcurrentSteps: combinedTurns,
103
+ },
104
+ ];
105
+ }