@app-vitals/agent-admin 0.16.1 → 1.0.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/e2e/agents-page.e2e.ts +2 -2
- package/openapi.json +3 -3
- package/openapi.platform.json +7 -7
- package/package.json +3 -5
- package/prisma/client/internal/class.ts +1 -1
- package/prisma/migrations/20261030100000_set_agent_specialty_default_specialist/migration.sql +4 -0
- package/prisma/schema/schema.prisma +1 -1
- package/src/account-agent-create.ts +1 -1
- package/src/admin-boot.ts +12 -6
- package/src/admin-extension.ts +51 -5
- package/src/admin-extension.unit.test.ts +61 -0
- package/src/admin-local-agent.smoke.test.ts +8 -5
- package/src/admin-ui-pages.ts +4 -4
- package/src/admin-ui.smoke.test.ts +54 -34
- package/src/admin-ui.ts +12 -12
- package/src/agent-chat-tokens.smoke.test.ts +2 -2
- package/src/agent-cron-jobs.integration.test.ts +23 -21
- package/src/agent-cron-jobs.ts +30 -40
- package/src/agent-cron-jobs.unit.test.ts +51 -39
- package/src/agent-cron-run-stats.smoke.test.ts +2 -2
- package/src/agent-manifest.ts +2 -6
- package/src/agent-patch-author-allowlist.integration.test.ts +16 -27
- package/src/agent-specialty-backfill.integration.test.ts +30 -0
- package/src/agents-api.integration.test.ts +29 -40
- package/src/agents-api.sentry.smoke.test.ts +2 -2
- package/src/agents-api.smoke.test.ts +17 -30
- package/src/agents-api.ts +13 -13
- package/src/agents.integration.test.ts +23 -33
- package/src/agents.ts +35 -34
- package/src/agents.unit.test.ts +35 -55
- package/src/api.smoke.test.ts +2 -2
- package/src/chat-service-provisioning-client.integration.test.ts +1 -1
- package/src/openapi-schemas.ts +4 -4
- package/src/server.smoke.test.ts +2 -2
- package/src/specialty-defaults.ts +4 -4
- package/src/specialty-types.ts +92 -0
- package/src/specialty-types.unit.test.ts +67 -0
- package/e2e/fixtures/specialties/coding/manifest.yaml +0 -33
- package/src/coding-task-store-provisioning-client.integration.test.ts +0 -372
- package/src/coding-task-store-provisioning-client.ts +0 -156
- package/src/fixtures/task-store-provisioning-cassette.json +0 -85
- package/src/specialty-manifest-loader.fixture.integration.test.ts +0 -67
- package/src/specialty-manifest-loader.ts +0 -245
- package/src/specialty-manifest-loader.unit.test.ts +0 -299
- package/src/specialty-registry.schema.unit.test.ts +0 -35
- package/src/specialty-registry.ts +0 -236
- package/src/specialty-registry.unit.test.ts +0 -272
package/src/admin-ui-pages.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type {
|
|
|
28
28
|
ThreadStats,
|
|
29
29
|
} from "./http-chat-client.ts";
|
|
30
30
|
import { renderPushToggle } from "./push-toggle.ts";
|
|
31
|
-
import type { SpecialtyOption } from "./specialty-
|
|
31
|
+
import type { SpecialtyOption } from "./specialty-types.ts";
|
|
32
32
|
|
|
33
33
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
34
34
|
|
|
@@ -83,7 +83,7 @@ export interface AgentDetail {
|
|
|
83
83
|
/** The agent's type name (e.g. "specialist"), resolved at creation time. */
|
|
84
84
|
specialty: string;
|
|
85
85
|
/**
|
|
86
|
-
* Required env keys declared by the agent's type
|
|
86
|
+
* Required env keys declared by the agent's type with no
|
|
87
87
|
* corresponding AgentEnv row yet — key names only, never values. Always
|
|
88
88
|
* present; purely informational (ATS-4.2), see renderAgentDetailPage's Env
|
|
89
89
|
* Vars card.
|
|
@@ -2351,7 +2351,7 @@ const STALL_INDICATOR_CLASS = "chat-stall-indicator";
|
|
|
2351
2351
|
* hard stop.
|
|
2352
2352
|
*/
|
|
2353
2353
|
export const STALL_WARN_AFTER_MS = 120_000;
|
|
2354
|
-
// 65min — mirrors
|
|
2354
|
+
// 65min — mirrors DEFAULT_CLAIM_TTL_MS (Shipwright claim-ttl)
|
|
2355
2355
|
// (DEFAULT_CLAUDE_TIMEOUT_MS 1hr + CLAIM_TTL_BUFFER_MS 5min). Inline JS can't
|
|
2356
2356
|
// import the lib file, so the value is hardcoded here with this reference.
|
|
2357
2357
|
export const ABSOLUTE_MAX_MS = 3_900_000;
|
|
@@ -2886,7 +2886,7 @@ export function renderChatThreadPage(
|
|
|
2886
2886
|
|
|
2887
2887
|
// Stall-warning threshold — 120s in prod; e2e overrides to a tiny value.
|
|
2888
2888
|
var STALL_WARN_AFTER_MS = ${stallWarnAfterMs};
|
|
2889
|
-
// Hard ceiling: 65min, mirrors
|
|
2889
|
+
// Hard ceiling: 65min, mirrors Shipwright's DEFAULT_CLAIM_TTL_MS
|
|
2890
2890
|
// (1hr DEFAULT_CLAUDE_TIMEOUT_MS + 5min CLAIM_TTL_BUFFER_MS). Only hard stop.
|
|
2891
2891
|
var ABSOLUTE_MAX_MS = ${ABSOLUTE_MAX_MS};
|
|
2892
2892
|
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { beforeAll, describe, expect, it } from "bun:test";
|
|
10
|
-
import { existsSync } from "node:fs";
|
|
10
|
+
import { existsSync, mkdtempSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
11
12
|
import { join } from "node:path";
|
|
12
13
|
import { sign } from "hono/jwt";
|
|
13
|
-
import {
|
|
14
|
-
import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
|
|
14
|
+
import { withExtensionAgentTypes } from "./admin-extension.ts";
|
|
15
15
|
import {
|
|
16
16
|
AGENT_ID,
|
|
17
17
|
CRON_ID,
|
|
@@ -26,16 +26,22 @@ import {
|
|
|
26
26
|
TOKEN_ID,
|
|
27
27
|
TOOL_ID,
|
|
28
28
|
} from "./admin-ui.fixtures.ts";
|
|
29
|
+
import { createAdminUIApp } from "./admin-ui.ts";
|
|
29
30
|
import { AnthropicCredentialsBlockedError } from "./anthropic-credential-guard.ts";
|
|
30
31
|
import type { PushService } from "./push-service.ts";
|
|
32
|
+
import {
|
|
33
|
+
resolveDefaultSpecialtyName,
|
|
34
|
+
SPECIALIST_TYPE,
|
|
35
|
+
SpecialtyTypes,
|
|
36
|
+
} from "./specialty-types.ts";
|
|
31
37
|
|
|
32
38
|
// The specialty the create-agent flows use: the configured default type (the
|
|
33
39
|
// platform test run points it at the fixture specialty; the monorepo falls
|
|
34
40
|
// back to "coding").
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
|
|
41
|
+
const TYPE = resolveDefaultSpecialtyName();
|
|
42
|
+
const TYPE_RECORD = new SpecialtyTypes()
|
|
43
|
+
.listTypes()
|
|
44
|
+
.find((t) => t.name === TYPE) as { name: string; displayName: string };
|
|
39
45
|
|
|
40
46
|
// ─── Auth redirect tests ──────────────────────────────────────────────────────
|
|
41
47
|
|
|
@@ -1288,7 +1294,7 @@ describe("admin UI — authenticated pages", () => {
|
|
|
1288
1294
|
'<select id="type" name="type" class="form-input" required>',
|
|
1289
1295
|
);
|
|
1290
1296
|
expect(html).toContain(
|
|
1291
|
-
`<option value="${TYPE}">${
|
|
1297
|
+
`<option value="${TYPE}">${TYPE_RECORD.displayName}</option>`,
|
|
1292
1298
|
);
|
|
1293
1299
|
});
|
|
1294
1300
|
|
|
@@ -5549,14 +5555,14 @@ describe("admin UI — create agent with patch author allowlist", () => {
|
|
|
5549
5555
|
});
|
|
5550
5556
|
});
|
|
5551
5557
|
|
|
5552
|
-
describe("admin UI — create agent seeds
|
|
5558
|
+
describe("admin UI — create agent seeds type tools and extension plugins", () => {
|
|
5553
5559
|
let cookie: string;
|
|
5554
5560
|
|
|
5555
5561
|
beforeAll(async () => {
|
|
5556
5562
|
cookie = await makeSessionCookie();
|
|
5557
5563
|
});
|
|
5558
5564
|
|
|
5559
|
-
it("POST /admin/agents with the default type seeds an AgentTool row for every
|
|
5565
|
+
it("POST /admin/agents with the default type seeds an AgentTool row for every type tool and no AgentPlugin rows", async () => {
|
|
5560
5566
|
const seededTools: string[] = [];
|
|
5561
5567
|
const seededPlugins: string[] = [];
|
|
5562
5568
|
const deps = makeMockDeps();
|
|
@@ -5594,28 +5600,13 @@ describe("admin UI — create agent seeds manifest tools and plugins", () => {
|
|
|
5594
5600
|
});
|
|
5595
5601
|
expect(res.status).toBe(302);
|
|
5596
5602
|
expect(res.headers.get("Location")).toBe(`/admin/agents/${AGENT_ID}`);
|
|
5597
|
-
expect(seededTools).toEqual(
|
|
5603
|
+
expect(seededTools).toEqual([...SPECIALIST_TYPE.tools]);
|
|
5598
5604
|
expect(seededTools).toContain("Bash");
|
|
5599
|
-
expect(seededPlugins).toEqual(
|
|
5600
|
-
});
|
|
5601
|
-
|
|
5602
|
-
it("POST /admin/agents with the
|
|
5603
|
-
const
|
|
5604
|
-
join(
|
|
5605
|
-
import.meta.dir,
|
|
5606
|
-
"..",
|
|
5607
|
-
"..",
|
|
5608
|
-
"agent",
|
|
5609
|
-
"src",
|
|
5610
|
-
"fixtures",
|
|
5611
|
-
"specialties",
|
|
5612
|
-
),
|
|
5613
|
-
join(import.meta.dir, "..", "..", "runtime", "fixtures", "specialties"),
|
|
5614
|
-
].find((dir) => existsSync(dir)) as string;
|
|
5615
|
-
const registry = new SpecialtyRegistry({
|
|
5616
|
-
typesDir: fixtureDir,
|
|
5617
|
-
defaultTypeName: "fixture",
|
|
5618
|
-
});
|
|
5605
|
+
expect(seededPlugins).toEqual([]);
|
|
5606
|
+
});
|
|
5607
|
+
|
|
5608
|
+
it("POST /admin/agents with the configured default type (AGR-1.9) seeds the specialist floor and needs no product specialty", async () => {
|
|
5609
|
+
const registry = new SpecialtyTypes({ defaultTypeName: "fixture" });
|
|
5619
5610
|
const seededTools: string[] = [];
|
|
5620
5611
|
const seededPlugins: string[] = [];
|
|
5621
5612
|
const deps = makeMockDeps();
|
|
@@ -5647,7 +5638,7 @@ describe("admin UI — create agent seeds manifest tools and plugins", () => {
|
|
|
5647
5638
|
headers: { Cookie: `admin_session=${cookie}` },
|
|
5648
5639
|
});
|
|
5649
5640
|
expect(await form.text()).toContain(
|
|
5650
|
-
'<option value="fixture">
|
|
5641
|
+
'<option value="fixture">fixture</option>',
|
|
5651
5642
|
);
|
|
5652
5643
|
const res = await app.request("/admin/agents", {
|
|
5653
5644
|
method: "POST",
|
|
@@ -5662,8 +5653,36 @@ describe("admin UI — create agent seeds manifest tools and plugins", () => {
|
|
|
5662
5653
|
});
|
|
5663
5654
|
expect(res.status).toBe(302);
|
|
5664
5655
|
expect(res.headers.get("Location")).toBe(`/admin/agents/${AGENT_ID}`);
|
|
5665
|
-
expect(seededTools).toEqual(registry.
|
|
5666
|
-
expect(seededPlugins).toEqual([
|
|
5656
|
+
expect(seededTools).toEqual([...registry.getType("fixture").tools]);
|
|
5657
|
+
expect(seededPlugins).toEqual([]);
|
|
5658
|
+
});
|
|
5659
|
+
|
|
5660
|
+
it("an extension-contributed type (ETP-1.2) shows in the picker and POST /admin/agents accepts it", async () => {
|
|
5661
|
+
const registry = withExtensionAgentTypes(new SpecialtyTypes(), [
|
|
5662
|
+
{ agentTypes: () => [{ name: "coding", displayName: "Coding Agent" }] },
|
|
5663
|
+
]);
|
|
5664
|
+
const deps = makeMockDeps();
|
|
5665
|
+
deps.specialtyRegistry = registry;
|
|
5666
|
+
const app = createAdminUIApp(deps);
|
|
5667
|
+
const form = await app.request("/admin/agents/new", {
|
|
5668
|
+
headers: { Cookie: `admin_session=${cookie}` },
|
|
5669
|
+
});
|
|
5670
|
+
expect(await form.text()).toContain(
|
|
5671
|
+
'<option value="coding">Coding Agent</option>',
|
|
5672
|
+
);
|
|
5673
|
+
const res = await app.request("/admin/agents", {
|
|
5674
|
+
method: "POST",
|
|
5675
|
+
body: new URLSearchParams({
|
|
5676
|
+
name: "Ext Agent",
|
|
5677
|
+
type: "coding",
|
|
5678
|
+
}).toString(),
|
|
5679
|
+
headers: {
|
|
5680
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
5681
|
+
Cookie: `admin_session=${cookie}`,
|
|
5682
|
+
},
|
|
5683
|
+
});
|
|
5684
|
+
expect(res.status).toBe(302);
|
|
5685
|
+
expect(res.headers.get("Location")).toBe(`/admin/agents/${AGENT_ID}`);
|
|
5667
5686
|
});
|
|
5668
5687
|
|
|
5669
5688
|
it("POST /admin/agents rolls back the agent and redirects with error when tool seeding fails", async () => {
|
|
@@ -5701,6 +5720,7 @@ describe("admin UI — create agent seeds manifest tools and plugins", () => {
|
|
|
5701
5720
|
it("POST /admin/agents rolls back the agent and redirects with error when plugin seeding fails", async () => {
|
|
5702
5721
|
let deletedId: string | undefined;
|
|
5703
5722
|
const deps = makeMockDeps();
|
|
5723
|
+
deps.specialtyDefaults = () => ({ plugins: ["ext-plugin"] });
|
|
5704
5724
|
deps.agentPluginService = {
|
|
5705
5725
|
...deps.agentPluginService,
|
|
5706
5726
|
add: async () => {
|
package/src/admin-ui.ts
CHANGED
|
@@ -74,11 +74,6 @@ import type { AgentPluginService } from "./agent-plugins.ts";
|
|
|
74
74
|
import type { AgentProvisioner } from "./agent-provisioner.ts";
|
|
75
75
|
import type { AgentTokenService } from "./agent-tokens.ts";
|
|
76
76
|
import type { AgentToolService } from "./agent-tools.ts";
|
|
77
|
-
import {
|
|
78
|
-
type SpecialtyManifestResolver,
|
|
79
|
-
SpecialtyRegistry,
|
|
80
|
-
} from "./specialty-manifest-loader.ts";
|
|
81
|
-
import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
|
|
82
77
|
import type { AgentService } from "./agents.ts";
|
|
83
78
|
import { createAgent, describeRepoNameCollision } from "./agents.ts";
|
|
84
79
|
import { AnthropicCredentialsBlockedError } from "./anthropic-credential-guard.ts";
|
|
@@ -86,7 +81,6 @@ import {
|
|
|
86
81
|
audioContentTypeForFilename,
|
|
87
82
|
validateAttachment,
|
|
88
83
|
} from "./attachment-validation.ts";
|
|
89
|
-
import { parseByteRange } from "./http-range.ts";
|
|
90
84
|
import {
|
|
91
85
|
type CallerScopeResolver,
|
|
92
86
|
memberOnlyCallerScopeResolver,
|
|
@@ -105,6 +99,7 @@ import {
|
|
|
105
99
|
filterSince,
|
|
106
100
|
toPolledMessage,
|
|
107
101
|
} from "./http-chat-client.ts";
|
|
102
|
+
import { parseByteRange } from "./http-range.ts";
|
|
108
103
|
import type { OktaAuthClient } from "./okta-auth-client.ts";
|
|
109
104
|
import type { PrismaTransactionClient } from "./prisma-tx.ts";
|
|
110
105
|
import type { PushService } from "./push-service.ts";
|
|
@@ -132,6 +127,11 @@ import {
|
|
|
132
127
|
PROVISION_STATE_TTL_SECONDS,
|
|
133
128
|
SlackProvisioningService,
|
|
134
129
|
} from "./slack-provisioning-service.ts";
|
|
130
|
+
import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
|
|
131
|
+
import {
|
|
132
|
+
type SpecialtyTypeResolver,
|
|
133
|
+
SpecialtyTypes,
|
|
134
|
+
} from "./specialty-types.ts";
|
|
135
135
|
|
|
136
136
|
export type AdminUIEnv = { Variables: { userEmail: string; isAdmin: boolean } };
|
|
137
137
|
|
|
@@ -347,13 +347,13 @@ export interface AdminUIDeps {
|
|
|
347
347
|
/** WIF-2.1: Anthropic federation is enabled; defaults to false. */
|
|
348
348
|
anthropicFederationEnabled?: boolean;
|
|
349
349
|
/**
|
|
350
|
-
* Resolves an agent's specialty to its
|
|
351
|
-
* the registry's
|
|
352
|
-
* Defaults to a disk-backed
|
|
350
|
+
* Resolves an agent's specialty to its type record and lists
|
|
351
|
+
* the registry's built-in types (for the new-specialty picker).
|
|
352
|
+
* Defaults to a disk-backed SpecialtyTypes, matching the DI pattern
|
|
353
353
|
* already used by agents-api.ts's AdminDeps.
|
|
354
354
|
*/
|
|
355
|
-
specialtyRegistry?:
|
|
356
|
-
/** Extension-supplied specialty defaults, consulted before the
|
|
355
|
+
specialtyRegistry?: SpecialtyTypeResolver;
|
|
356
|
+
/** Extension-supplied specialty defaults, consulted before the built-in type. */
|
|
357
357
|
specialtyDefaults?: SpecialtyDefaultsResolver;
|
|
358
358
|
/**
|
|
359
359
|
* Cleanup deps for the full delete-agent orchestration (POST
|
|
@@ -784,7 +784,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
784
784
|
accountOnboarding,
|
|
785
785
|
accountCreatedNotifier,
|
|
786
786
|
accountServices,
|
|
787
|
-
specialtyRegistry = new
|
|
787
|
+
specialtyRegistry = new SpecialtyTypes(),
|
|
788
788
|
specialtyDefaults,
|
|
789
789
|
callerScopeResolver = memberOnlyCallerScopeResolver(agentMemberService),
|
|
790
790
|
serviceIntegrations,
|
|
@@ -222,10 +222,10 @@ function makeMockDeps(opts?: {
|
|
|
222
222
|
listByAgentId: async () => [],
|
|
223
223
|
},
|
|
224
224
|
specialtyRegistry: {
|
|
225
|
-
|
|
225
|
+
getType: () => {
|
|
226
226
|
throw new Error("not implemented");
|
|
227
227
|
},
|
|
228
|
-
|
|
228
|
+
tryGetType: () => undefined,
|
|
229
229
|
listTypes: () => [],
|
|
230
230
|
},
|
|
231
231
|
agentChatTokenService: {
|
|
@@ -18,8 +18,14 @@ import {
|
|
|
18
18
|
import { AgentCronJobService } from "./agent-cron-jobs.ts";
|
|
19
19
|
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
20
20
|
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
21
|
-
import type {
|
|
22
|
-
|
|
21
|
+
import type {
|
|
22
|
+
SpecialtyDefaultCron,
|
|
23
|
+
SpecialtyDefaultsResolver,
|
|
24
|
+
} from "./specialty-defaults.ts";
|
|
25
|
+
import {
|
|
26
|
+
SPECIALIST_TYPE,
|
|
27
|
+
type SpecialtyTypeResolver,
|
|
28
|
+
} from "./specialty-types.ts";
|
|
23
29
|
|
|
24
30
|
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
25
31
|
|
|
@@ -34,9 +40,7 @@ function makePrisma(): PrismaClient {
|
|
|
34
40
|
// cron, covering every field reconcile maps into a row.
|
|
35
41
|
const FIXTURE_TYPE = "fixture";
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const FIXTURE_CRONS: ManifestCron[] = [
|
|
43
|
+
const FIXTURE_CRONS: SpecialtyDefaultCron[] = [
|
|
40
44
|
{
|
|
41
45
|
name: "fixture-loop",
|
|
42
46
|
schedule: "*/30 * * * *",
|
|
@@ -70,23 +74,16 @@ const FIXTURE_CRONS: ManifestCron[] = [
|
|
|
70
74
|
},
|
|
71
75
|
];
|
|
72
76
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
/** The extension hook supplying FIXTURE_TYPE's crons. */
|
|
78
|
+
const fixtureDefaults: SpecialtyDefaultsResolver = (specialty) =>
|
|
79
|
+
specialty === FIXTURE_TYPE ? { crons: FIXTURE_CRONS } : undefined;
|
|
76
80
|
|
|
77
81
|
/** Resolves only FIXTURE_TYPE; every other type is unknown. */
|
|
78
|
-
const fixtureResolver:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
},
|
|
84
|
-
tryGetManifest(specialty: string): SpecialtyManifest | undefined {
|
|
85
|
-
return specialty === FIXTURE_TYPE ? fixtureManifest : undefined;
|
|
86
|
-
},
|
|
87
|
-
listTypes() {
|
|
88
|
-
return [{ name: FIXTURE_TYPE, displayName: FIXTURE_TYPE }];
|
|
89
|
-
},
|
|
82
|
+
const fixtureResolver: SpecialtyTypeResolver = {
|
|
83
|
+
getType: () => SPECIALIST_TYPE,
|
|
84
|
+
tryGetType: (specialty) =>
|
|
85
|
+
specialty === FIXTURE_TYPE ? SPECIALIST_TYPE : undefined,
|
|
86
|
+
listTypes: () => [{ name: FIXTURE_TYPE, displayName: FIXTURE_TYPE }],
|
|
90
87
|
};
|
|
91
88
|
|
|
92
89
|
async function createAgent(
|
|
@@ -109,7 +106,12 @@ describeOrSkip("AgentCronJobService (integration)", () => {
|
|
|
109
106
|
await prisma.agentTool.deleteMany();
|
|
110
107
|
await prisma.agentEnv.deleteMany();
|
|
111
108
|
await prisma.agent.deleteMany();
|
|
112
|
-
service = new AgentCronJobService(
|
|
109
|
+
service = new AgentCronJobService(
|
|
110
|
+
prisma,
|
|
111
|
+
undefined,
|
|
112
|
+
fixtureResolver,
|
|
113
|
+
fixtureDefaults,
|
|
114
|
+
);
|
|
113
115
|
});
|
|
114
116
|
|
|
115
117
|
afterEach(async () => {
|
package/src/agent-cron-jobs.ts
CHANGED
|
@@ -15,17 +15,9 @@ import type {
|
|
|
15
15
|
SpecialtyDefaultsResolver,
|
|
16
16
|
} from "./specialty-defaults.ts";
|
|
17
17
|
import {
|
|
18
|
-
type
|
|
19
|
-
|
|
20
|
-
} from "./specialty-
|
|
21
|
-
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The shape of a single cron template resolved from a specialty's manifest.
|
|
25
|
-
* Structurally identical to the former SYSTEM_CRONS SystemCron entries — the
|
|
26
|
-
* manifest is now the source of truth (see specialty-manifest-loader.ts).
|
|
27
|
-
*/
|
|
28
|
-
type ManifestCron = SpecialtyManifest["crons"][number];
|
|
18
|
+
type SpecialtyTypeResolver,
|
|
19
|
+
SpecialtyTypes,
|
|
20
|
+
} from "./specialty-types.ts";
|
|
29
21
|
|
|
30
22
|
export type { AgentCronJob };
|
|
31
23
|
|
|
@@ -87,7 +79,7 @@ export class AgentCronJobService {
|
|
|
87
79
|
constructor(
|
|
88
80
|
private prisma: PrismaClient,
|
|
89
81
|
private clock: Clock = SystemClock(),
|
|
90
|
-
private specialtyRegistry:
|
|
82
|
+
private specialtyRegistry: SpecialtyTypeResolver = new SpecialtyTypes(),
|
|
91
83
|
private specialtyDefaults: SpecialtyDefaultsResolver = () => undefined,
|
|
92
84
|
) {}
|
|
93
85
|
|
|
@@ -442,26 +434,25 @@ export class AgentCronJobService {
|
|
|
442
434
|
/**
|
|
443
435
|
* Reconcile system crons for the given agent.
|
|
444
436
|
*
|
|
445
|
-
* The cron list
|
|
446
|
-
* stored `specialty
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
* (no seeding of another type's crons, no deletion of
|
|
451
|
-
* crons).
|
|
452
|
-
* SYSTEM_CRONS array as the source of truth (ATS-3.2).
|
|
437
|
+
* The cron list comes from the extension's specialtyDefaults() for the
|
|
438
|
+
* agent's stored `specialty`. Without extension crons the built-in types
|
|
439
|
+
* (looked up via the injected resolver's `tryGetType()`, deliberately
|
|
440
|
+
* WITHOUT a fallback) declare none, so their system crons are cleared; a
|
|
441
|
+
* specialty that is neither registered nor built in logs an error and
|
|
442
|
+
* reconciles nothing (no seeding of another type's crons, no deletion of
|
|
443
|
+
* existing system crons).
|
|
453
444
|
*
|
|
454
|
-
* Two passes so parent/child links resolve regardless of the
|
|
445
|
+
* Two passes so parent/child links resolve regardless of the default
|
|
455
446
|
* cron order or whether this is the agent's very first reconcile (i.e.
|
|
456
447
|
* the parent row may not exist yet when a child entry is processed):
|
|
457
448
|
*
|
|
458
|
-
* Pass 1 — for each
|
|
449
|
+
* Pass 1 — for each default cron entry:
|
|
459
450
|
* - If an existing system cron with matching name exists: update it in
|
|
460
|
-
* place with the current
|
|
451
|
+
* place with the current default definition, preserving its id and
|
|
461
452
|
* existing enabled state. Updating in place (rather than delete+create)
|
|
462
453
|
* keeps the row's id stable across restarts so AgentCronRun history
|
|
463
454
|
* (FK onDelete: Cascade) is never wiped out from under it.
|
|
464
|
-
* - If no matching cron exists: create it with the
|
|
455
|
+
* - If no matching cron exists: create it with the default
|
|
465
456
|
* enabled state.
|
|
466
457
|
* Records each entry's resulting row id in a name → id map as it goes.
|
|
467
458
|
*
|
|
@@ -472,10 +463,10 @@ export class AgentCronJobService {
|
|
|
472
463
|
* non-null parentCronId on that row is cleared back to null. Self-heals
|
|
473
464
|
* the link on every reconcile call in both directions — null→set (e.g.
|
|
474
465
|
* a pre-existing row from before parentCron was introduced) and set→null
|
|
475
|
-
* (e.g. a parentCron declaration removed from the
|
|
466
|
+
* (e.g. a parentCron declaration removed from the defaults).
|
|
476
467
|
*
|
|
477
468
|
* Orphan pass: delete any cron with system=true whose name is no longer in
|
|
478
|
-
* the resolved
|
|
469
|
+
* the resolved default cron list.
|
|
479
470
|
*
|
|
480
471
|
* All three passes run inside a single transaction for atomicity.
|
|
481
472
|
*
|
|
@@ -484,7 +475,7 @@ export class AgentCronJobService {
|
|
|
484
475
|
async reconcileSystemCrons(
|
|
485
476
|
agentId: string,
|
|
486
477
|
): Promise<{ created: number; updated: number; deleted: number }> {
|
|
487
|
-
// Resolve the agent's
|
|
478
|
+
// Resolve the agent's default cron list. No default-type
|
|
488
479
|
// fallback: seeding another type's crons on an agent is worse than
|
|
489
480
|
// leaving its crons untouched.
|
|
490
481
|
const agent = await this.prisma.agent.findUnique({
|
|
@@ -495,19 +486,18 @@ export class AgentCronJobService {
|
|
|
495
486
|
throw new NotFoundError(`agent ${agentId} not found`);
|
|
496
487
|
}
|
|
497
488
|
const { specialty } = agent;
|
|
498
|
-
// Extension-supplied crons win;
|
|
489
|
+
// Extension-supplied crons win; built-in types declare none.
|
|
499
490
|
const extensionCrons = this.specialtyDefaults(specialty)?.crons;
|
|
500
|
-
const
|
|
491
|
+
const knownType = extensionCrons
|
|
501
492
|
? undefined
|
|
502
|
-
: this.specialtyRegistry.
|
|
503
|
-
if (!extensionCrons && !
|
|
493
|
+
: this.specialtyRegistry.tryGetType(specialty);
|
|
494
|
+
if (!extensionCrons && !knownType) {
|
|
504
495
|
console.error(
|
|
505
|
-
`reconcileSystemCrons: no
|
|
496
|
+
`reconcileSystemCrons: no known type for specialty "${specialty}" (agent ${agentId}) — leaving crons untouched`,
|
|
506
497
|
);
|
|
507
498
|
return { created: 0, updated: 0, deleted: 0 };
|
|
508
499
|
}
|
|
509
|
-
const
|
|
510
|
-
extensionCrons ?? manifest?.crons ?? [];
|
|
500
|
+
const defaultCrons: readonly SpecialtyDefaultCron[] = extensionCrons ?? [];
|
|
511
501
|
|
|
512
502
|
// Fetch all existing system crons for this agent
|
|
513
503
|
const existingSystemCrons = await this.prisma.agentCronJob.findMany({
|
|
@@ -522,8 +512,8 @@ export class AgentCronJobService {
|
|
|
522
512
|
}
|
|
523
513
|
}
|
|
524
514
|
|
|
525
|
-
// Build set of valid
|
|
526
|
-
const systemCronNames = new Set(
|
|
515
|
+
// Build set of valid default cron names for orphan detection
|
|
516
|
+
const systemCronNames = new Set(defaultCrons.map((c) => c.name));
|
|
527
517
|
|
|
528
518
|
let created = 0;
|
|
529
519
|
let updated = 0;
|
|
@@ -532,9 +522,9 @@ export class AgentCronJobService {
|
|
|
532
522
|
// Wrap all mutations in a transaction so a crash mid-loop cannot leave
|
|
533
523
|
// the agent missing required system crons (e.g. shipwright-dev-task).
|
|
534
524
|
await this.prisma.$transaction(async (tx) => {
|
|
535
|
-
// Pass 1: reconcile each
|
|
525
|
+
// Pass 1: reconcile each default cron entry, recording name → row id.
|
|
536
526
|
const idByName = new Map<string, string>();
|
|
537
|
-
for (const systemCron of
|
|
527
|
+
for (const systemCron of defaultCrons) {
|
|
538
528
|
const existing = existingByName.get(systemCron.name);
|
|
539
529
|
|
|
540
530
|
if (existing) {
|
|
@@ -577,7 +567,7 @@ export class AgentCronJobService {
|
|
|
577
567
|
// Also clears parentCronId back to null when an entry no longer
|
|
578
568
|
// declares a resolvable parentCron, so the link self-heals in both
|
|
579
569
|
// directions rather than only null→set.
|
|
580
|
-
for (const systemCron of
|
|
570
|
+
for (const systemCron of defaultCrons) {
|
|
581
571
|
const childId = idByName.get(systemCron.name);
|
|
582
572
|
if (!childId) continue;
|
|
583
573
|
const parentId = systemCron.parentCron
|
|
@@ -600,7 +590,7 @@ export class AgentCronJobService {
|
|
|
600
590
|
}
|
|
601
591
|
}
|
|
602
592
|
|
|
603
|
-
// Orphan pass: delete any system cron whose name is not in the
|
|
593
|
+
// Orphan pass: delete any system cron whose name is not in the defaults.
|
|
604
594
|
for (const cron of existingSystemCrons) {
|
|
605
595
|
if (!cron.name || !systemCronNames.has(cron.name)) {
|
|
606
596
|
await tx.agentCronJob.delete({ where: { id: cron.id } });
|