@app-vitals/agent-admin 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,752 @@
1
+ /**
2
+ * admin/src/agent-cron-run-stats.ts
3
+ * AgentCronRunStatsService — aggregates AgentCronRun token columns into
4
+ * several dimensions: totals, byAgent, byCron (with AgentCronJob name JOIN),
5
+ * byModel, daily (DATE(startedAt)), byCronModel, and byPhase.
6
+ *
7
+ * Skipped runs (skipped=true) are always excluded from all aggregations.
8
+ * byPhase additionally excludes runs with a null phaseId (legacy five-job
9
+ * crons, or runs that predate phase tracking, or runs whose agent hasn't
10
+ * reconciled its phase child rows yet).
11
+ *
12
+ * bySkill (PAU-1.6) aggregates AgentCronRunSkillUsage per (kind, name) over
13
+ * non-skipped runs only. baselines groups the first-turn context baseline per
14
+ * (contextFingerprint, baselineModel, phase) — skipped runs ARE included
15
+ * (their baseline is still a valid measurement), runs with no baseline
16
+ * (baselineContextTokens NULL) are excluded; rows are ordered by first
17
+ * appearance. Both are empty arrays when nothing has been reported.
18
+ * Uses $queryRaw for all dimensions — Prisma groupBy doesn't support the
19
+ * LEFT JOIN needed for byCron.
20
+ *
21
+ * byCron/byCronModel additionally group by phase (WL-3.5, LPC-3.1): each row
22
+ * carries a `phase` field derived by LEFT JOINing "AgentCronJob" a second
23
+ * time (alias `p`) on `r."phaseId" = p.id` and stripping the
24
+ * "shipwright-" prefix from p.name (e.g. "shipwright-dev-task" → "dev-task")
25
+ * so the output label matches exactly what the old `r.phase` string column
26
+ * used to contain. A run with no phaseId (legacy five-job crons, or an
27
+ * unreconciled agent) has phase NULL, and since SQL GROUP BY treats NULL as
28
+ * a single group, all of a cron's legacy runs still collapse into one row —
29
+ * identical to today's cronId-only display. Runs that do carry a phaseId
30
+ * (unified loop) produce one row per (cronId, phase), surfacing the
31
+ * dev-task/review/patch/deploy breakdown that used to be implicit in having
32
+ * five separate crons.
33
+ */
34
+
35
+ import type { PrismaClient } from "../prisma/client/client.ts";
36
+ import { Prisma } from "../prisma/client/client.ts";
37
+
38
+ // ─── Types (mirrored from metrics/src/lib/admin-metrics-client.ts) ───────────
39
+ // These types are defined here to keep admin self-contained (rootDir constraint).
40
+ // The shapes must stay in sync with the interfaces in admin-metrics-client.ts.
41
+
42
+ interface TokenAggregate {
43
+ input: number;
44
+ output: number;
45
+ cacheRead: number;
46
+ cacheCreation: number;
47
+ total: number;
48
+ costUsd?: number;
49
+ }
50
+
51
+ interface KeyedTokenAggregate extends TokenAggregate {
52
+ key: string;
53
+ }
54
+
55
+ interface DoubleKeyedTokenAggregate extends TokenAggregate {
56
+ key1: string;
57
+ key2: string;
58
+ /**
59
+ * Pipeline phase this row's runs served (dev-task/review/patch/deploy).
60
+ * Only populated on byCron/byCronModel rows; null for legacy runs that
61
+ * predate phase tracking, and always undefined on other dimensions
62
+ * (byAgent, byModel) which don't group by phase.
63
+ */
64
+ phase?: string | null;
65
+ }
66
+
67
+ interface DailyTokenAggregate extends TokenAggregate {
68
+ period: string;
69
+ }
70
+
71
+ export interface CronRunTokenStats {
72
+ totals: TokenAggregate;
73
+ byAgent: KeyedTokenAggregate[];
74
+ byCron: DoubleKeyedTokenAggregate[];
75
+ byModel: DoubleKeyedTokenAggregate[];
76
+ daily: DailyTokenAggregate[];
77
+ byCronModel: DoubleKeyedTokenAggregate[]; // key1=agentId:cronName, key2=model
78
+ byPhase: KeyedTokenAggregate[]; // key=phase; runs with a null phase are excluded
79
+ bySkill?: SkillUsageAggregate[]; // per (kind,name); skipped runs excluded
80
+ baselines?: ContextBaselineAggregate[]; // per (fingerprint,model,phase); runs w/o baseline excluded
81
+ }
82
+
83
+ /** Per-skill / per-subagent usage rollup across runs. */
84
+ interface SkillUsageAggregate {
85
+ kind: string;
86
+ name: string;
87
+ runs: number;
88
+ invocations: number;
89
+ turns: number;
90
+ input: number;
91
+ output: number;
92
+ cacheRead: number;
93
+ cacheCreation: number;
94
+ /** Mean invokeContextDelta over rows that reported one; null when none did. */
95
+ avgInvokeContextDelta: number | null;
96
+ }
97
+
98
+ /** First-turn context baseline rollup for runs sharing a fingerprint/model/phase. */
99
+ interface ContextBaselineAggregate {
100
+ contextFingerprint: string | null;
101
+ baselineModel: string | null;
102
+ phase: string | null;
103
+ runs: number;
104
+ avgContextTokens: number;
105
+ minContextTokens: number;
106
+ maxContextTokens: number;
107
+ /** Mean over runs that reported a value; null when none did. */
108
+ avgTurns: number | null;
109
+ avgToolCalls: number | null;
110
+ firstSeen: string;
111
+ lastSeen: string;
112
+ }
113
+
114
+ interface CronRunOutcomeSeries {
115
+ /** Pipeline phase ("dev-task", "review", ...); null for runs without a phaseId. */
116
+ phase: string | null;
117
+ /** Context fingerprint stamped by the agent; null for runs that predate it. */
118
+ contextFingerprint: string | null;
119
+ runs: number;
120
+ completed: number;
121
+ failed: number;
122
+ skipped: number;
123
+ /** skipReason → count, over this series' skipped runs (null reasons key as "unknown"). */
124
+ skipReasons: Record<string, number>;
125
+ /** Over non-skipped runs with a completedAt; null when there are none. */
126
+ avgDurationMs: number | null;
127
+ p50DurationMs: number | null;
128
+ /** Over non-skipped runs that reported the metric; null when none did. */
129
+ avgTurns: number | null;
130
+ avgToolCalls: number | null;
131
+ avgContextTokens: number | null;
132
+ }
133
+
134
+ export interface CronRunOutcomes {
135
+ series: CronRunOutcomeSeries[];
136
+ }
137
+
138
+ interface OutcomeRow {
139
+ phase: string | null;
140
+ context_fingerprint: string | null;
141
+ runs: bigint;
142
+ completed: bigint;
143
+ failed: bigint;
144
+ skipped: bigint;
145
+ avg_duration_ms: number | null;
146
+ p50_duration_ms: number | null;
147
+ avg_turns: number | null;
148
+ avg_tool_calls: number | null;
149
+ avg_context_tokens: number | null;
150
+ }
151
+
152
+ interface SkipReasonRow {
153
+ phase: string | null;
154
+ context_fingerprint: string | null;
155
+ skip_reason: string | null;
156
+ n: bigint;
157
+ }
158
+
159
+ // ─── Raw row types from $queryRaw ────────────────────────────────────────────
160
+
161
+ interface TotalsRow {
162
+ input: bigint | null;
163
+ output: bigint | null;
164
+ cache_read: bigint | null;
165
+ cache_creation: bigint | null;
166
+ cost_usd: number | null;
167
+ }
168
+
169
+ interface ByAgentRow {
170
+ agent_id: string;
171
+ input: bigint | null;
172
+ output: bigint | null;
173
+ cache_read: bigint | null;
174
+ cache_creation: bigint | null;
175
+ cost_usd: number | null;
176
+ }
177
+
178
+ interface ByCronRow {
179
+ agent_id: string;
180
+ cron_id: string;
181
+ cron_name: string | null;
182
+ phase: string | null;
183
+ input: bigint | null;
184
+ output: bigint | null;
185
+ cache_read: bigint | null;
186
+ cache_creation: bigint | null;
187
+ cost_usd: number | null;
188
+ }
189
+
190
+ interface ByModelRow {
191
+ agent_id: string;
192
+ model: string;
193
+ input: bigint | null;
194
+ output: bigint | null;
195
+ cache_read: bigint | null;
196
+ cache_creation: bigint | null;
197
+ cost_usd: number | null;
198
+ }
199
+
200
+ interface DailyRow {
201
+ period: string;
202
+ input: bigint | null;
203
+ output: bigint | null;
204
+ cache_read: bigint | null;
205
+ cache_creation: bigint | null;
206
+ cost_usd: number | null;
207
+ }
208
+
209
+ interface ByCronModelRow {
210
+ agent_id: string;
211
+ cron_id: string;
212
+ cron_name: string | null;
213
+ model: string;
214
+ phase: string | null;
215
+ input: bigint | null;
216
+ output: bigint | null;
217
+ cache_read: bigint | null;
218
+ cache_creation: bigint | null;
219
+ cost_usd: number | null;
220
+ }
221
+
222
+ interface ByPhaseRow {
223
+ phase: string;
224
+ input: bigint | null;
225
+ output: bigint | null;
226
+ cache_read: bigint | null;
227
+ cache_creation: bigint | null;
228
+ cost_usd: number | null;
229
+ }
230
+
231
+ interface BySkillRow {
232
+ kind: string;
233
+ name: string;
234
+ runs: bigint;
235
+ invocations: bigint | null;
236
+ turns: bigint | null;
237
+ input: bigint | null;
238
+ output: bigint | null;
239
+ cache_read: bigint | null;
240
+ cache_creation: bigint | null;
241
+ avg_invoke_context_delta: number | null;
242
+ }
243
+
244
+ interface BaselineRow {
245
+ context_fingerprint: string | null;
246
+ baseline_model: string | null;
247
+ phase: string | null;
248
+ runs: bigint;
249
+ avg_context_tokens: number;
250
+ min_context_tokens: number;
251
+ max_context_tokens: number;
252
+ avg_turns: number | null;
253
+ avg_tool_calls: number | null;
254
+ first_seen: Date;
255
+ last_seen: Date;
256
+ }
257
+
258
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
259
+
260
+ function num(v: bigint | null | undefined): number {
261
+ if (v === null || v === undefined) return 0;
262
+ return Number(v);
263
+ }
264
+
265
+ function toAggregate(row: {
266
+ input: bigint | null;
267
+ output: bigint | null;
268
+ cache_read: bigint | null;
269
+ cache_creation: bigint | null;
270
+ cost_usd?: number | null;
271
+ }): TokenAggregate {
272
+ const input = num(row.input);
273
+ const output = num(row.output);
274
+ const cacheRead = num(row.cache_read);
275
+ const cacheCreation = num(row.cache_creation);
276
+ const result: TokenAggregate = {
277
+ input,
278
+ output,
279
+ cacheRead,
280
+ cacheCreation,
281
+ total: input + output + cacheRead + cacheCreation,
282
+ };
283
+ if (row.cost_usd !== null && row.cost_usd !== undefined) {
284
+ result.costUsd = row.cost_usd;
285
+ }
286
+ return result;
287
+ }
288
+
289
+ /**
290
+ * Build a composable date-range filter fragment for $queryRaw.
291
+ *
292
+ * @param from Lower bound (inclusive).
293
+ * @param to Upper bound (exclusive).
294
+ * @param alias Optional table alias to qualify "startedAt" (e.g. "r" → r."startedAt").
295
+ * Pass this whenever the query uses a JOIN so the column reference is
296
+ * unambiguous even if the joined table later gains a startedAt column.
297
+ */
298
+ function dateFilter(
299
+ from: Date | null,
300
+ to: Date | null,
301
+ alias?: string,
302
+ ): Prisma.Sql {
303
+ const col = alias
304
+ ? Prisma.sql`${Prisma.raw(`${alias}."startedAt"`)}`
305
+ : Prisma.sql`"startedAt"`;
306
+ if (from !== null && to !== null) {
307
+ return Prisma.sql`AND ${col} >= ${from} AND ${col} < ${to}`;
308
+ }
309
+ if (from !== null) {
310
+ return Prisma.sql`AND ${col} >= ${from}`;
311
+ }
312
+ if (to !== null) {
313
+ return Prisma.sql`AND ${col} < ${to}`;
314
+ }
315
+ return Prisma.empty;
316
+ }
317
+
318
+ // ─── Service ─────────────────────────────────────────────────────────────────
319
+
320
+ export class AgentCronRunStatsService {
321
+ constructor(private readonly prisma: PrismaClient) {}
322
+
323
+ /**
324
+ * Aggregate cron-run token stats into five dimensions.
325
+ *
326
+ * @param from ISO string — if provided, only runs with startedAt >= from
327
+ * @param to ISO string — if provided, only runs with startedAt < to
328
+ * @param agentId if provided, only that agent's runs (every dimension);
329
+ * omitted preserves the fleet-wide behavior
330
+ */
331
+ async query(
332
+ from?: string,
333
+ to?: string,
334
+ agentId?: string,
335
+ ): Promise<CronRunTokenStats> {
336
+ const fromDate = from ? new Date(from) : null;
337
+ const toDate = to ? new Date(to) : null;
338
+
339
+ // Every query now aliases AgentCronRun as "r" (all six LEFT/INNER JOIN the
340
+ // breakdown table), so all filters are qualified against that alias to keep
341
+ // "startedAt" unambiguous.
342
+ // The agentId fragment is parameterized and appended to the shared filter,
343
+ // so it reaches every aggregation block.
344
+ const filterR = Prisma.sql`${dateFilter(fromDate, toDate, "r")} ${
345
+ agentId !== undefined
346
+ ? Prisma.sql`AND r."agentId" = ${agentId}`
347
+ : Prisma.empty
348
+ }`;
349
+
350
+ const [
351
+ totalsRows,
352
+ byAgentRows,
353
+ byCronRows,
354
+ byModelRows,
355
+ dailyRows,
356
+ byCronModelRows,
357
+ byPhaseRows,
358
+ bySkillRows,
359
+ baselineRows,
360
+ ] = await Promise.all([
361
+ this.queryTotals(filterR),
362
+ this.queryByAgent(filterR),
363
+ this.queryByCron(filterR),
364
+ this.queryByModel(filterR),
365
+ this.queryDaily(filterR),
366
+ this.queryByCronModel(filterR),
367
+ this.queryByPhase(filterR),
368
+ this.queryBySkill(filterR),
369
+ this.queryBaselines(filterR),
370
+ ]);
371
+
372
+ const totalsRow = totalsRows[0];
373
+ const totals: TokenAggregate = totalsRow
374
+ ? toAggregate(totalsRow)
375
+ : { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
376
+
377
+ const byAgent: KeyedTokenAggregate[] = byAgentRows.map((row) => ({
378
+ ...toAggregate(row),
379
+ key: row.agent_id,
380
+ }));
381
+
382
+ const byCron: DoubleKeyedTokenAggregate[] = byCronRows.map((row) => ({
383
+ ...toAggregate(row),
384
+ key1: row.agent_id,
385
+ key2: row.cron_name ?? row.cron_id,
386
+ phase: row.phase,
387
+ }));
388
+
389
+ const byModel: DoubleKeyedTokenAggregate[] = byModelRows.map((row) => ({
390
+ ...toAggregate(row),
391
+ key1: row.agent_id,
392
+ key2: row.model,
393
+ }));
394
+
395
+ const daily: DailyTokenAggregate[] = dailyRows.map((row) => ({
396
+ ...toAggregate(row),
397
+ period: row.period,
398
+ }));
399
+
400
+ const byCronModel: DoubleKeyedTokenAggregate[] = byCronModelRows.map(
401
+ (row) => ({
402
+ ...toAggregate(row),
403
+ key1: `${row.agent_id}:${row.cron_name ?? row.cron_id}`,
404
+ key2: row.model,
405
+ phase: row.phase,
406
+ }),
407
+ );
408
+
409
+ const byPhase: KeyedTokenAggregate[] = byPhaseRows.map((row) => ({
410
+ ...toAggregate(row),
411
+ key: row.phase,
412
+ }));
413
+
414
+ const bySkill: SkillUsageAggregate[] = bySkillRows.map((row) => ({
415
+ kind: row.kind,
416
+ name: row.name,
417
+ runs: num(row.runs),
418
+ invocations: num(row.invocations),
419
+ turns: num(row.turns),
420
+ input: num(row.input),
421
+ output: num(row.output),
422
+ cacheRead: num(row.cache_read),
423
+ cacheCreation: num(row.cache_creation),
424
+ avgInvokeContextDelta: row.avg_invoke_context_delta,
425
+ }));
426
+
427
+ const baselines: ContextBaselineAggregate[] = baselineRows.map((row) => ({
428
+ contextFingerprint: row.context_fingerprint,
429
+ baselineModel: row.baseline_model,
430
+ phase: row.phase,
431
+ runs: num(row.runs),
432
+ avgContextTokens: row.avg_context_tokens,
433
+ minContextTokens: row.min_context_tokens,
434
+ maxContextTokens: row.max_context_tokens,
435
+ avgTurns: row.avg_turns,
436
+ avgToolCalls: row.avg_tool_calls,
437
+ firstSeen: row.first_seen.toISOString(),
438
+ lastSeen: row.last_seen.toISOString(),
439
+ }));
440
+
441
+ return {
442
+ totals,
443
+ byAgent,
444
+ byCron,
445
+ byModel,
446
+ daily,
447
+ byCronModel,
448
+ byPhase,
449
+ bySkill,
450
+ baselines,
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Per-(phase, contextFingerprint) outcome series for the prompt-audit
456
+ * before/after comparison. Unlike query(), skipped runs are counted here
457
+ * (in `skipped` and `skipReasons`) but excluded from the duration, turn,
458
+ * tool-call, and context-token averages.
459
+ *
460
+ * @param agentId if provided, only that agent's runs (both aggregation
461
+ * blocks); omitted preserves the fleet-wide behavior
462
+ */
463
+ async outcomes(
464
+ from?: string,
465
+ to?: string,
466
+ agentId?: string,
467
+ ): Promise<CronRunOutcomes> {
468
+ // Parameterized agentId fragment appended to the shared filter, so it
469
+ // reaches both queries below.
470
+ const filter = Prisma.sql`${dateFilter(
471
+ from ? new Date(from) : null,
472
+ to ? new Date(to) : null,
473
+ "r",
474
+ )} ${
475
+ agentId !== undefined
476
+ ? Prisma.sql`AND r."agentId" = ${agentId}`
477
+ : Prisma.empty
478
+ }`;
479
+ const [rows, reasonRows] = await Promise.all([
480
+ this.prisma.$queryRaw<OutcomeRow[]>`
481
+ SELECT
482
+ REGEXP_REPLACE(p.name, '^shipwright-', '') AS phase,
483
+ r."contextFingerprint" AS context_fingerprint,
484
+ COUNT(*) AS runs,
485
+ COUNT(*) FILTER (WHERE r.skipped = false AND r.outcome = 'completed') AS completed,
486
+ COUNT(*) FILTER (WHERE r.skipped = false AND r.outcome = 'failed') AS failed,
487
+ COUNT(*) FILTER (WHERE r.skipped = true) AS skipped,
488
+ (AVG(EXTRACT(EPOCH FROM (r."completedAt" - r."startedAt")) * 1000)
489
+ FILTER (WHERE r.skipped = false AND r."completedAt" IS NOT NULL))::float8 AS avg_duration_ms,
490
+ (PERCENTILE_CONT(0.5) WITHIN GROUP (
491
+ ORDER BY EXTRACT(EPOCH FROM (r."completedAt" - r."startedAt")) * 1000
492
+ ) FILTER (WHERE r.skipped = false AND r."completedAt" IS NOT NULL))::float8 AS p50_duration_ms,
493
+ (AVG(r.turns) FILTER (WHERE r.skipped = false))::float8 AS avg_turns,
494
+ (AVG(r."toolCalls") FILTER (WHERE r.skipped = false))::float8 AS avg_tool_calls,
495
+ (AVG(r."baselineContextTokens") FILTER (WHERE r.skipped = false))::float8 AS avg_context_tokens
496
+ FROM "AgentCronRun" r
497
+ LEFT JOIN "AgentCronJob" p ON p.id = r."phaseId"
498
+ WHERE true
499
+ ${filter}
500
+ GROUP BY p.name, r."contextFingerprint"
501
+ ORDER BY p.name NULLS LAST, r."contextFingerprint" NULLS LAST
502
+ `,
503
+ this.prisma.$queryRaw<SkipReasonRow[]>`
504
+ SELECT
505
+ REGEXP_REPLACE(p.name, '^shipwright-', '') AS phase,
506
+ r."contextFingerprint" AS context_fingerprint,
507
+ r."skipReason" AS skip_reason,
508
+ COUNT(*) AS n
509
+ FROM "AgentCronRun" r
510
+ LEFT JOIN "AgentCronJob" p ON p.id = r."phaseId"
511
+ WHERE r.skipped = true
512
+ ${filter}
513
+ GROUP BY p.name, r."contextFingerprint", r."skipReason"
514
+ `,
515
+ ]);
516
+
517
+ const keyOf = (phase: string | null, fp: string | null) =>
518
+ JSON.stringify([phase, fp]);
519
+ const reasons = new Map<string, Record<string, number>>();
520
+ for (const r of reasonRows) {
521
+ const k = keyOf(r.phase, r.context_fingerprint);
522
+ const hist = reasons.get(k) ?? {};
523
+ const reason = r.skip_reason ?? "unknown";
524
+ hist[reason] = (hist[reason] ?? 0) + Number(r.n);
525
+ reasons.set(k, hist);
526
+ }
527
+
528
+ return {
529
+ series: rows.map((row) => ({
530
+ phase: row.phase,
531
+ contextFingerprint: row.context_fingerprint,
532
+ runs: Number(row.runs),
533
+ completed: Number(row.completed),
534
+ failed: Number(row.failed),
535
+ skipped: Number(row.skipped),
536
+ skipReasons:
537
+ reasons.get(keyOf(row.phase, row.context_fingerprint)) ?? {},
538
+ avgDurationMs: row.avg_duration_ms,
539
+ p50DurationMs: row.p50_duration_ms,
540
+ avgTurns: row.avg_turns,
541
+ avgToolCalls: row.avg_tool_calls,
542
+ avgContextTokens: row.avg_context_tokens,
543
+ })),
544
+ };
545
+ }
546
+
547
+ // ─── Private query methods ──────────────────────────────────────────────────
548
+
549
+ private queryTotals(filter: Prisma.Sql): Promise<TotalsRow[]> {
550
+ // All token/cost data lives in AgentCronRunModelBreakdown. A LEFT JOIN keeps
551
+ // runs without breakdown rows in scope for the skipped/date filters (they
552
+ // simply contribute NULL, i.e. zero, to every SUM).
553
+ return this.prisma.$queryRaw<TotalsRow[]>`
554
+ SELECT
555
+ SUM(b."inputTokens") AS input,
556
+ SUM(b."outputTokens") AS output,
557
+ SUM(b."cacheReadTokens") AS cache_read,
558
+ SUM(b."cacheCreationTokens") AS cache_creation,
559
+ SUM(b."costUsd") AS cost_usd
560
+ FROM "AgentCronRun" r
561
+ LEFT JOIN "AgentCronRunModelBreakdown" b ON b."cronRunId" = r.id
562
+ WHERE r.skipped = false
563
+ ${filter}
564
+ `;
565
+ }
566
+
567
+ private queryByAgent(filter: Prisma.Sql): Promise<ByAgentRow[]> {
568
+ return this.prisma.$queryRaw<ByAgentRow[]>`
569
+ SELECT
570
+ r."agentId" AS agent_id,
571
+ SUM(b."inputTokens") AS input,
572
+ SUM(b."outputTokens") AS output,
573
+ SUM(b."cacheReadTokens") AS cache_read,
574
+ SUM(b."cacheCreationTokens") AS cache_creation,
575
+ SUM(b."costUsd") AS cost_usd
576
+ FROM "AgentCronRun" r
577
+ LEFT JOIN "AgentCronRunModelBreakdown" b ON b."cronRunId" = r.id
578
+ WHERE r.skipped = false
579
+ ${filter}
580
+ GROUP BY r."agentId"
581
+ ORDER BY r."agentId"
582
+ `;
583
+ }
584
+
585
+ private queryByCron(filter: Prisma.Sql): Promise<ByCronRow[]> {
586
+ // GROUP BY includes p.name (LPC-3.1, via phaseId): Postgres treats NULL
587
+ // as a single group, so legacy/unreconciled runs (phaseId IS NULL) for a
588
+ // cron still collapse into one row — identical to the pre-WL-3.5
589
+ // cronId-only grouping. Runs that do carry a phaseId produce one row per
590
+ // (cronId, phase). The "shipwright-" prefix is stripped from p.name so
591
+ // the label matches exactly what the old r.phase string column used to
592
+ // contain (e.g. "dev-task", not "shipwright-dev-task").
593
+ return this.prisma.$queryRaw<ByCronRow[]>`
594
+ SELECT
595
+ r."agentId" AS agent_id,
596
+ r."cronId" AS cron_id,
597
+ j.name AS cron_name,
598
+ REGEXP_REPLACE(p.name, '^shipwright-', '') AS phase,
599
+ SUM(b."inputTokens") AS input,
600
+ SUM(b."outputTokens") AS output,
601
+ SUM(b."cacheReadTokens") AS cache_read,
602
+ SUM(b."cacheCreationTokens") AS cache_creation,
603
+ SUM(b."costUsd") AS cost_usd
604
+ FROM "AgentCronRun" r
605
+ LEFT JOIN "AgentCronJob" j ON j.id = r."cronId"
606
+ LEFT JOIN "AgentCronJob" p ON p.id = r."phaseId"
607
+ LEFT JOIN "AgentCronRunModelBreakdown" b ON b."cronRunId" = r.id
608
+ WHERE r.skipped = false
609
+ ${filter}
610
+ GROUP BY r."agentId", r."cronId", j.name, p.name
611
+ ORDER BY r."agentId", r."cronId", p.name
612
+ `;
613
+ }
614
+
615
+ private queryByModel(filter: Prisma.Sql): Promise<ByModelRow[]> {
616
+ // All per-model data comes from AgentCronRunModelBreakdown.
617
+ // Runs without breakdown rows simply have no byModel data.
618
+ return this.prisma.$queryRaw<ByModelRow[]>`
619
+ SELECT
620
+ r."agentId" AS agent_id,
621
+ b.model AS model,
622
+ SUM(b."inputTokens") AS input,
623
+ SUM(b."outputTokens") AS output,
624
+ SUM(b."cacheReadTokens") AS cache_read,
625
+ SUM(b."cacheCreationTokens") AS cache_creation,
626
+ SUM(b."costUsd") AS cost_usd
627
+ FROM "AgentCronRun" r
628
+ INNER JOIN "AgentCronRunModelBreakdown" b ON b."cronRunId" = r.id
629
+ WHERE r.skipped = false
630
+ ${filter}
631
+ GROUP BY r."agentId", b.model
632
+ ORDER BY r."agentId", b.model
633
+ `;
634
+ }
635
+
636
+ private queryDaily(filter: Prisma.Sql): Promise<DailyRow[]> {
637
+ return this.prisma.$queryRaw<DailyRow[]>`
638
+ SELECT
639
+ TO_CHAR(DATE(r."startedAt"), 'YYYY-MM-DD') AS period,
640
+ SUM(b."inputTokens") AS input,
641
+ SUM(b."outputTokens") AS output,
642
+ SUM(b."cacheReadTokens") AS cache_read,
643
+ SUM(b."cacheCreationTokens") AS cache_creation,
644
+ SUM(b."costUsd") AS cost_usd
645
+ FROM "AgentCronRun" r
646
+ LEFT JOIN "AgentCronRunModelBreakdown" b ON b."cronRunId" = r.id
647
+ WHERE r.skipped = false
648
+ ${filter}
649
+ GROUP BY DATE(r."startedAt")
650
+ ORDER BY DATE(r."startedAt")
651
+ `;
652
+ }
653
+
654
+ private queryByCronModel(filter: Prisma.Sql): Promise<ByCronModelRow[]> {
655
+ // GROUP BY includes p.name (LPC-3.1, via phaseId) — same
656
+ // NULL-collapses-to-one-group reasoning as queryByCron above.
657
+ return this.prisma.$queryRaw<ByCronModelRow[]>`
658
+ SELECT
659
+ r."agentId" AS agent_id,
660
+ r."cronId" AS cron_id,
661
+ j.name AS cron_name,
662
+ b.model AS model,
663
+ REGEXP_REPLACE(p.name, '^shipwright-', '') AS phase,
664
+ SUM(b."inputTokens") AS input,
665
+ SUM(b."outputTokens") AS output,
666
+ SUM(b."cacheReadTokens") AS cache_read,
667
+ SUM(b."cacheCreationTokens") AS cache_creation,
668
+ SUM(b."costUsd") AS cost_usd
669
+ FROM "AgentCronRun" r
670
+ LEFT JOIN "AgentCronJob" j ON j.id = r."cronId"
671
+ LEFT JOIN "AgentCronJob" p ON p.id = r."phaseId"
672
+ INNER JOIN "AgentCronRunModelBreakdown" b ON b."cronRunId" = r.id
673
+ WHERE r.skipped = false
674
+ ${filter}
675
+ GROUP BY r."agentId", r."cronId", j.name, b.model, p.name
676
+ ORDER BY r."agentId", r."cronId", b.model, p.name
677
+ `;
678
+ }
679
+
680
+ private queryByPhase(filter: Prisma.Sql): Promise<ByPhaseRow[]> {
681
+ // Runs with no phaseId (legacy five-job crons, or an unreconciled agent)
682
+ // are excluded — phase is additive grouping, not a replacement for
683
+ // totals/byAgent/etc. Label derived by LEFT JOINing "AgentCronJob" (alias
684
+ // p) on r."phaseId" = p.id and stripping the "shipwright-" prefix from
685
+ // p.name, matching what r.phase used to contain directly.
686
+ return this.prisma.$queryRaw<ByPhaseRow[]>`
687
+ SELECT
688
+ REGEXP_REPLACE(p.name, '^shipwright-', '') AS phase,
689
+ SUM(b."inputTokens") AS input,
690
+ SUM(b."outputTokens") AS output,
691
+ SUM(b."cacheReadTokens") AS cache_read,
692
+ SUM(b."cacheCreationTokens") AS cache_creation,
693
+ SUM(b."costUsd") AS cost_usd
694
+ FROM "AgentCronRun" r
695
+ LEFT JOIN "AgentCronJob" p ON p.id = r."phaseId"
696
+ LEFT JOIN "AgentCronRunModelBreakdown" b ON b."cronRunId" = r.id
697
+ WHERE r.skipped = false AND r."phaseId" IS NOT NULL
698
+ ${filter}
699
+ GROUP BY p.name
700
+ ORDER BY p.name
701
+ `;
702
+ }
703
+
704
+ private queryBySkill(filter: Prisma.Sql): Promise<BySkillRow[]> {
705
+ // Skipped runs excluded (same rule as every token dimension). AVG ignores
706
+ // NULL invokeContextDelta rows, so a skill never measured yields NULL.
707
+ return this.prisma.$queryRaw<BySkillRow[]>`
708
+ SELECT
709
+ u.kind AS kind,
710
+ u.name AS name,
711
+ COUNT(DISTINCT r.id) AS runs,
712
+ SUM(u.invocations) AS invocations,
713
+ SUM(u.turns) AS turns,
714
+ SUM(u."inputTokens") AS input,
715
+ SUM(u."outputTokens") AS output,
716
+ SUM(u."cacheReadTokens") AS cache_read,
717
+ SUM(u."cacheCreationTokens") AS cache_creation,
718
+ AVG(u."invokeContextDelta")::float8 AS avg_invoke_context_delta
719
+ FROM "AgentCronRun" r
720
+ INNER JOIN "AgentCronRunSkillUsage" u ON u."cronRunId" = r.id
721
+ WHERE r.skipped = false
722
+ ${filter}
723
+ GROUP BY u.kind, u.name
724
+ ORDER BY u.kind, u.name
725
+ `;
726
+ }
727
+
728
+ private queryBaselines(filter: Prisma.Sql): Promise<BaselineRow[]> {
729
+ // Skipped runs are deliberately included; runs with no baseline are not.
730
+ // NULL fingerprint/model/phase each collapse into a single group.
731
+ return this.prisma.$queryRaw<BaselineRow[]>`
732
+ SELECT
733
+ r."contextFingerprint" AS context_fingerprint,
734
+ r."baselineModel" AS baseline_model,
735
+ REGEXP_REPLACE(p.name, '^shipwright-', '') AS phase,
736
+ COUNT(*) AS runs,
737
+ AVG(r."baselineContextTokens")::float8 AS avg_context_tokens,
738
+ MIN(r."baselineContextTokens") AS min_context_tokens,
739
+ MAX(r."baselineContextTokens") AS max_context_tokens,
740
+ AVG(r.turns)::float8 AS avg_turns,
741
+ AVG(r."toolCalls")::float8 AS avg_tool_calls,
742
+ MIN(r."startedAt") AS first_seen,
743
+ MAX(r."startedAt") AS last_seen
744
+ FROM "AgentCronRun" r
745
+ LEFT JOIN "AgentCronJob" p ON p.id = r."phaseId"
746
+ WHERE r."baselineContextTokens" IS NOT NULL
747
+ ${filter}
748
+ GROUP BY r."contextFingerprint", r."baselineModel", p.name
749
+ ORDER BY MIN(r."startedAt"), r."contextFingerprint", r."baselineModel", p.name
750
+ `;
751
+ }
752
+ }