@app-vitals/agent-admin 0.0.1 → 0.1.1

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 (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +62 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,615 @@
1
+ /**
2
+ * agent/src/agent-cron-jobs.ts
3
+ * AgentCronJobService — CRUD for scheduled prompts per agent.
4
+ *
5
+ * Cron expression validation uses an inline 5-field regex check.
6
+ * Delivery target must be either a Slack channel ID or a Slack user ID (DM)
7
+ * — not both, and not neither (unless silent=true).
8
+ */
9
+
10
+ import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
11
+ import {
12
+ type SpecialtyManifestResolver,
13
+ SpecialtyRegistry,
14
+ missingDefaultSpecialtyError,
15
+ resolveDefaultSpecialtyName,
16
+ resolveSpecialtiesDir,
17
+ } from "./specialty-manifest-loader.ts";
18
+ import type { SpecialtyManifest } from "./specialty-registry.ts";
19
+ import { type Clock, SystemClock } from "./clock.ts";
20
+ import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
21
+
22
+ /**
23
+ * The shape of a single cron template resolved from a specialty's manifest.
24
+ * Structurally identical to the former SYSTEM_CRONS SystemCron entries — the
25
+ * manifest is now the source of truth (see specialty-manifest-loader.ts).
26
+ */
27
+ type ManifestCron = SpecialtyManifest["crons"][number];
28
+
29
+ export type { AgentCronJob };
30
+
31
+ interface CronRunSummary {
32
+ startedAt: Date;
33
+ completedAt: Date | null;
34
+ skipped: boolean;
35
+ outcome: string | null;
36
+ }
37
+
38
+ export interface AgentCronJobWithRunSummary extends AgentCronJob {
39
+ lastRun: CronRunSummary | null;
40
+ runCountToday: number;
41
+ }
42
+
43
+ export interface CreateAgentCronJobInput {
44
+ schedule: string;
45
+ prompt: string;
46
+ channel?: string | null;
47
+ user?: string | null;
48
+ silent?: boolean;
49
+ enabled?: boolean;
50
+ preCheck?: string | null;
51
+ name?: string | null;
52
+ system?: boolean;
53
+ }
54
+
55
+ /**
56
+ * Validates a cron expression.
57
+ * Accepts standard 5-field cron: minute hour day-of-month month day-of-week.
58
+ * Each field may contain digits, *, /, -, and comma (e.g. a minute field of
59
+ * "0,30" for a twice-hourly, staggered schedule is valid).
60
+ */
61
+ export function isValidCron(schedule: string): boolean {
62
+ const fields = schedule.trim().split(/\s+/);
63
+ if (fields.length !== 5) return false;
64
+ const fieldPattern = /^(\*|[0-9]+([-,][0-9]+)*)(\/[0-9]+)?$|^\*\/[0-9]+$/;
65
+ return fields.every((f) => fieldPattern.test(f));
66
+ }
67
+
68
+ function validateDeliveryTarget(
69
+ channel: string | null,
70
+ user: string | null,
71
+ silent: boolean,
72
+ ): void {
73
+ if (channel && user) {
74
+ throw new UnprocessableEntityError(
75
+ "channel and user are mutually exclusive — set one or the other, not both",
76
+ );
77
+ }
78
+ if (!silent && !channel && !user) {
79
+ throw new UnprocessableEntityError(
80
+ "at least one of channel or user must be set (unless silent=true)",
81
+ );
82
+ }
83
+ }
84
+
85
+ export class AgentCronJobService {
86
+ constructor(
87
+ private prisma: PrismaClient,
88
+ private clock: Clock = SystemClock(),
89
+ private specialtyRegistry: SpecialtyManifestResolver = new SpecialtyRegistry(),
90
+ ) {}
91
+
92
+ /**
93
+ * List all cron jobs for a given agent.
94
+ */
95
+ async list(agentId: string): Promise<AgentCronJob[]> {
96
+ return this.prisma.agentCronJob.findMany({
97
+ where: { agentId },
98
+ orderBy: { createdAt: "asc" },
99
+ });
100
+ }
101
+
102
+ /**
103
+ * List all cron jobs for a given agent with run summary data.
104
+ * Each job includes:
105
+ * - lastRun: the most recent run record (null if none)
106
+ * - runCountToday: count of runs since midnight UTC today
107
+ */
108
+ async listWithRunSummary(
109
+ agentId: string,
110
+ ): Promise<AgentCronJobWithRunSummary[]> {
111
+ const jobs = await this.prisma.agentCronJob.findMany({
112
+ where: { agentId },
113
+ orderBy: { createdAt: "asc" },
114
+ });
115
+
116
+ // Compute midnight UTC today for the runCountToday boundary
117
+ const todayMidnightUtc = new Date(this.clock.now());
118
+ todayMidnightUtc.setUTCHours(0, 0, 0, 0);
119
+
120
+ // Fetch run summaries for all cron IDs in one pass
121
+ const cronIds = jobs.map((j) => j.id);
122
+ if (cronIds.length === 0) return [];
123
+
124
+ // Most recent run per cron job — use DISTINCT ON to get the latest run
125
+ // per cronId in a single query instead of N parallel findFirst calls.
126
+ const [lastRunsRaw, todayCounts] = await Promise.all([
127
+ // Single query: DISTINCT ON ("cronId") ordered by startedAt DESC gives
128
+ // the most-recent run row per cron, reducing N+1 to one round-trip.
129
+ this.prisma.$queryRaw<
130
+ {
131
+ cronId: string;
132
+ startedAt: Date;
133
+ completedAt: Date | null;
134
+ skipped: boolean;
135
+ outcome: string | null;
136
+ }[]
137
+ >`
138
+ SELECT DISTINCT ON ("cronId")
139
+ "cronId",
140
+ "startedAt",
141
+ "completedAt",
142
+ "skipped",
143
+ "outcome"
144
+ FROM "AgentCronRun"
145
+ WHERE "cronId" = ANY(${cronIds}::text[])
146
+ ORDER BY "cronId", "startedAt" DESC
147
+ `,
148
+ // Count today's runs per cronId
149
+ this.prisma.agentCronRun.groupBy({
150
+ by: ["cronId"],
151
+ where: {
152
+ cronId: { in: cronIds },
153
+ startedAt: { gte: todayMidnightUtc },
154
+ },
155
+ _count: { id: true },
156
+ }),
157
+ ]);
158
+
159
+ // Build lookup maps
160
+ // lastRunsRaw is a flat array of one row per cronId (DISTINCT ON result) —
161
+ // crons with no runs are simply absent, so we default to null via Map.get().
162
+ const lastRunByCronId = new Map<string, CronRunSummary>();
163
+ for (const run of lastRunsRaw) {
164
+ lastRunByCronId.set(run.cronId, {
165
+ startedAt: run.startedAt,
166
+ completedAt: run.completedAt,
167
+ skipped: run.skipped,
168
+ outcome: run.outcome,
169
+ });
170
+ }
171
+
172
+ const todayCountByCronId = new Map<string, number>();
173
+ for (const row of todayCounts) {
174
+ todayCountByCronId.set(row.cronId, row._count.id);
175
+ }
176
+
177
+ return jobs.map((job) => ({
178
+ ...job,
179
+ lastRun: lastRunByCronId.get(job.id) ?? null,
180
+ runCountToday: todayCountByCronId.get(job.id) ?? 0,
181
+ }));
182
+ }
183
+
184
+ async get(agentId: string, cronId: string): Promise<AgentCronJob> {
185
+ const job = await this.prisma.agentCronJob.findUnique({
186
+ where: { id: cronId },
187
+ });
188
+ if (!job || job.agentId !== agentId) {
189
+ throw new NotFoundError(`cron job ${cronId} not found`);
190
+ }
191
+ return job;
192
+ }
193
+
194
+ /**
195
+ * List all enabled cron jobs across all agents.
196
+ * Used by the runtime sync loop on startup and every 60s.
197
+ */
198
+ async listEnabled(): Promise<AgentCronJob[]> {
199
+ return this.prisma.agentCronJob.findMany({
200
+ where: { enabled: true },
201
+ orderBy: { createdAt: "asc" },
202
+ });
203
+ }
204
+
205
+ /**
206
+ * List all enabled cron jobs belonging to an agent whose trial has expired
207
+ * (`trialExpiresAt` strictly before `now`) — the working set for the
208
+ * trial-expiry lockdown sweep (ATE-3.1, admin/src/trial-expiry-sweeper.ts).
209
+ * A single join query rather than an N+1 per-cron agent lookup: filtering
210
+ * on the related `agent.trialExpiresAt` happens in the same round-trip.
211
+ * An agent with `trialExpiresAt` null never matches (Prisma's `lt` filter
212
+ * excludes null), and `now` itself is exclusive (`lt`, not `lte`) so a
213
+ * trial expiring at exactly `now` is not yet treated as expired.
214
+ */
215
+ async listEnabledWithExpiredTrial(now: Date): Promise<AgentCronJob[]> {
216
+ return this.prisma.agentCronJob.findMany({
217
+ where: { enabled: true, agent: { trialExpiresAt: { lt: now } } },
218
+ orderBy: { createdAt: "asc" },
219
+ });
220
+ }
221
+
222
+ /**
223
+ * List all enabled cron jobs belonging to an agent whose account is locked
224
+ * (status 'suspended' or 'trial_expired') — the working set for the
225
+ * account-level lockdown (SSP-8.2, admin/src/account-lifecycle.ts).
226
+ * Optionally narrowed to one account. Agents with accountId null never
227
+ * match.
228
+ */
229
+ async listEnabledInLockedAccounts(
230
+ accountId?: string,
231
+ ): Promise<AgentCronJob[]> {
232
+ return this.prisma.agentCronJob.findMany({
233
+ where: {
234
+ enabled: true,
235
+ agent: {
236
+ account: {
237
+ status: { in: ["suspended", "trial_expired"] },
238
+ ...(accountId !== undefined ? { id: accountId } : {}),
239
+ },
240
+ },
241
+ },
242
+ orderBy: { createdAt: "asc" },
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Create a new cron job for the given agent.
248
+ * Validates:
249
+ * - schedule is a valid 5-field cron expression
250
+ * - channel and user are mutually exclusive (422 if both set)
251
+ * - at least one of channel or user is set (unless silent=true)
252
+ * Throws UnprocessableEntityError for invalid inputs.
253
+ */
254
+ async create(
255
+ agentId: string,
256
+ input: CreateAgentCronJobInput,
257
+ ): Promise<AgentCronJob> {
258
+ if (!isValidCron(input.schedule)) {
259
+ throw new UnprocessableEntityError(
260
+ `invalid cron expression: "${input.schedule}"`,
261
+ );
262
+ }
263
+
264
+ const channel = input.channel ?? null;
265
+ const user = input.user ?? null;
266
+ const silent = input.silent ?? false;
267
+
268
+ validateDeliveryTarget(channel, user, silent);
269
+
270
+ return this.prisma.agentCronJob.create({
271
+ data: {
272
+ agentId,
273
+ schedule: input.schedule,
274
+ prompt: input.prompt,
275
+ channel,
276
+ user,
277
+ silent,
278
+ enabled: input.enabled ?? true,
279
+ preCheck: input.preCheck ?? null,
280
+ name: input.name ?? null,
281
+ system: input.system ?? false,
282
+ },
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Delete a cron job. Verifies agentId ownership before deleting.
288
+ * Throws NotFoundError if the cronId doesn't exist or belongs to a different agent.
289
+ */
290
+ async delete(agentId: string, cronId: string): Promise<void> {
291
+ const existing = await this.prisma.agentCronJob.findUnique({
292
+ where: { id: cronId },
293
+ });
294
+
295
+ if (!existing || existing.agentId !== agentId) {
296
+ throw new NotFoundError(`cron job ${cronId} not found`);
297
+ }
298
+
299
+ await this.prisma.agentCronJob.delete({ where: { id: cronId } });
300
+ }
301
+
302
+ /**
303
+ * Update a cron job's schedule, prompt, channel, user, silent, preCheck, and enabled.
304
+ * Applies the same validation as create():
305
+ * - schedule must be a valid cron expression
306
+ * - channel and user are mutually exclusive (422 if both set)
307
+ * - at least one of channel or user must be set (unless silent=true)
308
+ * Throws NotFoundError if the cronId doesn't exist or belongs to a different agent.
309
+ * Throws UnprocessableEntityError for invalid inputs.
310
+ */
311
+ async update(
312
+ agentId: string,
313
+ cronId: string,
314
+ input: {
315
+ schedule: string;
316
+ prompt: string;
317
+ channel?: string | null;
318
+ user?: string | null;
319
+ silent?: boolean;
320
+ preCheck?: string | null;
321
+ enabled?: boolean;
322
+ },
323
+ ): Promise<AgentCronJob> {
324
+ await this.get(agentId, cronId);
325
+
326
+ if (!isValidCron(input.schedule)) {
327
+ throw new UnprocessableEntityError(
328
+ `invalid cron expression: "${input.schedule}"`,
329
+ );
330
+ }
331
+
332
+ const channel = input.channel ?? null;
333
+ const user = input.user ?? null;
334
+ const silent = input.silent ?? false;
335
+
336
+ validateDeliveryTarget(channel, user, silent);
337
+
338
+ return this.prisma.agentCronJob.update({
339
+ where: { id: cronId },
340
+ data: {
341
+ schedule: input.schedule,
342
+ prompt: input.prompt,
343
+ channel,
344
+ user,
345
+ silent,
346
+ ...(input.preCheck !== undefined && { preCheck: input.preCheck }),
347
+ // Any explicit enabled change is a user decision — clear the lockdown
348
+ // marker so restoreLockdownDisabled() never overrides it.
349
+ ...(input.enabled !== undefined && {
350
+ enabled: input.enabled,
351
+ lockdownDisabledAt: null,
352
+ }),
353
+ },
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Update only the preCheck field of a cron job.
359
+ * Pass null to clear the preCheck script.
360
+ * Throws NotFoundError if the cronId doesn't exist or belongs to a different agent.
361
+ */
362
+ async updatePreCheck(
363
+ agentId: string,
364
+ cronId: string,
365
+ preCheck: string | null,
366
+ ): Promise<AgentCronJob> {
367
+ await this.get(agentId, cronId);
368
+ return this.prisma.agentCronJob.update({
369
+ where: { id: cronId },
370
+ data: { preCheck },
371
+ });
372
+ }
373
+
374
+ /**
375
+ * Update only the delivery-target fields of a cron job (channel, user, silent).
376
+ * Does not touch schedule or prompt.
377
+ * Throws NotFoundError if the cronId doesn't exist or belongs to a different agent.
378
+ */
379
+ async updateDelivery(
380
+ agentId: string,
381
+ cronId: string,
382
+ input: {
383
+ channel?: string | null;
384
+ user?: string | null;
385
+ silent?: boolean;
386
+ },
387
+ ): Promise<AgentCronJob> {
388
+ await this.get(agentId, cronId);
389
+
390
+ return this.prisma.agentCronJob.update({
391
+ where: { id: cronId },
392
+ data: {
393
+ ...(input.channel !== undefined && { channel: input.channel }),
394
+ ...(input.user !== undefined && { user: input.user }),
395
+ ...(input.silent !== undefined && { silent: input.silent }),
396
+ },
397
+ });
398
+ }
399
+
400
+ /**
401
+ * Enable or disable a cron job.
402
+ * Throws NotFoundError if the cronId doesn't exist or belongs to a different agent.
403
+ *
404
+ * `lockdownDisabledAt` tracks who disabled the cron: passing
405
+ * `{ lockdown: true }` with `enabled: false` stamps it (trial-expiry
406
+ * lockdown); every other call clears it, so a manual disable or re-enable
407
+ * never leaves a stale marker for restoreLockdownDisabled() to act on.
408
+ */
409
+ async setEnabled(
410
+ agentId: string,
411
+ cronId: string,
412
+ enabled: boolean,
413
+ opts?: { lockdown?: boolean },
414
+ ): Promise<AgentCronJob> {
415
+ await this.get(agentId, cronId);
416
+
417
+ return this.prisma.agentCronJob.update({
418
+ where: { id: cronId },
419
+ data: {
420
+ enabled,
421
+ lockdownDisabledAt: !enabled && opts?.lockdown ? new Date() : null,
422
+ },
423
+ });
424
+ }
425
+
426
+ /**
427
+ * Re-enable exactly the crons the lockdown disabled for the given agents
428
+ * (lockdownDisabledAt set) and clear the marker. Crons the user disabled
429
+ * manually carry no marker and are left alone. Returns the number restored.
430
+ */
431
+ async restoreLockdownDisabled(agentIds: string[]): Promise<number> {
432
+ if (agentIds.length === 0) return 0;
433
+ const { count } = await this.prisma.agentCronJob.updateMany({
434
+ where: { agentId: { in: agentIds }, lockdownDisabledAt: { not: null } },
435
+ data: { enabled: true, lockdownDisabledAt: null },
436
+ });
437
+ return count;
438
+ }
439
+
440
+ /**
441
+ * Reconcile system crons for the given agent.
442
+ *
443
+ * The cron list is resolved from the agent's type manifest — the agent's
444
+ * stored `typeName` is looked up via the injected SpecialtyRegistry's
445
+ * `tryGetManifest()` (the parsed `specialties/{typeName}/manifest.yaml`),
446
+ * deliberately WITHOUT the "coding" fallback: a non-default type with no
447
+ * loadable manifest logs an error and reconciles nothing (no seeding of
448
+ * coding crons, no deletion of existing system crons), while a "coding"
449
+ * manifest that cannot load throws — the universal fallback failing is a
450
+ * startup bug. This replaced the former hardcoded
451
+ * SYSTEM_CRONS array as the source of truth (ATS-3.2).
452
+ *
453
+ * Two passes so parent/child links resolve regardless of the manifest's
454
+ * cron order or whether this is the agent's very first reconcile (i.e.
455
+ * the parent row may not exist yet when a child entry is processed):
456
+ *
457
+ * Pass 1 — for each manifest cron entry:
458
+ * - If an existing system cron with matching name exists: update it in
459
+ * place with the current manifest definition, preserving its id and
460
+ * existing enabled state. Updating in place (rather than delete+create)
461
+ * keeps the row's id stable across restarts so AgentCronRun history
462
+ * (FK onDelete: Cascade) is never wiped out from under it.
463
+ * - If no matching cron exists: create it with the manifest's default
464
+ * enabled state.
465
+ * Records each entry's resulting row id in a name → id map as it goes.
466
+ *
467
+ * Pass 2 — for each entry that declares `parentCron`, resolves the
468
+ * parent's row id from the name → id map built in pass 1 and sets
469
+ * parentCronId on the child. If an entry does not declare `parentCron`
470
+ * (or declares one that doesn't resolve to a known row), any existing
471
+ * non-null parentCronId on that row is cleared back to null. Self-heals
472
+ * the link on every reconcile call in both directions — null→set (e.g.
473
+ * a pre-existing row from before parentCron was introduced) and set→null
474
+ * (e.g. a parentCron declaration removed from the manifest).
475
+ *
476
+ * Orphan pass: delete any cron with system=true whose name is no longer in
477
+ * the resolved manifest cron list.
478
+ *
479
+ * All three passes run inside a single transaction for atomicity.
480
+ *
481
+ * Returns a summary { created, updated, deleted }.
482
+ */
483
+ async reconcileSystemCrons(
484
+ agentId: string,
485
+ ): Promise<{ created: number; updated: number; deleted: number }> {
486
+ // Resolve the agent's type manifest → its declared cron list. No "coding"
487
+ // fallback: seeding enabled coding crons on a non-coding agent is worse
488
+ // than leaving its crons untouched.
489
+ const agent = await this.prisma.agent.findUnique({
490
+ where: { id: agentId },
491
+ select: { typeName: true },
492
+ });
493
+ if (!agent) {
494
+ throw new NotFoundError(`agent ${agentId} not found`);
495
+ }
496
+ const manifest = this.specialtyRegistry.tryGetManifest(agent.typeName);
497
+ if (!manifest) {
498
+ const defaultName = resolveDefaultSpecialtyName();
499
+ if (agent.typeName === defaultName) {
500
+ throw missingDefaultSpecialtyError(
501
+ defaultName,
502
+ resolveSpecialtiesDir(),
503
+ new Error(`reconcileSystemCrons for agent ${agentId}`),
504
+ );
505
+ }
506
+ console.error(
507
+ `reconcileSystemCrons: no loadable manifest for typeName "${agent.typeName}" (agent ${agentId}) — leaving crons untouched`,
508
+ );
509
+ return { created: 0, updated: 0, deleted: 0 };
510
+ }
511
+ const manifestCrons: readonly ManifestCron[] = manifest.crons;
512
+
513
+ // Fetch all existing system crons for this agent
514
+ const existingSystemCrons = await this.prisma.agentCronJob.findMany({
515
+ where: { agentId, system: true },
516
+ });
517
+
518
+ // Build a map of name → existing cron
519
+ const existingByName = new Map<string, AgentCronJob>();
520
+ for (const cron of existingSystemCrons) {
521
+ if (cron.name) {
522
+ existingByName.set(cron.name, cron);
523
+ }
524
+ }
525
+
526
+ // Build set of valid manifest cron names for orphan detection
527
+ const systemCronNames = new Set(manifestCrons.map((c) => c.name));
528
+
529
+ let created = 0;
530
+ let updated = 0;
531
+ let deleted = 0;
532
+
533
+ // Wrap all mutations in a transaction so a crash mid-loop cannot leave
534
+ // the agent missing required system crons (e.g. shipwright-dev-task).
535
+ await this.prisma.$transaction(async (tx) => {
536
+ // Pass 1: reconcile each manifest cron entry, recording name → row id.
537
+ const idByName = new Map<string, string>();
538
+ for (const systemCron of manifestCrons) {
539
+ const existing = existingByName.get(systemCron.name);
540
+
541
+ if (existing) {
542
+ // Update in place — preserves id (and thus AgentCronRun history,
543
+ // which cascade-deletes when its AgentCronJob is deleted) and the
544
+ // existing enabled state.
545
+ await tx.agentCronJob.update({
546
+ where: { id: existing.id },
547
+ data: {
548
+ schedule: systemCron.schedule,
549
+ prompt: systemCron.prompt,
550
+ silent: systemCron.silent ?? false,
551
+ preCheck: systemCron.preCheck ?? null,
552
+ },
553
+ });
554
+ idByName.set(systemCron.name, existing.id);
555
+ updated++;
556
+ } else {
557
+ const createdCron = await tx.agentCronJob.create({
558
+ data: {
559
+ agentId,
560
+ name: systemCron.name,
561
+ system: true,
562
+ schedule: systemCron.schedule,
563
+ prompt: systemCron.prompt,
564
+ silent: systemCron.silent ?? false,
565
+ preCheck: systemCron.preCheck ?? null,
566
+ channel: null,
567
+ user: null,
568
+ enabled: systemCron.enabled,
569
+ },
570
+ });
571
+ idByName.set(systemCron.name, createdCron.id);
572
+ created++;
573
+ }
574
+ }
575
+
576
+ // Pass 2: link declared parent/child crons now that every row in this
577
+ // reconcile has a known id, regardless of array order or first-boot.
578
+ // Also clears parentCronId back to null when an entry no longer
579
+ // declares a resolvable parentCron, so the link self-heals in both
580
+ // directions rather than only null→set.
581
+ for (const systemCron of manifestCrons) {
582
+ const childId = idByName.get(systemCron.name);
583
+ if (!childId) continue;
584
+ const parentId = systemCron.parentCron
585
+ ? idByName.get(systemCron.parentCron)
586
+ : undefined;
587
+
588
+ if (parentId) {
589
+ await tx.agentCronJob.update({
590
+ where: { id: childId },
591
+ data: { parentCronId: parentId },
592
+ });
593
+ } else {
594
+ const existing = existingByName.get(systemCron.name);
595
+ if (existing && existing.parentCronId !== null) {
596
+ await tx.agentCronJob.update({
597
+ where: { id: childId },
598
+ data: { parentCronId: null },
599
+ });
600
+ }
601
+ }
602
+ }
603
+
604
+ // Orphan pass: delete any system cron whose name is not in the manifest.
605
+ for (const cron of existingSystemCrons) {
606
+ if (!cron.name || !systemCronNames.has(cron.name)) {
607
+ await tx.agentCronJob.delete({ where: { id: cron.id } });
608
+ deleted++;
609
+ }
610
+ }
611
+ });
612
+
613
+ return { created, updated, deleted };
614
+ }
615
+ }