@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,1491 @@
1
+ /**
2
+ * admin/src/agent-cron-runs.integration.test.ts
3
+ * Integration tests for AgentCronRunService — create/list run records.
4
+ *
5
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import type { PrismaClient } from "../prisma/client/client.ts";
10
+ import { AgentCronJobService } from "./agent-cron-jobs.ts";
11
+ import { AgentCronRunService } from "./agent-cron-runs.ts";
12
+ import { FixedClock } from "./clock.ts";
13
+ import { NotFoundError } from "./errors.ts";
14
+ import { createAdminPrismaClient } from "./prisma-client.ts";
15
+
16
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
17
+
18
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
19
+
20
+ function makePrisma(): PrismaClient {
21
+ // TEST_DB is guaranteed set — the describe block is skipped otherwise.
22
+ return createAdminPrismaClient(TEST_DB as string);
23
+ }
24
+
25
+ async function createAgent(
26
+ prisma: PrismaClient,
27
+ name = "Test Agent",
28
+ ): Promise<string> {
29
+ const agent = await prisma.agent.create({ data: { name } });
30
+ return agent.id;
31
+ }
32
+
33
+ async function createCron(
34
+ cronJobService: AgentCronJobService,
35
+ agentId: string,
36
+ ): Promise<string> {
37
+ const job = await cronJobService.create(agentId, {
38
+ schedule: "0 9 * * *",
39
+ prompt: "Test prompt",
40
+ silent: true,
41
+ });
42
+ return job.id;
43
+ }
44
+
45
+ // Fixed "now" for deterministic day-boundary tests: 2026-01-15T12:00:00Z
46
+ // setUTCHours(0,0,0,0) on this gives 2026-01-15T00:00:00.000Z
47
+ const FIXED_NOW = new Date("2026-01-15T12:00:00Z");
48
+
49
+ describeOrSkip("AgentCronRunService (integration)", () => {
50
+ let prisma: PrismaClient;
51
+ let cronJobService: AgentCronJobService;
52
+ let runService: AgentCronRunService;
53
+
54
+ beforeEach(async () => {
55
+ prisma = makePrisma();
56
+ await prisma.agentCronRun.deleteMany();
57
+ await prisma.agentToken.deleteMany();
58
+ await prisma.agentCronJob.deleteMany();
59
+ await prisma.agentTool.deleteMany();
60
+ await prisma.agentEnv.deleteMany();
61
+ await prisma.agent.deleteMany();
62
+ cronJobService = new AgentCronJobService(prisma, FixedClock(FIXED_NOW));
63
+ runService = new AgentCronRunService(prisma);
64
+ });
65
+
66
+ afterEach(async () => {
67
+ await prisma.$disconnect();
68
+ });
69
+
70
+ // ─── create ─────────────────────────────────────────────────────────────────
71
+
72
+ it("create() creates a run record and returns 201 data", async () => {
73
+ const agentId = await createAgent(prisma);
74
+ const cronId = await createCron(cronJobService, agentId);
75
+ const startedAt = new Date();
76
+
77
+ const run = await runService.create(cronId, agentId, {
78
+ startedAt,
79
+ skipped: false,
80
+ outcome: "success",
81
+ });
82
+
83
+ expect(run.cronId).toBe(cronId);
84
+ expect(run.agentId).toBe(agentId);
85
+ expect(run.skipped).toBe(false);
86
+ expect(run.outcome).toBe("success");
87
+ expect(run.startedAt).toEqual(startedAt);
88
+ expect(run.completedAt).toBeNull();
89
+ expect(run.error).toBeNull();
90
+ expect(run.skipReason).toBeNull();
91
+ });
92
+
93
+ it("create() supports optional completedAt, skipReason, error fields", async () => {
94
+ const agentId = await createAgent(prisma);
95
+ const cronId = await createCron(cronJobService, agentId);
96
+ const startedAt = new Date();
97
+ const completedAt = new Date(startedAt.getTime() + 5000);
98
+
99
+ const run = await runService.create(cronId, agentId, {
100
+ startedAt,
101
+ completedAt,
102
+ skipped: true,
103
+ skipReason: "pre-check returned false",
104
+ error: null,
105
+ outcome: null,
106
+ });
107
+
108
+ expect(run.completedAt).toEqual(completedAt);
109
+ expect(run.skipped).toBe(true);
110
+ expect(run.skipReason).toBe("pre-check returned false");
111
+ });
112
+
113
+ it("create() round-trips an optional phaseId field", async () => {
114
+ const agentId = await createAgent(prisma);
115
+ const cronId = await createCron(cronJobService, agentId);
116
+ const phaseCron = await cronJobService.create(agentId, {
117
+ schedule: "0 9 * * *",
118
+ prompt: "Test prompt",
119
+ silent: true,
120
+ name: "shipwright-dev-task",
121
+ });
122
+ const startedAt = new Date();
123
+
124
+ const run = await runService.create(cronId, agentId, {
125
+ startedAt,
126
+ skipped: false,
127
+ outcome: "success",
128
+ phaseId: phaseCron.id,
129
+ });
130
+
131
+ expect(run.phaseId).toBe(phaseCron.id);
132
+
133
+ const { items } = await runService.list(cronId, agentId);
134
+ expect(items[0].phaseId).toBe(phaseCron.id);
135
+ });
136
+
137
+ it("create() leaves phaseId null when not provided (legacy behavior unchanged)", async () => {
138
+ const agentId = await createAgent(prisma);
139
+ const cronId = await createCron(cronJobService, agentId);
140
+ const startedAt = new Date();
141
+
142
+ const run = await runService.create(cronId, agentId, {
143
+ startedAt,
144
+ skipped: false,
145
+ outcome: "success",
146
+ });
147
+
148
+ expect(run.phaseId).toBeNull();
149
+
150
+ const { items } = await runService.list(cronId, agentId);
151
+ expect(items[0].phaseId).toBeNull();
152
+ });
153
+
154
+ it("create() round-trips optional itemType/itemId fields for a task item", async () => {
155
+ const agentId = await createAgent(prisma);
156
+ const cronId = await createCron(cronJobService, agentId);
157
+ const startedAt = new Date();
158
+
159
+ const run = await runService.create(cronId, agentId, {
160
+ startedAt,
161
+ skipped: false,
162
+ outcome: "success",
163
+ itemType: "task",
164
+ itemId: "WLS-2.2",
165
+ });
166
+
167
+ expect(run.itemType).toBe("task");
168
+ expect(run.itemId).toBe("WLS-2.2");
169
+
170
+ const { items } = await runService.list(cronId, agentId);
171
+ expect(items[0].itemType).toBe("task");
172
+ expect(items[0].itemId).toBe("WLS-2.2");
173
+ });
174
+
175
+ it("create() round-trips optional itemType/itemId fields for a PR item", async () => {
176
+ const agentId = await createAgent(prisma);
177
+ const cronId = await createCron(cronJobService, agentId);
178
+ const startedAt = new Date();
179
+
180
+ const run = await runService.create(cronId, agentId, {
181
+ startedAt,
182
+ skipped: false,
183
+ outcome: "success",
184
+ itemType: "pr",
185
+ itemId: "acme/x#123",
186
+ });
187
+
188
+ expect(run.itemType).toBe("pr");
189
+ expect(run.itemId).toBe("acme/x#123");
190
+
191
+ const { items } = await runService.list(cronId, agentId);
192
+ expect(items[0].itemType).toBe("pr");
193
+ expect(items[0].itemId).toBe("acme/x#123");
194
+ });
195
+
196
+ it("create() leaves itemType/itemId null when not provided (skipped tick with no dispatch)", async () => {
197
+ const agentId = await createAgent(prisma);
198
+ const cronId = await createCron(cronJobService, agentId);
199
+ const startedAt = new Date();
200
+
201
+ const run = await runService.create(cronId, agentId, {
202
+ startedAt,
203
+ skipped: false,
204
+ outcome: "success",
205
+ });
206
+
207
+ expect(run.itemType).toBeNull();
208
+ expect(run.itemId).toBeNull();
209
+
210
+ const { items } = await runService.list(cronId, agentId);
211
+ expect(items[0].itemType).toBeNull();
212
+ expect(items[0].itemId).toBeNull();
213
+ });
214
+
215
+ it("create() throws NotFoundError when cronId does not exist", async () => {
216
+ const agentId = await createAgent(prisma);
217
+
218
+ await expect(
219
+ runService.create("nonexistent-cron-id", agentId, {
220
+ startedAt: new Date(),
221
+ skipped: false,
222
+ }),
223
+ ).rejects.toBeInstanceOf(NotFoundError);
224
+ });
225
+
226
+ it("create() throws NotFoundError when cronId belongs to a different agent", async () => {
227
+ const agentId1 = await createAgent(prisma, "Agent 1");
228
+ const agentId2 = await createAgent(prisma, "Agent 2");
229
+ const cronId = await createCron(cronJobService, agentId1);
230
+
231
+ await expect(
232
+ runService.create(cronId, agentId2, {
233
+ startedAt: new Date(),
234
+ skipped: false,
235
+ }),
236
+ ).rejects.toBeInstanceOf(NotFoundError);
237
+ });
238
+
239
+ // ─── list ──────────────────────────────────────────────────────────────────
240
+
241
+ it("list() returns runs sorted descending by startedAt", async () => {
242
+ const agentId = await createAgent(prisma);
243
+ const cronId = await createCron(cronJobService, agentId);
244
+
245
+ const t1 = new Date("2026-01-01T08:00:00Z");
246
+ const t2 = new Date("2026-01-02T08:00:00Z");
247
+ const t3 = new Date("2026-01-03T08:00:00Z");
248
+
249
+ await runService.create(cronId, agentId, { startedAt: t1, skipped: false });
250
+ await runService.create(cronId, agentId, { startedAt: t2, skipped: false });
251
+ await runService.create(cronId, agentId, { startedAt: t3, skipped: false });
252
+
253
+ const { items, total } = await runService.list(cronId, agentId);
254
+
255
+ expect(total).toBe(3);
256
+ expect(items).toHaveLength(3);
257
+ // Descending: t3, t2, t1
258
+ expect(items[0].startedAt).toEqual(t3);
259
+ expect(items[1].startedAt).toEqual(t2);
260
+ expect(items[2].startedAt).toEqual(t1);
261
+ });
262
+
263
+ it("list() defaults to limit 20", async () => {
264
+ const agentId = await createAgent(prisma);
265
+ const cronId = await createCron(cronJobService, agentId);
266
+
267
+ // Create 25 runs
268
+ for (let i = 0; i < 25; i++) {
269
+ await runService.create(cronId, agentId, {
270
+ startedAt: new Date(Date.now() + i * 1000),
271
+ skipped: false,
272
+ });
273
+ }
274
+
275
+ const { items, total } = await runService.list(cronId, agentId);
276
+ expect(items).toHaveLength(20);
277
+ expect(total).toBe(25);
278
+ });
279
+
280
+ it("list() supports custom limit and offset", async () => {
281
+ const agentId = await createAgent(prisma);
282
+ const cronId = await createCron(cronJobService, agentId);
283
+
284
+ for (let i = 0; i < 5; i++) {
285
+ await runService.create(cronId, agentId, {
286
+ startedAt: new Date(Date.now() + i * 1000),
287
+ skipped: false,
288
+ });
289
+ }
290
+
291
+ const { items, total } = await runService.list(cronId, agentId, {
292
+ limit: 2,
293
+ offset: 1,
294
+ });
295
+ expect(items).toHaveLength(2);
296
+ expect(total).toBe(5);
297
+ });
298
+
299
+ it("list() throws NotFoundError when cronId belongs to a different agent", async () => {
300
+ const agentId1 = await createAgent(prisma, "Agent 1");
301
+ const agentId2 = await createAgent(prisma, "Agent 2");
302
+ const cronId = await createCron(cronJobService, agentId1);
303
+
304
+ await expect(runService.list(cronId, agentId2)).rejects.toBeInstanceOf(
305
+ NotFoundError,
306
+ );
307
+ });
308
+
309
+ it("list() opts.itemId narrows results to runs dispatched against that item", async () => {
310
+ const agentId = await createAgent(prisma);
311
+ const cronId = await createCron(cronJobService, agentId);
312
+
313
+ await runService.create(cronId, agentId, {
314
+ startedAt: new Date("2026-01-01T08:00:00Z"),
315
+ skipped: false,
316
+ itemType: "task",
317
+ itemId: "WLS-2.2",
318
+ });
319
+ await runService.create(cronId, agentId, {
320
+ startedAt: new Date("2026-01-02T08:00:00Z"),
321
+ skipped: false,
322
+ itemType: "pr",
323
+ itemId: "acme/x#123",
324
+ });
325
+ await runService.create(cronId, agentId, {
326
+ startedAt: new Date("2026-01-03T08:00:00Z"),
327
+ skipped: false,
328
+ });
329
+
330
+ const { items, total } = await runService.list(cronId, agentId, {
331
+ itemId: "WLS-2.2",
332
+ });
333
+
334
+ expect(total).toBe(1);
335
+ expect(items).toHaveLength(1);
336
+ expect(items[0].itemId).toBe("WLS-2.2");
337
+ });
338
+
339
+ it("list() opts.phaseId narrows results to runs dispatched by that phase cron", async () => {
340
+ const agentId = await createAgent(prisma);
341
+ const cronId = await createCron(cronJobService, agentId);
342
+ const phaseCronReview = await cronJobService.create(agentId, {
343
+ schedule: "0 9 * * *",
344
+ prompt: "Test prompt",
345
+ silent: true,
346
+ name: "shipwright-review",
347
+ });
348
+ const phaseCronDeploy = await cronJobService.create(agentId, {
349
+ schedule: "0 9 * * *",
350
+ prompt: "Test prompt",
351
+ silent: true,
352
+ name: "shipwright-deploy",
353
+ });
354
+
355
+ await runService.create(cronId, agentId, {
356
+ startedAt: new Date("2026-01-01T08:00:00Z"),
357
+ skipped: false,
358
+ phaseId: phaseCronReview.id,
359
+ });
360
+ await runService.create(cronId, agentId, {
361
+ startedAt: new Date("2026-01-02T08:00:00Z"),
362
+ skipped: false,
363
+ phaseId: phaseCronDeploy.id,
364
+ });
365
+ await runService.create(cronId, agentId, {
366
+ startedAt: new Date("2026-01-03T08:00:00Z"),
367
+ skipped: false,
368
+ });
369
+
370
+ const { items, total } = await runService.list(cronId, agentId, {
371
+ phaseId: phaseCronReview.id,
372
+ });
373
+
374
+ expect(total).toBe(1);
375
+ expect(items).toHaveLength(1);
376
+ expect(items[0].phaseId).toBe(phaseCronReview.id);
377
+ });
378
+
379
+ it("list() combines itemId and phaseId filters (AND, not OR)", async () => {
380
+ const agentId = await createAgent(prisma);
381
+ const cronId = await createCron(cronJobService, agentId);
382
+ const phaseCronReview = await cronJobService.create(agentId, {
383
+ schedule: "0 9 * * *",
384
+ prompt: "Test prompt",
385
+ silent: true,
386
+ name: "shipwright-review",
387
+ });
388
+ const phaseCronDeploy = await cronJobService.create(agentId, {
389
+ schedule: "0 9 * * *",
390
+ prompt: "Test prompt",
391
+ silent: true,
392
+ name: "shipwright-deploy",
393
+ });
394
+
395
+ // Matches both filters
396
+ const matching = await runService.create(cronId, agentId, {
397
+ startedAt: new Date("2026-01-01T08:00:00Z"),
398
+ skipped: false,
399
+ itemType: "pr",
400
+ itemId: "acme/x#123",
401
+ phaseId: phaseCronReview.id,
402
+ });
403
+ // Same itemId, different phaseId — must not match
404
+ await runService.create(cronId, agentId, {
405
+ startedAt: new Date("2026-01-02T08:00:00Z"),
406
+ skipped: false,
407
+ itemType: "pr",
408
+ itemId: "acme/x#123",
409
+ phaseId: phaseCronDeploy.id,
410
+ });
411
+ // Same phaseId, different itemId — must not match
412
+ await runService.create(cronId, agentId, {
413
+ startedAt: new Date("2026-01-03T08:00:00Z"),
414
+ skipped: false,
415
+ itemType: "pr",
416
+ itemId: "acme/y#456",
417
+ phaseId: phaseCronReview.id,
418
+ });
419
+
420
+ const { items, total } = await runService.list(cronId, agentId, {
421
+ itemId: "acme/x#123",
422
+ phaseId: phaseCronReview.id,
423
+ });
424
+
425
+ expect(total).toBe(1);
426
+ expect(items).toHaveLength(1);
427
+ expect(items[0].id).toBe(matching.id);
428
+ });
429
+
430
+ it("list() with neither itemId nor phaseId returns unfiltered results (existing behavior unchanged)", async () => {
431
+ const agentId = await createAgent(prisma);
432
+ const cronId = await createCron(cronJobService, agentId);
433
+
434
+ await runService.create(cronId, agentId, {
435
+ startedAt: new Date("2026-01-01T08:00:00Z"),
436
+ skipped: false,
437
+ itemId: "WLS-2.2",
438
+ });
439
+ await runService.create(cronId, agentId, {
440
+ startedAt: new Date("2026-01-02T08:00:00Z"),
441
+ skipped: false,
442
+ });
443
+
444
+ const { items, total } = await runService.list(cronId, agentId, {
445
+ limit: 10,
446
+ offset: 0,
447
+ });
448
+
449
+ expect(total).toBe(2);
450
+ expect(items).toHaveLength(2);
451
+ });
452
+
453
+ it("list() returns modelBreakdown rows for a run with multiple models", async () => {
454
+ const agentId = await createAgent(prisma);
455
+ const cronId = await createCron(cronJobService, agentId);
456
+
457
+ const run = await runService.create(cronId, agentId, {
458
+ startedAt: new Date("2026-01-15T10:00:00Z"),
459
+ skipped: false,
460
+ });
461
+
462
+ await runService.patch(run.id, agentId, cronId, {
463
+ outcome: "success",
464
+ modelBreakdown: [
465
+ {
466
+ model: "claude-sonnet-4-5",
467
+ inputTokens: 200,
468
+ outputTokens: 100,
469
+ cacheReadTokens: 8,
470
+ cacheCreationTokens: 4,
471
+ costUsd: 0.002,
472
+ },
473
+ {
474
+ model: "claude-haiku-4-5",
475
+ inputTokens: 50,
476
+ outputTokens: 20,
477
+ cacheReadTokens: 0,
478
+ cacheCreationTokens: 0,
479
+ costUsd: 0.0005,
480
+ },
481
+ ],
482
+ });
483
+
484
+ const { items } = await runService.list(cronId, agentId);
485
+
486
+ expect(items).toHaveLength(1);
487
+ const breakdown = items[0].modelBreakdown;
488
+ expect(breakdown).toHaveLength(2);
489
+ const models = breakdown.map((b) => b.model).sort();
490
+ expect(models).toEqual(["claude-haiku-4-5", "claude-sonnet-4-5"]);
491
+ const sonnet = breakdown.find((b) => b.model === "claude-sonnet-4-5");
492
+ expect(sonnet?.inputTokens).toBe(200);
493
+ expect(sonnet?.outputTokens).toBe(100);
494
+ expect(sonnet?.cacheReadTokens).toBe(8);
495
+ expect(sonnet?.cacheCreationTokens).toBe(4);
496
+ expect(sonnet?.costUsd).toBeCloseTo(0.002);
497
+ });
498
+
499
+ it("list() returns an empty modelBreakdown array for a run with no model breakdown rows", async () => {
500
+ const agentId = await createAgent(prisma);
501
+ const cronId = await createCron(cronJobService, agentId);
502
+
503
+ await runService.create(cronId, agentId, {
504
+ startedAt: new Date("2026-01-15T10:00:00Z"),
505
+ skipped: false,
506
+ });
507
+
508
+ const { items } = await runService.list(cronId, agentId);
509
+
510
+ expect(items).toHaveLength(1);
511
+ expect(items[0].modelBreakdown).toEqual([]);
512
+ });
513
+
514
+ // ─── patch ──────────────────────────────────────────────────────────────────
515
+
516
+ it("patch() updates completion fields and ignores legacy token fields", async () => {
517
+ const agentId = await createAgent(prisma);
518
+ const cronId = await createCron(cronJobService, agentId);
519
+ const startedAt = new Date("2026-01-15T10:00:00Z");
520
+ const completedAt = new Date("2026-01-15T10:05:00Z");
521
+
522
+ const run = await runService.create(cronId, agentId, {
523
+ startedAt,
524
+ skipped: false,
525
+ });
526
+
527
+ // Legacy top-level token fields are still accepted in the input (older agent
528
+ // builds send them) but are no longer persisted — the columns were dropped
529
+ // and all token accounting flows through AgentCronRunModelBreakdown.
530
+ const updated = await runService.patch(run.id, agentId, cronId, {
531
+ completedAt,
532
+ outcome: "success",
533
+ inputTokens: 1234,
534
+ outputTokens: 567,
535
+ cacheReadTokens: 89,
536
+ cacheCreationTokens: 10,
537
+ });
538
+
539
+ expect(updated.id).toBe(run.id);
540
+ expect(updated.completedAt).toEqual(completedAt);
541
+ expect(updated.outcome).toBe("success");
542
+ // Token columns no longer exist on the run; no breakdown rows were written
543
+ // because the payload carried no modelBreakdown.
544
+ expect(updated.modelBreakdown).toEqual([]);
545
+ });
546
+
547
+ it("patch() updates error and skipped fields", async () => {
548
+ const agentId = await createAgent(prisma);
549
+ const cronId = await createCron(cronJobService, agentId);
550
+ const startedAt = new Date("2026-01-15T10:00:00Z");
551
+
552
+ const run = await runService.create(cronId, agentId, {
553
+ startedAt,
554
+ skipped: false,
555
+ });
556
+
557
+ const updated = await runService.patch(run.id, agentId, cronId, {
558
+ completedAt: new Date("2026-01-15T10:01:00Z"),
559
+ outcome: "error",
560
+ error: "something went wrong",
561
+ });
562
+
563
+ expect(updated.outcome).toBe("error");
564
+ expect(updated.error).toBe("something went wrong");
565
+ });
566
+
567
+ it("patch() throws NotFoundError when runId does not exist", async () => {
568
+ const agentId = await createAgent(prisma);
569
+ const cronId = await createCron(cronJobService, agentId);
570
+
571
+ await expect(
572
+ runService.patch("nonexistent-run-id", agentId, cronId, {
573
+ outcome: "success",
574
+ }),
575
+ ).rejects.toBeInstanceOf(NotFoundError);
576
+ });
577
+
578
+ it("patch() throws NotFoundError when runId belongs to a different agent", async () => {
579
+ const agentId1 = await createAgent(prisma, "Agent 1");
580
+ const agentId2 = await createAgent(prisma, "Agent 2");
581
+ const cronId = await createCron(cronJobService, agentId1);
582
+
583
+ const run = await runService.create(cronId, agentId1, {
584
+ startedAt: new Date(),
585
+ skipped: false,
586
+ });
587
+
588
+ await expect(
589
+ runService.patch(run.id, agentId2, cronId, {
590
+ outcome: "success",
591
+ }),
592
+ ).rejects.toBeInstanceOf(NotFoundError);
593
+ });
594
+
595
+ it("patch() throws NotFoundError when runId belongs to a different cron", async () => {
596
+ const agentId = await createAgent(prisma);
597
+ const cronId1 = await createCron(cronJobService, agentId);
598
+ const cronId2 = await createCron(cronJobService, agentId);
599
+
600
+ const run = await runService.create(cronId1, agentId, {
601
+ startedAt: new Date(),
602
+ skipped: false,
603
+ });
604
+
605
+ await expect(
606
+ runService.patch(run.id, agentId, cronId2, {
607
+ outcome: "success",
608
+ }),
609
+ ).rejects.toBeInstanceOf(NotFoundError);
610
+ });
611
+
612
+ it("patch() round-trips a sessionId field", async () => {
613
+ const agentId = await createAgent(prisma);
614
+ const cronId = await createCron(cronJobService, agentId);
615
+
616
+ const run = await runService.create(cronId, agentId, {
617
+ startedAt: new Date(),
618
+ skipped: false,
619
+ });
620
+ expect(run.sessionId).toBeNull();
621
+
622
+ const updated = await runService.patch(run.id, agentId, cronId, {
623
+ sessionId: "session-abc-123",
624
+ });
625
+
626
+ expect(updated.sessionId).toBe("session-abc-123");
627
+ });
628
+
629
+ it("patch() clears sessionId when explicitly set to null", async () => {
630
+ const agentId = await createAgent(prisma);
631
+ const cronId = await createCron(cronJobService, agentId);
632
+
633
+ const run = await runService.create(cronId, agentId, {
634
+ startedAt: new Date(),
635
+ skipped: false,
636
+ });
637
+
638
+ await runService.patch(run.id, agentId, cronId, {
639
+ sessionId: "session-to-be-cleared",
640
+ });
641
+ const cleared = await runService.patch(run.id, agentId, cronId, {
642
+ sessionId: null,
643
+ });
644
+
645
+ expect(cleared.sessionId).toBeNull();
646
+ });
647
+
648
+ it("patch() leaves sessionId untouched when omitted from the input", async () => {
649
+ const agentId = await createAgent(prisma);
650
+ const cronId = await createCron(cronJobService, agentId);
651
+
652
+ const run = await runService.create(cronId, agentId, {
653
+ startedAt: new Date(),
654
+ skipped: false,
655
+ });
656
+
657
+ await runService.patch(run.id, agentId, cronId, {
658
+ sessionId: "session-should-persist",
659
+ });
660
+ const updated = await runService.patch(run.id, agentId, cronId, {
661
+ outcome: "success",
662
+ });
663
+
664
+ expect(updated.sessionId).toBe("session-should-persist");
665
+ });
666
+
667
+ // ─── patch — lastHeartbeatAt (CRH-1.1) ─────────────────────────────────────
668
+
669
+ it("patch() round-trips a lastHeartbeatAt field", async () => {
670
+ const agentId = await createAgent(prisma);
671
+ const cronId = await createCron(cronJobService, agentId);
672
+
673
+ const run = await runService.create(cronId, agentId, {
674
+ startedAt: new Date("2026-01-15T10:00:00Z"),
675
+ skipped: false,
676
+ });
677
+ expect(run.lastHeartbeatAt).toBeNull();
678
+
679
+ const heartbeatAt = new Date("2026-01-15T10:00:05Z");
680
+ const updated = await runService.patch(run.id, agentId, cronId, {
681
+ lastHeartbeatAt: heartbeatAt,
682
+ });
683
+
684
+ expect(updated.lastHeartbeatAt).toEqual(heartbeatAt);
685
+ });
686
+
687
+ it("patch() clears lastHeartbeatAt when explicitly set to null", async () => {
688
+ const agentId = await createAgent(prisma);
689
+ const cronId = await createCron(cronJobService, agentId);
690
+
691
+ const run = await runService.create(cronId, agentId, {
692
+ startedAt: new Date(),
693
+ skipped: false,
694
+ });
695
+
696
+ await runService.patch(run.id, agentId, cronId, {
697
+ lastHeartbeatAt: new Date("2026-01-15T10:00:05Z"),
698
+ });
699
+ const cleared = await runService.patch(run.id, agentId, cronId, {
700
+ lastHeartbeatAt: null,
701
+ });
702
+
703
+ expect(cleared.lastHeartbeatAt).toBeNull();
704
+ });
705
+
706
+ it("patch() leaves lastHeartbeatAt untouched when omitted from the input", async () => {
707
+ const agentId = await createAgent(prisma);
708
+ const cronId = await createCron(cronJobService, agentId);
709
+
710
+ const run = await runService.create(cronId, agentId, {
711
+ startedAt: new Date(),
712
+ skipped: false,
713
+ });
714
+ const heartbeatAt = new Date("2026-01-15T10:00:05Z");
715
+
716
+ await runService.patch(run.id, agentId, cronId, {
717
+ lastHeartbeatAt: heartbeatAt,
718
+ });
719
+ const updated = await runService.patch(run.id, agentId, cronId, {
720
+ outcome: "success",
721
+ });
722
+
723
+ expect(updated.lastHeartbeatAt).toEqual(heartbeatAt);
724
+ });
725
+
726
+ it("patch() updates lastHeartbeatAt repeatedly across successive progress pushes (debounced heartbeat simulation)", async () => {
727
+ const agentId = await createAgent(prisma);
728
+ const cronId = await createCron(cronJobService, agentId);
729
+
730
+ const run = await runService.create(cronId, agentId, {
731
+ startedAt: new Date("2026-01-15T10:00:00Z"),
732
+ skipped: false,
733
+ });
734
+
735
+ await runService.patch(run.id, agentId, cronId, {
736
+ lastHeartbeatAt: new Date("2026-01-15T10:00:05Z"),
737
+ });
738
+ const secondPush = await runService.patch(run.id, agentId, cronId, {
739
+ lastHeartbeatAt: new Date("2026-01-15T10:00:10Z"),
740
+ });
741
+
742
+ expect(secondPush.lastHeartbeatAt).toEqual(
743
+ new Date("2026-01-15T10:00:10Z"),
744
+ );
745
+ });
746
+
747
+ // ─── listWithRunSummary ─────────────────────────────────────────────────────
748
+
749
+ it("listWithRunSummary() returns lastRun null when no runs exist", async () => {
750
+ const agentId = await createAgent(prisma);
751
+ await createCron(cronJobService, agentId);
752
+
753
+ const jobs = await cronJobService.listWithRunSummary(agentId);
754
+ expect(jobs).toHaveLength(1);
755
+ expect(jobs[0].lastRun).toBeNull();
756
+ expect(jobs[0].runCountToday).toBe(0);
757
+ });
758
+
759
+ it("listWithRunSummary() returns lastRun as the most recent run", async () => {
760
+ const agentId = await createAgent(prisma);
761
+ const cronId = await createCron(cronJobService, agentId);
762
+
763
+ const t1 = new Date("2026-01-01T06:00:00Z");
764
+ const t2 = new Date("2026-01-01T08:00:00Z");
765
+
766
+ await runService.create(cronId, agentId, {
767
+ startedAt: t1,
768
+ completedAt: new Date(t1.getTime() + 1000),
769
+ skipped: false,
770
+ outcome: "success",
771
+ });
772
+ await runService.create(cronId, agentId, {
773
+ startedAt: t2,
774
+ completedAt: new Date(t2.getTime() + 2000),
775
+ skipped: false,
776
+ outcome: "error",
777
+ });
778
+
779
+ const jobs = await cronJobService.listWithRunSummary(agentId);
780
+ expect(jobs[0].lastRun).not.toBeNull();
781
+ expect(jobs[0].lastRun?.startedAt).toEqual(t2);
782
+ expect(jobs[0].lastRun?.outcome).toBe("error");
783
+ });
784
+
785
+ it("listWithRunSummary() counts runCountToday correctly", async () => {
786
+ const agentId = await createAgent(prisma);
787
+ const cronId = await createCron(cronJobService, agentId);
788
+
789
+ // Derive midnight from the fixed clock so the boundary matches what the
790
+ // service computes — avoids a narrow race when the real clock rolls over midnight.
791
+ const todayMidnightUtc = new Date(FIXED_NOW);
792
+ todayMidnightUtc.setUTCHours(0, 0, 0, 0);
793
+
794
+ const yesterday = new Date(todayMidnightUtc.getTime() - 1000);
795
+ const todayEarly = new Date(todayMidnightUtc.getTime() + 1000);
796
+ const todayLate = new Date(todayMidnightUtc.getTime() + 3600_000);
797
+
798
+ // 1 run yesterday (should not count)
799
+ await runService.create(cronId, agentId, {
800
+ startedAt: yesterday,
801
+ skipped: false,
802
+ });
803
+ // 2 runs today
804
+ await runService.create(cronId, agentId, {
805
+ startedAt: todayEarly,
806
+ skipped: false,
807
+ });
808
+ await runService.create(cronId, agentId, {
809
+ startedAt: todayLate,
810
+ skipped: false,
811
+ });
812
+
813
+ const jobs = await cronJobService.listWithRunSummary(agentId);
814
+ expect(jobs[0].runCountToday).toBe(2);
815
+ });
816
+
817
+ // ─── listForAgent ───────────────────────────────────────────────────────────
818
+
819
+ it("listForAgent() returns runs from multiple crons under one agent, sorted descending by startedAt", async () => {
820
+ const agentId = await createAgent(prisma);
821
+ const cronId1 = await createCron(cronJobService, agentId);
822
+ const cronId2 = await createCron(cronJobService, agentId);
823
+
824
+ const t1 = new Date("2026-01-01T08:00:00Z");
825
+ const t2 = new Date("2026-01-02T08:00:00Z");
826
+ const t3 = new Date("2026-01-03T08:00:00Z");
827
+
828
+ await runService.create(cronId1, agentId, {
829
+ startedAt: t1,
830
+ skipped: false,
831
+ });
832
+ await runService.create(cronId2, agentId, {
833
+ startedAt: t2,
834
+ skipped: false,
835
+ });
836
+ await runService.create(cronId1, agentId, {
837
+ startedAt: t3,
838
+ skipped: false,
839
+ });
840
+
841
+ const { items, total, limit, offset } =
842
+ await runService.listForAgent(agentId);
843
+
844
+ expect(total).toBe(3);
845
+ expect(items).toHaveLength(3);
846
+ expect(limit).toBe(20);
847
+ expect(offset).toBe(0);
848
+ // Descending: t3, t2, t1
849
+ expect(items[0].startedAt).toEqual(t3);
850
+ expect(items[1].startedAt).toEqual(t2);
851
+ expect(items[2].startedAt).toEqual(t1);
852
+ // Cron relation is included with id/name/schedule
853
+ expect(items[0].cron.id).toBe(cronId1);
854
+ expect(items[0].cron.schedule).toBe("0 9 * * *");
855
+ expect(items[1].cron.id).toBe(cronId2);
856
+ });
857
+
858
+ it("listForAgent() opts.cronId narrows to a single cron, still scoped to agentId", async () => {
859
+ const agentId = await createAgent(prisma);
860
+ const cronId1 = await createCron(cronJobService, agentId);
861
+ const cronId2 = await createCron(cronJobService, agentId);
862
+
863
+ await runService.create(cronId1, agentId, {
864
+ startedAt: new Date("2026-01-01T08:00:00Z"),
865
+ skipped: false,
866
+ });
867
+ await runService.create(cronId2, agentId, {
868
+ startedAt: new Date("2026-01-02T08:00:00Z"),
869
+ skipped: false,
870
+ });
871
+
872
+ const { items, total } = await runService.listForAgent(agentId, {
873
+ cronId: cronId1,
874
+ });
875
+
876
+ expect(total).toBe(1);
877
+ expect(items).toHaveLength(1);
878
+ expect(items[0].cronId).toBe(cronId1);
879
+ });
880
+
881
+ it("listForAgent() opts.cronId belonging to a different agent yields an empty result, not an error", async () => {
882
+ const agentId1 = await createAgent(prisma, "Agent 1");
883
+ const agentId2 = await createAgent(prisma, "Agent 2");
884
+ const cronId1 = await createCron(cronJobService, agentId1);
885
+
886
+ await runService.create(cronId1, agentId1, {
887
+ startedAt: new Date(),
888
+ skipped: false,
889
+ });
890
+
891
+ const { items, total } = await runService.listForAgent(agentId2, {
892
+ cronId: cronId1,
893
+ });
894
+
895
+ expect(total).toBe(0);
896
+ expect(items).toHaveLength(0);
897
+ });
898
+
899
+ it("listForAgent() filters by a normal outcome value (skipped = false, outcome = value)", async () => {
900
+ const agentId = await createAgent(prisma);
901
+ const cronId = await createCron(cronJobService, agentId);
902
+
903
+ await runService.create(cronId, agentId, {
904
+ startedAt: new Date("2026-01-01T08:00:00Z"),
905
+ skipped: false,
906
+ outcome: "success",
907
+ });
908
+ await runService.create(cronId, agentId, {
909
+ startedAt: new Date("2026-01-02T08:00:00Z"),
910
+ skipped: false,
911
+ outcome: "error",
912
+ });
913
+ await runService.create(cronId, agentId, {
914
+ startedAt: new Date("2026-01-03T08:00:00Z"),
915
+ skipped: true,
916
+ skipReason: "pre-check returned false",
917
+ });
918
+
919
+ const { items, total } = await runService.listForAgent(agentId, {
920
+ outcome: "success",
921
+ });
922
+
923
+ expect(total).toBe(1);
924
+ expect(items).toHaveLength(1);
925
+ expect(items[0].outcome).toBe("success");
926
+ expect(items[0].skipped).toBe(false);
927
+ });
928
+
929
+ it("listForAgent() opts.outcome === 'skipped' matches skipped rows regardless of their outcome column", async () => {
930
+ const agentId = await createAgent(prisma);
931
+ const cronId = await createCron(cronJobService, agentId);
932
+
933
+ await runService.create(cronId, agentId, {
934
+ startedAt: new Date("2026-01-01T08:00:00Z"),
935
+ skipped: true,
936
+ skipReason: "pre-check returned false",
937
+ outcome: null,
938
+ });
939
+ // A skipped run that (unusually) still carries a non-null outcome column
940
+ // must still match — "skipped" is driven purely by the boolean flag.
941
+ const skippedWithOutcome = await runService.create(cronId, agentId, {
942
+ startedAt: new Date("2026-01-02T08:00:00Z"),
943
+ skipped: true,
944
+ outcome: "success",
945
+ });
946
+ await runService.create(cronId, agentId, {
947
+ startedAt: new Date("2026-01-03T08:00:00Z"),
948
+ skipped: false,
949
+ outcome: "success",
950
+ });
951
+
952
+ const { items, total } = await runService.listForAgent(agentId, {
953
+ outcome: "skipped",
954
+ });
955
+
956
+ expect(total).toBe(2);
957
+ expect(items).toHaveLength(2);
958
+ expect(items.every((r) => r.skipped)).toBe(true);
959
+ expect(items.some((r) => r.id === skippedWithOutcome.id)).toBe(true);
960
+ });
961
+
962
+ it("listForAgent() never returns a second agent's runs", async () => {
963
+ const agentId1 = await createAgent(prisma, "Agent 1");
964
+ const agentId2 = await createAgent(prisma, "Agent 2");
965
+ const cronId1 = await createCron(cronJobService, agentId1);
966
+ const cronId2 = await createCron(cronJobService, agentId2);
967
+
968
+ await runService.create(cronId1, agentId1, {
969
+ startedAt: new Date("2026-01-01T08:00:00Z"),
970
+ skipped: false,
971
+ });
972
+ await runService.create(cronId2, agentId2, {
973
+ startedAt: new Date("2026-01-02T08:00:00Z"),
974
+ skipped: false,
975
+ });
976
+
977
+ const { items, total } = await runService.listForAgent(agentId1);
978
+
979
+ expect(total).toBe(1);
980
+ expect(items).toHaveLength(1);
981
+ expect(items[0].agentId).toBe(agentId1);
982
+ });
983
+
984
+ it("listForAgent() supports custom limit and offset", async () => {
985
+ const agentId = await createAgent(prisma);
986
+ const cronId = await createCron(cronJobService, agentId);
987
+
988
+ for (let i = 0; i < 5; i++) {
989
+ await runService.create(cronId, agentId, {
990
+ startedAt: new Date(Date.now() + i * 1000),
991
+ skipped: false,
992
+ });
993
+ }
994
+
995
+ const { items, total } = await runService.listForAgent(agentId, {
996
+ limit: 2,
997
+ offset: 1,
998
+ });
999
+ expect(items).toHaveLength(2);
1000
+ expect(total).toBe(5);
1001
+ });
1002
+
1003
+ it("listForAgent() returns modelBreakdown rows alongside the cron relation", async () => {
1004
+ const agentId = await createAgent(prisma);
1005
+ const cronId = await createCron(cronJobService, agentId);
1006
+
1007
+ const run = await runService.create(cronId, agentId, {
1008
+ startedAt: new Date("2026-01-15T10:00:00Z"),
1009
+ skipped: false,
1010
+ });
1011
+
1012
+ await runService.patch(run.id, agentId, cronId, {
1013
+ outcome: "success",
1014
+ modelBreakdown: [
1015
+ {
1016
+ model: "claude-sonnet-4-5",
1017
+ inputTokens: 200,
1018
+ outputTokens: 100,
1019
+ cacheReadTokens: 8,
1020
+ cacheCreationTokens: 4,
1021
+ costUsd: 0.002,
1022
+ },
1023
+ ],
1024
+ });
1025
+
1026
+ const { items } = await runService.listForAgent(agentId);
1027
+
1028
+ expect(items).toHaveLength(1);
1029
+ expect(items[0].modelBreakdown).toHaveLength(1);
1030
+ expect(items[0].modelBreakdown[0].model).toBe("claude-sonnet-4-5");
1031
+ expect(items[0].cron.id).toBe(cronId);
1032
+ });
1033
+
1034
+ it("listForAgent() returns an empty result when the agent has no runs", async () => {
1035
+ const agentId = await createAgent(prisma);
1036
+ await createCron(cronJobService, agentId);
1037
+
1038
+ const { items, total } = await runService.listForAgent(agentId);
1039
+
1040
+ expect(total).toBe(0);
1041
+ expect(items).toHaveLength(0);
1042
+ });
1043
+
1044
+ // ─── listAcrossAgents ───────────────────────────────────────────────────────
1045
+
1046
+ it("listAcrossAgents() returns runs across 3+ distinct agents, sorted descending by startedAt", async () => {
1047
+ const agentId1 = await createAgent(prisma, "Agent 1");
1048
+ const agentId2 = await createAgent(prisma, "Agent 2");
1049
+ const agentId3 = await createAgent(prisma, "Agent 3");
1050
+ const cronId1 = await createCron(cronJobService, agentId1);
1051
+ const cronId2 = await createCron(cronJobService, agentId2);
1052
+ const cronId3 = await createCron(cronJobService, agentId3);
1053
+
1054
+ const t1 = new Date("2026-01-01T08:00:00Z");
1055
+ const t2 = new Date("2026-01-02T08:00:00Z");
1056
+ const t3 = new Date("2026-01-03T08:00:00Z");
1057
+
1058
+ await runService.create(cronId1, agentId1, {
1059
+ startedAt: t1,
1060
+ skipped: false,
1061
+ });
1062
+ await runService.create(cronId2, agentId2, {
1063
+ startedAt: t2,
1064
+ skipped: false,
1065
+ });
1066
+ await runService.create(cronId3, agentId3, {
1067
+ startedAt: t3,
1068
+ skipped: false,
1069
+ });
1070
+
1071
+ const { items, total, limit, offset } = await runService.listAcrossAgents([
1072
+ agentId1,
1073
+ agentId2,
1074
+ agentId3,
1075
+ ]);
1076
+
1077
+ expect(total).toBe(3);
1078
+ expect(items).toHaveLength(3);
1079
+ expect(limit).toBe(20);
1080
+ expect(offset).toBe(0);
1081
+ // Descending: t3, t2, t1
1082
+ expect(items[0].startedAt).toEqual(t3);
1083
+ expect(items[1].startedAt).toEqual(t2);
1084
+ expect(items[2].startedAt).toEqual(t1);
1085
+ // Cron relation is included with id/name/schedule, same shape as listForAgent
1086
+ expect(items[0].cron.id).toBe(cronId3);
1087
+ expect(items[0].cron.schedule).toBe("0 9 * * *");
1088
+ expect(items[1].cron.id).toBe(cronId2);
1089
+ });
1090
+
1091
+ it("listAcrossAgents() excludes an agent not present in the given agentIds list", async () => {
1092
+ const agentId1 = await createAgent(prisma, "Agent 1");
1093
+ const agentId2 = await createAgent(prisma, "Agent 2");
1094
+ const agentId3 = await createAgent(prisma, "Agent 3 (excluded)");
1095
+ const cronId1 = await createCron(cronJobService, agentId1);
1096
+ const cronId2 = await createCron(cronJobService, agentId2);
1097
+ const cronId3 = await createCron(cronJobService, agentId3);
1098
+
1099
+ await runService.create(cronId1, agentId1, {
1100
+ startedAt: new Date("2026-01-01T08:00:00Z"),
1101
+ skipped: false,
1102
+ });
1103
+ await runService.create(cronId2, agentId2, {
1104
+ startedAt: new Date("2026-01-02T08:00:00Z"),
1105
+ skipped: false,
1106
+ });
1107
+ await runService.create(cronId3, agentId3, {
1108
+ startedAt: new Date("2026-01-03T08:00:00Z"),
1109
+ skipped: false,
1110
+ });
1111
+
1112
+ const { items, total } = await runService.listAcrossAgents([
1113
+ agentId1,
1114
+ agentId2,
1115
+ ]);
1116
+
1117
+ expect(total).toBe(2);
1118
+ expect(items).toHaveLength(2);
1119
+ expect(
1120
+ items.every((r) => r.agentId === agentId1 || r.agentId === agentId2),
1121
+ ).toBe(true);
1122
+ expect(items.some((r) => r.agentId === agentId3)).toBe(false);
1123
+ });
1124
+
1125
+ it("listAcrossAgents() returns an empty result for an empty agentIds list", async () => {
1126
+ const agentId = await createAgent(prisma);
1127
+ const cronId = await createCron(cronJobService, agentId);
1128
+ await runService.create(cronId, agentId, {
1129
+ startedAt: new Date("2026-01-01T08:00:00Z"),
1130
+ skipped: false,
1131
+ });
1132
+
1133
+ const { items, total } = await runService.listAcrossAgents([]);
1134
+
1135
+ expect(total).toBe(0);
1136
+ expect(items).toHaveLength(0);
1137
+ });
1138
+
1139
+ it("listAcrossAgents() returns an empty result when no runs match any given agentId", async () => {
1140
+ const agentId1 = await createAgent(prisma, "Agent 1");
1141
+ const agentId2 = await createAgent(prisma, "Agent 2 (no runs)");
1142
+ const cronId1 = await createCron(cronJobService, agentId1);
1143
+ await runService.create(cronId1, agentId1, {
1144
+ startedAt: new Date("2026-01-01T08:00:00Z"),
1145
+ skipped: false,
1146
+ });
1147
+
1148
+ const { items, total } = await runService.listAcrossAgents([agentId2]);
1149
+
1150
+ expect(total).toBe(0);
1151
+ expect(items).toHaveLength(0);
1152
+ });
1153
+
1154
+ it("listAcrossAgents() opts.cronId narrows to a single cron across the given agents", async () => {
1155
+ const agentId1 = await createAgent(prisma, "Agent 1");
1156
+ const agentId2 = await createAgent(prisma, "Agent 2");
1157
+ const cronId1 = await createCron(cronJobService, agentId1);
1158
+ const cronId2 = await createCron(cronJobService, agentId2);
1159
+
1160
+ await runService.create(cronId1, agentId1, {
1161
+ startedAt: new Date("2026-01-01T08:00:00Z"),
1162
+ skipped: false,
1163
+ });
1164
+ await runService.create(cronId2, agentId2, {
1165
+ startedAt: new Date("2026-01-02T08:00:00Z"),
1166
+ skipped: false,
1167
+ });
1168
+
1169
+ const { items, total } = await runService.listAcrossAgents(
1170
+ [agentId1, agentId2],
1171
+ { cronId: cronId1 },
1172
+ );
1173
+
1174
+ expect(total).toBe(1);
1175
+ expect(items).toHaveLength(1);
1176
+ expect(items[0].cronId).toBe(cronId1);
1177
+ });
1178
+
1179
+ it("listAcrossAgents() filters by a normal outcome value (skipped = false, outcome = value)", async () => {
1180
+ const agentId1 = await createAgent(prisma, "Agent 1");
1181
+ const agentId2 = await createAgent(prisma, "Agent 2");
1182
+ const cronId1 = await createCron(cronJobService, agentId1);
1183
+ const cronId2 = await createCron(cronJobService, agentId2);
1184
+
1185
+ await runService.create(cronId1, agentId1, {
1186
+ startedAt: new Date("2026-01-01T08:00:00Z"),
1187
+ skipped: false,
1188
+ outcome: "success",
1189
+ });
1190
+ await runService.create(cronId2, agentId2, {
1191
+ startedAt: new Date("2026-01-02T08:00:00Z"),
1192
+ skipped: false,
1193
+ outcome: "error",
1194
+ });
1195
+ await runService.create(cronId1, agentId1, {
1196
+ startedAt: new Date("2026-01-03T08:00:00Z"),
1197
+ skipped: true,
1198
+ skipReason: "pre-check returned false",
1199
+ });
1200
+
1201
+ const { items, total } = await runService.listAcrossAgents(
1202
+ [agentId1, agentId2],
1203
+ { outcome: "success" },
1204
+ );
1205
+
1206
+ expect(total).toBe(1);
1207
+ expect(items).toHaveLength(1);
1208
+ expect(items[0].outcome).toBe("success");
1209
+ expect(items[0].skipped).toBe(false);
1210
+ });
1211
+
1212
+ it("listAcrossAgents() opts.outcome === 'skipped' matches skipped rows regardless of their outcome column", async () => {
1213
+ const agentId1 = await createAgent(prisma, "Agent 1");
1214
+ const agentId2 = await createAgent(prisma, "Agent 2");
1215
+ const cronId1 = await createCron(cronJobService, agentId1);
1216
+ const cronId2 = await createCron(cronJobService, agentId2);
1217
+
1218
+ await runService.create(cronId1, agentId1, {
1219
+ startedAt: new Date("2026-01-01T08:00:00Z"),
1220
+ skipped: true,
1221
+ skipReason: "pre-check returned false",
1222
+ outcome: null,
1223
+ });
1224
+ const skippedWithOutcome = await runService.create(cronId2, agentId2, {
1225
+ startedAt: new Date("2026-01-02T08:00:00Z"),
1226
+ skipped: true,
1227
+ outcome: "success",
1228
+ });
1229
+ await runService.create(cronId1, agentId1, {
1230
+ startedAt: new Date("2026-01-03T08:00:00Z"),
1231
+ skipped: false,
1232
+ outcome: "success",
1233
+ });
1234
+
1235
+ const { items, total } = await runService.listAcrossAgents(
1236
+ [agentId1, agentId2],
1237
+ { outcome: "skipped" },
1238
+ );
1239
+
1240
+ expect(total).toBe(2);
1241
+ expect(items).toHaveLength(2);
1242
+ expect(items.every((r) => r.skipped)).toBe(true);
1243
+ expect(items.some((r) => r.id === skippedWithOutcome.id)).toBe(true);
1244
+ });
1245
+
1246
+ it("listAcrossAgents() supports custom limit and offset", async () => {
1247
+ const agentId1 = await createAgent(prisma, "Agent 1");
1248
+ const agentId2 = await createAgent(prisma, "Agent 2");
1249
+ const agentId3 = await createAgent(prisma, "Agent 3");
1250
+ const cronId1 = await createCron(cronJobService, agentId1);
1251
+ const cronId2 = await createCron(cronJobService, agentId2);
1252
+ const cronId3 = await createCron(cronJobService, agentId3);
1253
+
1254
+ const crons = [cronId1, cronId2, cronId3];
1255
+ const agents = [agentId1, agentId2, agentId3];
1256
+ for (let i = 0; i < 5; i++) {
1257
+ await runService.create(crons[i % 3], agents[i % 3], {
1258
+ startedAt: new Date(Date.now() + i * 1000),
1259
+ skipped: false,
1260
+ });
1261
+ }
1262
+
1263
+ const { items, total } = await runService.listAcrossAgents(agents, {
1264
+ limit: 2,
1265
+ offset: 1,
1266
+ });
1267
+ expect(items).toHaveLength(2);
1268
+ expect(total).toBe(5);
1269
+ });
1270
+
1271
+ it("listAcrossAgents() returns modelBreakdown rows alongside the cron relation", async () => {
1272
+ const agentId1 = await createAgent(prisma, "Agent 1");
1273
+ const agentId2 = await createAgent(prisma, "Agent 2");
1274
+ const cronId1 = await createCron(cronJobService, agentId1);
1275
+
1276
+ const run = await runService.create(cronId1, agentId1, {
1277
+ startedAt: new Date("2026-01-15T10:00:00Z"),
1278
+ skipped: false,
1279
+ });
1280
+
1281
+ await runService.patch(run.id, agentId1, cronId1, {
1282
+ outcome: "success",
1283
+ modelBreakdown: [
1284
+ {
1285
+ model: "claude-sonnet-4-5",
1286
+ inputTokens: 200,
1287
+ outputTokens: 100,
1288
+ cacheReadTokens: 8,
1289
+ cacheCreationTokens: 4,
1290
+ costUsd: 0.002,
1291
+ },
1292
+ ],
1293
+ });
1294
+
1295
+ const { items } = await runService.listAcrossAgents([agentId1, agentId2]);
1296
+
1297
+ expect(items).toHaveLength(1);
1298
+ expect(items[0].modelBreakdown).toHaveLength(1);
1299
+ expect(items[0].modelBreakdown[0].model).toBe("claude-sonnet-4-5");
1300
+ expect(items[0].cron.id).toBe(cronId1);
1301
+ });
1302
+ });
1303
+
1304
+ // ─── run telemetry: baseline columns + skill-usage upserts (prompt-audit) ───
1305
+
1306
+ describeOrSkip("AgentCronRunService — run telemetry (integration)", () => {
1307
+ let prisma: PrismaClient;
1308
+ let cronJobService: AgentCronJobService;
1309
+ let runService: AgentCronRunService;
1310
+
1311
+ beforeEach(async () => {
1312
+ prisma = makePrisma();
1313
+ await prisma.agentCronRunSkillUsage.deleteMany();
1314
+ await prisma.agentCronRunModelBreakdown.deleteMany();
1315
+ await prisma.agentCronRun.deleteMany();
1316
+ await prisma.agentToken.deleteMany();
1317
+ await prisma.agentCronJob.deleteMany();
1318
+ await prisma.agentTool.deleteMany();
1319
+ await prisma.agentEnv.deleteMany();
1320
+ await prisma.agent.deleteMany();
1321
+ cronJobService = new AgentCronJobService(prisma, FixedClock(FIXED_NOW));
1322
+ runService = new AgentCronRunService(prisma);
1323
+ });
1324
+
1325
+ afterEach(async () => {
1326
+ await prisma.$disconnect();
1327
+ });
1328
+
1329
+ it("patch() spreads contextBaseline onto the baseline* columns and stores counts, fingerprint, and versions", async () => {
1330
+ const agentId = await createAgent(prisma);
1331
+ const cronId = await createCron(cronJobService, agentId);
1332
+ const run = await runService.create(cronId, agentId, {
1333
+ startedAt: FIXED_NOW,
1334
+ });
1335
+
1336
+ await runService.patch(run.id, agentId, cronId, {
1337
+ completedAt: FIXED_NOW,
1338
+ outcome: "completed",
1339
+ contextBaseline: {
1340
+ model: "claude-sonnet-4-6",
1341
+ contextTokens: 79_188,
1342
+ inputTokens: 2,
1343
+ cacheCreationTokens: 41_415,
1344
+ cacheReadTokens: 37_771,
1345
+ },
1346
+ turns: 12,
1347
+ toolCalls: 7,
1348
+ contextFingerprint: "abc123def456",
1349
+ pluginVersion: "1.363.0",
1350
+ claudeCodeVersion: "2.1.285",
1351
+ });
1352
+
1353
+ const stored = await prisma.agentCronRun.findUniqueOrThrow({
1354
+ where: { id: run.id },
1355
+ });
1356
+ expect(stored.baselineModel).toBe("claude-sonnet-4-6");
1357
+ expect(stored.baselineContextTokens).toBe(79_188);
1358
+ expect(stored.baselineInputTokens).toBe(2);
1359
+ expect(stored.baselineCacheCreationTokens).toBe(41_415);
1360
+ expect(stored.baselineCacheReadTokens).toBe(37_771);
1361
+ expect(stored.turns).toBe(12);
1362
+ expect(stored.toolCalls).toBe(7);
1363
+ expect(stored.contextFingerprint).toBe("abc123def456");
1364
+ expect(stored.pluginVersion).toBe("1.363.0");
1365
+ expect(stored.claudeCodeVersion).toBe("2.1.285");
1366
+ });
1367
+
1368
+ it("patch() without telemetry leaves every telemetry column null", async () => {
1369
+ const agentId = await createAgent(prisma);
1370
+ const cronId = await createCron(cronJobService, agentId);
1371
+ const run = await runService.create(cronId, agentId, {
1372
+ startedAt: FIXED_NOW,
1373
+ });
1374
+
1375
+ await runService.patch(run.id, agentId, cronId, { outcome: "completed" });
1376
+
1377
+ const stored = await prisma.agentCronRun.findUniqueOrThrow({
1378
+ where: { id: run.id },
1379
+ });
1380
+ expect(stored.baselineModel).toBeNull();
1381
+ expect(stored.baselineContextTokens).toBeNull();
1382
+ expect(stored.turns).toBeNull();
1383
+ expect(stored.contextFingerprint).toBeNull();
1384
+ });
1385
+
1386
+ it("patch() upserts skillUsage rows per [cronRunId, kind, name], alongside modelBreakdown, in one call", async () => {
1387
+ const agentId = await createAgent(prisma);
1388
+ const cronId = await createCron(cronJobService, agentId);
1389
+ const run = await runService.create(cronId, agentId, {
1390
+ startedAt: FIXED_NOW,
1391
+ });
1392
+
1393
+ const row = {
1394
+ kind: "skill",
1395
+ name: "shipwright:task-store",
1396
+ invocations: 1,
1397
+ turns: 3,
1398
+ inputTokens: 12,
1399
+ outputTokens: 100,
1400
+ cacheReadTokens: 219_200,
1401
+ cacheCreationTokens: 9_300,
1402
+ invokeContextDelta: 103,
1403
+ };
1404
+ await runService.patch(run.id, agentId, cronId, {
1405
+ modelBreakdown: [
1406
+ {
1407
+ model: "claude-sonnet-4-6",
1408
+ inputTokens: 514,
1409
+ outputTokens: 150,
1410
+ cacheReadTokens: 0,
1411
+ cacheCreationTokens: 0,
1412
+ costUsd: 0.42,
1413
+ },
1414
+ ],
1415
+ skillUsage: [
1416
+ row,
1417
+ {
1418
+ kind: "root",
1419
+ name: "root",
1420
+ invocations: 0,
1421
+ turns: 1,
1422
+ inputTokens: 2,
1423
+ outputTokens: 10,
1424
+ cacheReadTokens: 30_000,
1425
+ cacheCreationTokens: 40_000,
1426
+ invokeContextDelta: null,
1427
+ },
1428
+ ],
1429
+ });
1430
+
1431
+ // Second PATCH with updated numbers for the same key — must update, not duplicate.
1432
+ await runService.patch(run.id, agentId, cronId, {
1433
+ skillUsage: [{ ...row, turns: 4, inputTokens: 20 }],
1434
+ });
1435
+
1436
+ const rows = await prisma.agentCronRunSkillUsage.findMany({
1437
+ where: { cronRunId: run.id },
1438
+ orderBy: { kind: "asc" },
1439
+ });
1440
+ expect(rows).toHaveLength(2);
1441
+ const skill = rows.find((r) => r.kind === "skill");
1442
+ expect(skill).toMatchObject({
1443
+ name: "shipwright:task-store",
1444
+ invocations: 1,
1445
+ turns: 4,
1446
+ inputTokens: 20,
1447
+ invokeContextDelta: 103,
1448
+ });
1449
+ const root = rows.find((r) => r.kind === "root");
1450
+ expect(root).toMatchObject({
1451
+ name: "root",
1452
+ turns: 1,
1453
+ invokeContextDelta: null,
1454
+ });
1455
+
1456
+ const breakdown = await prisma.agentCronRunModelBreakdown.findMany({
1457
+ where: { cronRunId: run.id },
1458
+ });
1459
+ expect(breakdown).toHaveLength(1);
1460
+ });
1461
+
1462
+ it("skillUsage rows cascade-delete with their run", async () => {
1463
+ const agentId = await createAgent(prisma);
1464
+ const cronId = await createCron(cronJobService, agentId);
1465
+ const run = await runService.create(cronId, agentId, {
1466
+ startedAt: FIXED_NOW,
1467
+ });
1468
+ await runService.patch(run.id, agentId, cronId, {
1469
+ skillUsage: [
1470
+ {
1471
+ kind: "agent",
1472
+ name: "Explore",
1473
+ invocations: 1,
1474
+ turns: 1,
1475
+ inputTokens: 500,
1476
+ outputTokens: 40,
1477
+ cacheReadTokens: 0,
1478
+ cacheCreationTokens: 0,
1479
+ invokeContextDelta: null,
1480
+ },
1481
+ ],
1482
+ });
1483
+
1484
+ await prisma.agentCronRun.delete({ where: { id: run.id } });
1485
+ expect(
1486
+ await prisma.agentCronRunSkillUsage.count({
1487
+ where: { cronRunId: run.id },
1488
+ }),
1489
+ ).toBe(0);
1490
+ });
1491
+ });