@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.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- 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
|
+
}
|