@app-vitals/agent-admin 0.0.0-stage → 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.
- 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 +64 -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,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/prisma-client.integration.test.ts
|
|
3
|
+
* Integration tests for the Prisma 7 driver-adapter client factory against a
|
|
4
|
+
* real PostgreSQL DB.
|
|
5
|
+
*
|
|
6
|
+
* The rest of the admin integration suite is the regression check for query
|
|
7
|
+
* *behavior* across the v6→v7 upgrade. This file covers the one thing that is
|
|
8
|
+
* genuinely new: client construction now goes through a `PrismaPg` driver
|
|
9
|
+
* adapter over a `pg.Pool` instead of Prisma's built-in query engine, so the
|
|
10
|
+
* boot path itself needs a live-DB assertion that it connects and round-trips.
|
|
11
|
+
*
|
|
12
|
+
* Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
16
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
17
|
+
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
18
|
+
|
|
19
|
+
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
20
|
+
|
|
21
|
+
const describeOrSkip = TEST_DB ? describe : describe.skip;
|
|
22
|
+
|
|
23
|
+
describeOrSkip("createAdminPrismaClient (integration)", () => {
|
|
24
|
+
let prisma: PrismaClient | undefined;
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await prisma?.$disconnect();
|
|
28
|
+
prisma = undefined;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("boots an adapter-backed client that executes a raw round-trip query", async () => {
|
|
32
|
+
// TEST_DB is guaranteed set — the describe block is skipped otherwise.
|
|
33
|
+
prisma = createAdminPrismaClient(TEST_DB as string);
|
|
34
|
+
|
|
35
|
+
const rows = await prisma.$queryRaw<
|
|
36
|
+
Array<{ one: number }>
|
|
37
|
+
>`SELECT 1 AS one`;
|
|
38
|
+
|
|
39
|
+
expect(rows).toEqual([{ one: 1 }]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("round-trips a model write and read back through the adapter", async () => {
|
|
43
|
+
prisma = createAdminPrismaClient(TEST_DB as string);
|
|
44
|
+
|
|
45
|
+
const created = await prisma.agent.create({
|
|
46
|
+
data: { name: "Adapter Boot Agent" },
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
const found = await prisma.agent.findUnique({
|
|
50
|
+
where: { id: created.id },
|
|
51
|
+
});
|
|
52
|
+
expect(found?.name).toBe("Adapter Boot Agent");
|
|
53
|
+
} finally {
|
|
54
|
+
await prisma.agent.delete({ where: { id: created.id } });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/prisma-client.ts
|
|
3
|
+
*
|
|
4
|
+
* Single place the admin service builds a PrismaClient.
|
|
5
|
+
*
|
|
6
|
+
* Prisma 7 removed the bundled query engine: a client no longer connects off
|
|
7
|
+
* `datasource.url` by itself, it needs a driver adapter. Admin talks to
|
|
8
|
+
* Postgres, so every client is a `PrismaPg` adapter wrapping a `pg.Pool`.
|
|
9
|
+
* Keeping that in one factory means the runtime (main.ts), the dev/CI scripts,
|
|
10
|
+
* and the integration suites all get the same pool tuning instead of each
|
|
11
|
+
* re-deriving it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { pinPgSslMode } from "@app-vitals/agent-lib/pg-sslmode";
|
|
15
|
+
import {
|
|
16
|
+
type CheckoutPool,
|
|
17
|
+
type Clock,
|
|
18
|
+
type PoolLog,
|
|
19
|
+
startPoolMonitor,
|
|
20
|
+
trackConnectionHold,
|
|
21
|
+
} from "@app-vitals/agent-lib/pool-observability";
|
|
22
|
+
import { PrismaPg } from "@prisma/adapter-pg";
|
|
23
|
+
import pg from "pg";
|
|
24
|
+
import { PrismaClient } from "../prisma/client/client.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Connect timeout for a pooled Postgres connection.
|
|
28
|
+
*
|
|
29
|
+
* Prisma 6 applied an implicit 5s connect timeout for Postgres; Prisma 7's
|
|
30
|
+
* driver adapters do not, and `pg.Pool` defaults to waiting forever. Without an
|
|
31
|
+
* explicit value an unreachable database would hang the boot preflight (and
|
|
32
|
+
* every request) instead of failing fast, so this restores bounded behavior.
|
|
33
|
+
* Slightly above the old 5s to stay tolerant of a cold Cloud SQL connection.
|
|
34
|
+
*/
|
|
35
|
+
export const DB_CONNECT_TIMEOUT_MS = 10_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the `pg.Pool` the admin service's Prisma client runs on.
|
|
39
|
+
*
|
|
40
|
+
* Split out of `createAdminPrismaClient` so the pool's own configuration — the
|
|
41
|
+
* connect timeout and the mandatory `error` listener — is assertable without a
|
|
42
|
+
* live database.
|
|
43
|
+
*/
|
|
44
|
+
export function createAdminPgPool(databaseUrl: string): pg.Pool {
|
|
45
|
+
// Fail loudly on a missing connection string rather than booting against an
|
|
46
|
+
// unintended database. `pg` only parses `connectionString` when it is truthy
|
|
47
|
+
// (`if (config.connectionString) { ... }` in its ConnectionParameters), so a
|
|
48
|
+
// blank value is silently dropped and `pg.Pool` falls back to
|
|
49
|
+
// PGHOST/PGUSER/PGPASSWORD/PGDATABASE — or localhost:5432 as the OS user.
|
|
50
|
+
// The process would then either talk to the wrong database or die later with
|
|
51
|
+
// a bare `ECONNREFUSED 127.0.0.1:5432` that names no env var. Prisma 6's
|
|
52
|
+
// `new PrismaClient()` threw `Environment variable not found:
|
|
53
|
+
// DATABASE_URL_SHIPWRIGHT_ADMIN` immediately; this restores that.
|
|
54
|
+
if (databaseUrl.trim() === "") {
|
|
55
|
+
throw new Error(
|
|
56
|
+
"DATABASE_URL_SHIPWRIGHT_ADMIN is not set — the admin service requires a Postgres connection string (postgresql://user:password@host:5432/database)",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pool = new pg.Pool({
|
|
61
|
+
// Legacy sslmode aliases are pinned to verify-full so a future pg major
|
|
62
|
+
// cannot silently drop certificate verification — see lib/pg-sslmode.ts.
|
|
63
|
+
connectionString: pinPgSslMode(databaseUrl),
|
|
64
|
+
connectionTimeoutMillis: DB_CONNECT_TIMEOUT_MS,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Required, not diagnostic logging: `pg.Pool` re-emits backend/network errors
|
|
68
|
+
// hit by *idle* clients as its own `error` event, and `Pool` is an
|
|
69
|
+
// `EventEmitter` — an `error` event with no listener is rethrown as an
|
|
70
|
+
// uncaught exception that kills the process. Prisma 6's Rust query engine
|
|
71
|
+
// owned the pool and absorbed these; under Prisma 7 the pool is plain
|
|
72
|
+
// application code, so admin now has to own it. The service sits behind Cloud
|
|
73
|
+
// SQL / a proxy sidecar that drops idle connections, and `pg`'s default
|
|
74
|
+
// `idleTimeoutMillis` keeps idle clients around long enough to be hit. The
|
|
75
|
+
// pool discards the broken client on its own, so logging is the correct
|
|
76
|
+
// response — the next checkout just opens a fresh connection.
|
|
77
|
+
pool.on("error", (err) => {
|
|
78
|
+
console.error("[admin] idle pg pool client error:", err);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return pool;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Positive-integer env parse; anything else (unset, blank, junk, <=0) means "use the default". */
|
|
85
|
+
function parsePositiveInt(raw: string | undefined): number | undefined {
|
|
86
|
+
if (raw === undefined || !/^\s*\d+\s*$/.test(raw)) return undefined;
|
|
87
|
+
const n = Number(raw);
|
|
88
|
+
return n > 0 ? n : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface AdminPoolObservabilityConfig {
|
|
92
|
+
holdThresholdMs: number | undefined;
|
|
93
|
+
monitorIntervalMs: number | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the pool-observability env overrides
|
|
98
|
+
* (SHIPWRIGHT_ADMIN_POOL_HOLD_THRESHOLD_MS / SHIPWRIGHT_ADMIN_POOL_MONITOR_INTERVAL_MS).
|
|
99
|
+
* Invalid values yield `undefined` so lib/pool-observability.ts defaults apply.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveAdminPoolObservabilityConfig(
|
|
102
|
+
env: Record<string, string | undefined>,
|
|
103
|
+
): AdminPoolObservabilityConfig {
|
|
104
|
+
return {
|
|
105
|
+
holdThresholdMs: parsePositiveInt(
|
|
106
|
+
env.SHIPWRIGHT_ADMIN_POOL_HOLD_THRESHOLD_MS,
|
|
107
|
+
),
|
|
108
|
+
monitorIntervalMs: parsePositiveInt(
|
|
109
|
+
env.SHIPWRIGHT_ADMIN_POOL_MONITOR_INTERVAL_MS,
|
|
110
|
+
),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Injection seams for tests. */
|
|
115
|
+
export interface ObserveAdminPoolDeps {
|
|
116
|
+
log?: PoolLog;
|
|
117
|
+
clock?: Clock;
|
|
118
|
+
setInterval?: (fn: () => void, ms: number) => unknown;
|
|
119
|
+
clearInterval?: (handle: unknown) => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Wire hold tracking and the waiting-clients monitor onto the admin pool.
|
|
124
|
+
* Observability only — no pool size or query behavior changes. Returns a
|
|
125
|
+
* single stop fn (stops the monitor, restores `pool.connect`) for shutdown.
|
|
126
|
+
*/
|
|
127
|
+
export function observeAdminPool(
|
|
128
|
+
pool: CheckoutPool,
|
|
129
|
+
env: Record<string, string | undefined> = process.env,
|
|
130
|
+
deps: ObserveAdminPoolDeps = {},
|
|
131
|
+
): () => void {
|
|
132
|
+
const { holdThresholdMs, monitorIntervalMs } =
|
|
133
|
+
resolveAdminPoolObservabilityConfig(env);
|
|
134
|
+
const log: PoolLog = deps.log ?? ((m, d) => console.warn(`[admin] ${m}`, d));
|
|
135
|
+
const stopHold = trackConnectionHold(pool, {
|
|
136
|
+
thresholdMs: holdThresholdMs,
|
|
137
|
+
log,
|
|
138
|
+
clock: deps.clock,
|
|
139
|
+
});
|
|
140
|
+
const stopMonitor = startPoolMonitor(pool, {
|
|
141
|
+
intervalMs: monitorIntervalMs,
|
|
142
|
+
log,
|
|
143
|
+
setInterval: deps.setInterval,
|
|
144
|
+
clearInterval: deps.clearInterval,
|
|
145
|
+
});
|
|
146
|
+
return () => {
|
|
147
|
+
stopMonitor();
|
|
148
|
+
stopHold();
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface CreateAdminPrismaClientOptions {
|
|
153
|
+
/**
|
|
154
|
+
* When set, the pool is instrumented via `observeAdminPool` and this
|
|
155
|
+
* callback receives the pool plus the stop fn (call on shutdown).
|
|
156
|
+
*/
|
|
157
|
+
onPoolObserved?: (observed: { pool: pg.Pool; stop: () => void }) => void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Build an adapter-backed PrismaClient for the given Postgres connection string.
|
|
162
|
+
*
|
|
163
|
+
* `disposeExternalPool: true` is required, not cosmetic: the adapter leaves an
|
|
164
|
+
* externally supplied pool open on dispose by default, so without it every
|
|
165
|
+
* `prisma.$disconnect()` would leak its pool's sockets — which the integration
|
|
166
|
+
* suites (a fresh client per `beforeEach`) would turn into connection
|
|
167
|
+
* exhaustion within a single run.
|
|
168
|
+
*
|
|
169
|
+
* Note for BYO-Postgres operators: the connection string is parsed by `pg`, not
|
|
170
|
+
* by Prisma's old query engine, so Prisma-only query params (`schema`,
|
|
171
|
+
* `connection_limit`, `pool_timeout`, `pgbouncer`) are ignored at runtime and
|
|
172
|
+
* `sslmode=require` now verifies the server certificate (pinned explicitly to
|
|
173
|
+
* `verify-full` by `pinPgSslMode`, so a pg upgrade cannot weaken it). See
|
|
174
|
+
* `docs/migration.md` ("Breaking: admin's `DATABASE_URL_SHIPWRIGHT_ADMIN` is
|
|
175
|
+
* now parsed by `pg`, not Prisma").
|
|
176
|
+
*/
|
|
177
|
+
export function createAdminPrismaClient(
|
|
178
|
+
databaseUrl: string,
|
|
179
|
+
opts: CreateAdminPrismaClientOptions = {},
|
|
180
|
+
): PrismaClient {
|
|
181
|
+
const pool = createAdminPgPool(databaseUrl);
|
|
182
|
+
if (opts.onPoolObserved) {
|
|
183
|
+
opts.onPoolObserved({ pool, stop: observeAdminPool(pool) });
|
|
184
|
+
}
|
|
185
|
+
const adapter = new PrismaPg(pool, { disposeExternalPool: true });
|
|
186
|
+
return new PrismaClient({ adapter });
|
|
187
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/prisma-client.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit coverage for the parts of the client factory that do no I/O.
|
|
5
|
+
*
|
|
6
|
+
* Under Prisma 7 the client is built on a `pg.Pool` that admin now owns, so two
|
|
7
|
+
* things have to be right before anything connects:
|
|
8
|
+
* - a blank `connectionString` must fail loudly — `pg` silently ignores a
|
|
9
|
+
* falsy one (falling back to PGHOST/PGUSER/... or localhost), which would
|
|
10
|
+
* boot the service against an unintended database;
|
|
11
|
+
* - the pool must carry an `error` listener — `Pool` is an `EventEmitter`, and
|
|
12
|
+
* an idle-client `error` with no listener is rethrown as an uncaught
|
|
13
|
+
* exception that kills the process.
|
|
14
|
+
*
|
|
15
|
+
* `pg.Pool` does not connect on construction, so both are assertable here. The
|
|
16
|
+
* connecting paths live in prisma-client.integration.test.ts (real DB).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it } from "bun:test";
|
|
20
|
+
import {
|
|
21
|
+
createAdminPgPool,
|
|
22
|
+
createAdminPrismaClient,
|
|
23
|
+
DB_CONNECT_TIMEOUT_MS,
|
|
24
|
+
observeAdminPool,
|
|
25
|
+
resolveAdminPoolObservabilityConfig,
|
|
26
|
+
} from "./prisma-client.ts";
|
|
27
|
+
|
|
28
|
+
/** A syntactically valid URL. `pg.Pool` does not connect on construction. */
|
|
29
|
+
const DUMMY_URL = "postgresql://user:password@localhost:5432/shipwright_admin";
|
|
30
|
+
|
|
31
|
+
describe("createAdminPgPool", () => {
|
|
32
|
+
it("attaches an 'error' listener so an idle-client error cannot crash the process", async () => {
|
|
33
|
+
const pool = createAdminPgPool(DUMMY_URL);
|
|
34
|
+
try {
|
|
35
|
+
// `Pool` is an EventEmitter: an 'error' event with no listener is
|
|
36
|
+
// rethrown as an uncaught exception. Prisma 6's engine owned the pool and
|
|
37
|
+
// absorbed these; under Prisma 7 the pool is ours, so the listener is
|
|
38
|
+
// load-bearing rather than cosmetic.
|
|
39
|
+
expect(pool.listenerCount("error")).toBeGreaterThan(0);
|
|
40
|
+
} finally {
|
|
41
|
+
await pool.end();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not rethrow when the pool emits an idle-client 'error'", async () => {
|
|
46
|
+
const pool = createAdminPgPool(DUMMY_URL);
|
|
47
|
+
try {
|
|
48
|
+
expect(() => {
|
|
49
|
+
pool.emit("error", new Error("Connection terminated unexpectedly"));
|
|
50
|
+
}).not.toThrow();
|
|
51
|
+
} finally {
|
|
52
|
+
await pool.end();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("pins a legacy sslmode=require to verify-full for pg", async () => {
|
|
57
|
+
const pool = createAdminPgPool(`${DUMMY_URL}?sslmode=require`);
|
|
58
|
+
try {
|
|
59
|
+
expect(pool.options.connectionString).toBe(
|
|
60
|
+
`${DUMMY_URL}?sslmode=verify-full`,
|
|
61
|
+
);
|
|
62
|
+
} finally {
|
|
63
|
+
await pool.end();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("bounds the connect timeout instead of waiting forever", async () => {
|
|
68
|
+
const pool = createAdminPgPool(DUMMY_URL);
|
|
69
|
+
try {
|
|
70
|
+
expect(pool.options.connectionTimeoutMillis).toBe(DB_CONNECT_TIMEOUT_MS);
|
|
71
|
+
} finally {
|
|
72
|
+
await pool.end();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("throws on a blank connection string before constructing a pool", () => {
|
|
77
|
+
expect(() => createAdminPgPool("")).toThrow(
|
|
78
|
+
/DATABASE_URL_SHIPWRIGHT_ADMIN is not set/,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("createAdminPrismaClient — missing connection string", () => {
|
|
84
|
+
it("throws when the connection string is empty", () => {
|
|
85
|
+
expect(() => createAdminPrismaClient("")).toThrow(
|
|
86
|
+
/DATABASE_URL_SHIPWRIGHT_ADMIN is not set/,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("throws when the connection string is only whitespace", () => {
|
|
91
|
+
expect(() => createAdminPrismaClient(" ")).toThrow(
|
|
92
|
+
/DATABASE_URL_SHIPWRIGHT_ADMIN is not set/,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("names the env var and the expected format in the error message", () => {
|
|
97
|
+
let message = "";
|
|
98
|
+
try {
|
|
99
|
+
createAdminPrismaClient("");
|
|
100
|
+
} catch (err) {
|
|
101
|
+
message = (err as Error).message;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
expect(message).toContain("DATABASE_URL_SHIPWRIGHT_ADMIN");
|
|
105
|
+
expect(message).toContain("postgresql://");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("resolveAdminPoolObservabilityConfig", () => {
|
|
110
|
+
it("returns undefined overrides when env vars are unset", () => {
|
|
111
|
+
expect(resolveAdminPoolObservabilityConfig({})).toEqual({
|
|
112
|
+
holdThresholdMs: undefined,
|
|
113
|
+
monitorIntervalMs: undefined,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("parses positive integer overrides", () => {
|
|
118
|
+
expect(
|
|
119
|
+
resolveAdminPoolObservabilityConfig({
|
|
120
|
+
SHIPWRIGHT_ADMIN_POOL_HOLD_THRESHOLD_MS: "500",
|
|
121
|
+
SHIPWRIGHT_ADMIN_POOL_MONITOR_INTERVAL_MS: "1000",
|
|
122
|
+
}),
|
|
123
|
+
).toEqual({ holdThresholdMs: 500, monitorIntervalMs: 1000 });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("falls back to defaults for blank, non-numeric, zero, or negative values", () => {
|
|
127
|
+
for (const bad of ["", " ", "abc", "0", "-5", "1.5", "NaN"]) {
|
|
128
|
+
expect(
|
|
129
|
+
resolveAdminPoolObservabilityConfig({
|
|
130
|
+
SHIPWRIGHT_ADMIN_POOL_HOLD_THRESHOLD_MS: bad,
|
|
131
|
+
SHIPWRIGHT_ADMIN_POOL_MONITOR_INTERVAL_MS: bad,
|
|
132
|
+
}),
|
|
133
|
+
).toEqual({ holdThresholdMs: undefined, monitorIntervalMs: undefined });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("observeAdminPool", () => {
|
|
139
|
+
it("logs holds over the env threshold and stops the monitor on stop()", async () => {
|
|
140
|
+
let now = 0;
|
|
141
|
+
const logs: Array<[string, unknown]> = [];
|
|
142
|
+
const cleared: unknown[] = [];
|
|
143
|
+
const intervals: number[] = [];
|
|
144
|
+
const released: unknown[] = [];
|
|
145
|
+
const pool = {
|
|
146
|
+
totalCount: 1,
|
|
147
|
+
idleCount: 0,
|
|
148
|
+
waitingCount: 0,
|
|
149
|
+
options: { max: 10 },
|
|
150
|
+
connect: async () => ({ release: (e?: unknown) => released.push(e) }),
|
|
151
|
+
};
|
|
152
|
+
const stop = observeAdminPool(
|
|
153
|
+
pool,
|
|
154
|
+
{
|
|
155
|
+
SHIPWRIGHT_ADMIN_POOL_HOLD_THRESHOLD_MS: "100",
|
|
156
|
+
SHIPWRIGHT_ADMIN_POOL_MONITOR_INTERVAL_MS: "250",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
log: (m, d) => logs.push([m, d]),
|
|
160
|
+
clock: { now: () => new Date(now) },
|
|
161
|
+
setInterval: (_fn, ms) => {
|
|
162
|
+
intervals.push(ms);
|
|
163
|
+
return "h";
|
|
164
|
+
},
|
|
165
|
+
clearInterval: (h) => cleared.push(h),
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
expect(intervals).toEqual([250]);
|
|
169
|
+
const client = await pool.connect();
|
|
170
|
+
now = 150;
|
|
171
|
+
client.release();
|
|
172
|
+
expect(logs.length).toBe(1);
|
|
173
|
+
expect(logs[0]?.[0]).toBe("pg connection held past threshold");
|
|
174
|
+
expect(logs[0]?.[1]).toMatchObject({ heldMs: 150 });
|
|
175
|
+
stop();
|
|
176
|
+
expect(cleared).toEqual(["h"]);
|
|
177
|
+
});
|
|
178
|
+
});
|
package/src/prisma-tx.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/prisma-tx.ts
|
|
3
|
+
* Shared type for service methods that can optionally participate in a
|
|
4
|
+
* caller-supplied interactive Prisma transaction (APA-1.1).
|
|
5
|
+
*
|
|
6
|
+
* A method that normally writes via `this.prisma` (the top-level PrismaClient)
|
|
7
|
+
* accepts this as an optional trailing parameter, defaulting to `this.prisma`
|
|
8
|
+
* when omitted. Passing the `tx` argument from `prisma.$transaction(async
|
|
9
|
+
* (tx) => ...)` instead makes that write participate in the caller's
|
|
10
|
+
* transaction — see AgentService.runTransaction() and createAgent() in
|
|
11
|
+
* agents.ts for the orchestrator that drives this.
|
|
12
|
+
*
|
|
13
|
+
* A real `PrismaClient` structurally satisfies this type (it merely omits a
|
|
14
|
+
* handful of methods Prisma disallows inside an interactive transaction —
|
|
15
|
+
* $connect/$disconnect/$on/$use/$extends), so both the top-level client and
|
|
16
|
+
* the `tx` callback argument are valid arguments wherever this type is used.
|
|
17
|
+
*/
|
|
18
|
+
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
19
|
+
|
|
20
|
+
export type PrismaTransactionClient = Omit<
|
|
21
|
+
PrismaClient,
|
|
22
|
+
"$connect" | "$disconnect" | "$on" | "$use" | "$extends"
|
|
23
|
+
>;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/push-content.ts
|
|
3
|
+
* The notification-content POLICY, as pure functions.
|
|
4
|
+
*
|
|
5
|
+
* A notification renders on a LOCKED phone, possibly in public. So the visible
|
|
6
|
+
* fields are deliberately austere:
|
|
7
|
+
* - generic : title "Your agent replied", no body. (default, safest)
|
|
8
|
+
* - title : + the thread's title in the body.
|
|
9
|
+
* - preview : + up to 120 chars of the reply preview.
|
|
10
|
+
*
|
|
11
|
+
* NEVER included at ANY level, in ANY visible field: agent name (agent names
|
|
12
|
+
* are frequently client-shaped), ids, repo names, file paths, costs. The thread
|
|
13
|
+
* title and preview are USER-authored free text, so they could themselves
|
|
14
|
+
* contain such tokens — sanitizePublic() scrubs id/repo/path shapes before they
|
|
15
|
+
* reach a lock screen. The deep-link `url` is the ONLY field allowed to carry a
|
|
16
|
+
* threadId (the phone consumes it, it isn't rendered on the lock screen).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type PushDetailLevel = "generic" | "title" | "preview";
|
|
20
|
+
|
|
21
|
+
const LEVELS: PushDetailLevel[] = ["generic", "title", "preview"];
|
|
22
|
+
const LEVEL_RANK: Record<PushDetailLevel, number> = {
|
|
23
|
+
generic: 0,
|
|
24
|
+
title: 1,
|
|
25
|
+
preview: 2,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Operator ceiling default per the brief. */
|
|
29
|
+
export const DEFAULT_MAX_DETAIL: PushDetailLevel = "title";
|
|
30
|
+
/** Safest per-subscription default. */
|
|
31
|
+
const DEFAULT_OPT_IN: PushDetailLevel = "generic";
|
|
32
|
+
|
|
33
|
+
const GENERIC_TITLE = "Your agent replied";
|
|
34
|
+
const PREVIEW_MAX = 120;
|
|
35
|
+
|
|
36
|
+
function isLevel(v: unknown): v is PushDetailLevel {
|
|
37
|
+
return typeof v === "string" && (LEVELS as string[]).includes(v);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Effective detail level = min(operator ceiling, user opt-in). An unknown/unset
|
|
42
|
+
* ceiling falls back to the operator default (title); an unknown/unset opt-in
|
|
43
|
+
* falls back to the safest level (generic).
|
|
44
|
+
*/
|
|
45
|
+
export function resolveDetailLevel(
|
|
46
|
+
ceiling: PushDetailLevel | string | undefined,
|
|
47
|
+
optIn: PushDetailLevel | string | undefined,
|
|
48
|
+
): PushDetailLevel {
|
|
49
|
+
const c = isLevel(ceiling) ? ceiling : DEFAULT_MAX_DETAIL;
|
|
50
|
+
const o = isLevel(optIn) ? optIn : DEFAULT_OPT_IN;
|
|
51
|
+
return LEVEL_RANK[c] <= LEVEL_RANK[o] ? c : o;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NotificationThread {
|
|
55
|
+
threadId: string;
|
|
56
|
+
agentId: string;
|
|
57
|
+
title?: string | null;
|
|
58
|
+
/** The reply text (or a slice of it) used to build a preview. */
|
|
59
|
+
preview?: string | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface NotificationPayload {
|
|
63
|
+
title: string;
|
|
64
|
+
body: string;
|
|
65
|
+
/** Deep link to the thread — consumed by the SW, not shown on the lock screen. */
|
|
66
|
+
url: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Token shapes that must never surface on a lock screen. Applied to any
|
|
70
|
+
// user-authored free text (thread title / preview) before it is rendered.
|
|
71
|
+
const ID_SHAPE = /\b[a-z]{2,}[_-][A-Za-z0-9_-]{4,}\b/g; // agt_..., thr_..., WLS-2.2-ish
|
|
72
|
+
const CUID_SHAPE = /\bc[a-z0-9]{24}\b/g; // prisma cuid
|
|
73
|
+
const REPO_SHAPE = /\b[\w.-]+\/[\w.-]+(?:\/[\w.-]+)*\b/g; // owner/repo, src/foo/bar.ts
|
|
74
|
+
const COST_SHAPE = /\$\s?\d[\d,]*(?:\.\d+)?/g; // $4.20
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Scrubs id/repo/path/cost shapes out of user-authored free text so nothing
|
|
78
|
+
* client-shaped reaches a lock screen. Conservative by design: a false-positive
|
|
79
|
+
* redaction (an innocuous slash-word) is strictly safer than a leak.
|
|
80
|
+
*/
|
|
81
|
+
function sanitizePublic(text: string): string {
|
|
82
|
+
return text
|
|
83
|
+
.replace(CUID_SHAPE, "…")
|
|
84
|
+
.replace(REPO_SHAPE, "…")
|
|
85
|
+
.replace(ID_SHAPE, "…")
|
|
86
|
+
.replace(COST_SHAPE, "…")
|
|
87
|
+
.replace(/\s+/g, " ")
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Truncates already-sanitized preview text to the shared lock-screen cap. */
|
|
92
|
+
function truncateForPreview(text: string): string {
|
|
93
|
+
return text.length > PREVIEW_MAX ? text.slice(0, PREVIEW_MAX) : text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Builds the notification payload for a resolved detail level. */
|
|
97
|
+
export function buildNotificationPayload(
|
|
98
|
+
level: PushDetailLevel,
|
|
99
|
+
thread: NotificationThread,
|
|
100
|
+
): NotificationPayload {
|
|
101
|
+
const url = `/admin/chat/${encodeURIComponent(
|
|
102
|
+
thread.agentId,
|
|
103
|
+
)}/threads/${encodeURIComponent(thread.threadId)}`;
|
|
104
|
+
|
|
105
|
+
if (level === "generic") {
|
|
106
|
+
return { title: GENERIC_TITLE, body: "", url };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (level === "title") {
|
|
110
|
+
const safeTitle = sanitizePublic(thread.title ?? "");
|
|
111
|
+
return { title: GENERIC_TITLE, body: safeTitle, url };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// preview
|
|
115
|
+
const raw = sanitizePublic(thread.preview ?? thread.title ?? "");
|
|
116
|
+
return { title: GENERIC_TITLE, body: truncateForPreview(raw), url };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* admin/src/push-content.ts — session notification policy.
|
|
121
|
+
*
|
|
122
|
+
* A sibling to the chat-thread notification path above, for the separate
|
|
123
|
+
* "session needs attention / completed" surface. Same lock-screen austerity
|
|
124
|
+
* policy applies: user-authored free text (session title / reason) is
|
|
125
|
+
* sanitized before it reaches a visible field. The deep-link `url` is the
|
|
126
|
+
* only field allowed to carry the session slug (consumed by the SW, not
|
|
127
|
+
* rendered on the lock screen); `tag` also carries the slug, per spec, so
|
|
128
|
+
* the OS can coalesce repeat notifications for the same session.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
export type SessionNotificationKind = "immediate" | "reminder" | "completed";
|
|
132
|
+
|
|
133
|
+
export interface NotificationSession {
|
|
134
|
+
slug: string;
|
|
135
|
+
title?: string | null;
|
|
136
|
+
/** User-authored free text explaining why the session needs attention. */
|
|
137
|
+
reason?: string | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface SessionNotificationPayload {
|
|
141
|
+
title: string;
|
|
142
|
+
body: string;
|
|
143
|
+
/** Deep link to the session — consumed by the SW, not shown on the lock screen. */
|
|
144
|
+
url: string;
|
|
145
|
+
tag: string;
|
|
146
|
+
kind: SessionNotificationKind;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const SESSION_KIND_TITLES: Record<SessionNotificationKind, string> = {
|
|
150
|
+
immediate: "A session needs you",
|
|
151
|
+
reminder: "Still waiting on you",
|
|
152
|
+
completed: "Session completed",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** Builds the notification payload for a session, per kind and detail level. */
|
|
156
|
+
export function buildSessionNotificationPayload(
|
|
157
|
+
level: PushDetailLevel,
|
|
158
|
+
kind: SessionNotificationKind,
|
|
159
|
+
session: NotificationSession,
|
|
160
|
+
): SessionNotificationPayload {
|
|
161
|
+
const title = SESSION_KIND_TITLES[kind];
|
|
162
|
+
const url = `/admin/sessions/${encodeURIComponent(session.slug)}`;
|
|
163
|
+
const tag = `shipwright-session-${session.slug}`;
|
|
164
|
+
|
|
165
|
+
if (level === "generic") {
|
|
166
|
+
return { title, body: "", url, tag, kind };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (level === "title") {
|
|
170
|
+
const safeTitle = sanitizePublic(session.title ?? "");
|
|
171
|
+
return { title, body: safeTitle, url, tag, kind };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// preview
|
|
175
|
+
const raw = sanitizePublic(session.reason ?? session.title ?? "");
|
|
176
|
+
return { title, body: truncateForPreview(raw), url, tag, kind };
|
|
177
|
+
}
|