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