@app-vitals/agent-admin 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/account-agent-create.ts +4 -4
- package/src/account-agent-create.unit.test.ts +2 -2
- package/src/admin-boot.ts +12 -1
- package/src/admin-extension.ts +29 -0
- package/src/admin-extension.unit.test.ts +19 -0
- package/src/admin-local-agent.smoke.test.ts +1 -1
- package/src/admin-ui-pages.ts +5 -4
- package/src/admin-ui-pages.unit.test.ts +34 -6
- package/src/admin-ui.ts +6 -1
- package/src/agent-cron-jobs.integration.test.ts +94 -0
- package/src/agent-cron-jobs.ts +23 -25
- package/src/agent-cron-jobs.unit.test.ts +126 -18
- package/src/agents-api.ts +6 -1
- package/src/agents.ts +13 -3
- package/src/agents.unit.test.ts +70 -0
- package/src/specialty-defaults.ts +35 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +7 -3
- package/src/specialty-manifest-loader.ts +80 -90
- package/src/specialty-manifest-loader.unit.test.ts +46 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anthropic-ai/sdk": "0.135.0",
|
|
46
|
-
"@app-vitals/agent-lib": "0.
|
|
46
|
+
"@app-vitals/agent-lib": "0.16.1",
|
|
47
47
|
"@hono/zod-openapi": "^1.0.0",
|
|
48
48
|
"@prisma/adapter-pg": "^7.10.0",
|
|
49
49
|
"@prisma/client": "^7.10.0",
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Shared rules for self-serve agent creation by account users (SSP-4.2),
|
|
5
5
|
* used by both the admin UI form handler and the JSON API route so the two
|
|
6
|
-
* cannot drift: account users always create
|
|
7
|
-
* THEIR account (never a client-supplied
|
|
8
|
-
* failures surface as clear messages.
|
|
6
|
+
* cannot drift: account users always create the self-serve specialty (see
|
|
7
|
+
* getSelfServeSpecialty) stamped with THEIR account (never a client-supplied
|
|
8
|
+
* one), and quota / account-status failures surface as clear messages.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { CallerScope } from "./caller-scope.ts";
|
|
@@ -14,7 +14,7 @@ import { resolveDefaultSpecialtyName } from "./specialty-manifest-loader.ts";
|
|
|
14
14
|
/**
|
|
15
15
|
* The only specialty account users may create: SHIPWRIGHT_SELF_SERVE_AGENT_TYPE
|
|
16
16
|
* when set, else the platform's default specialty (SHIPWRIGHT_DEFAULT_AGENT_TYPE,
|
|
17
|
-
* "
|
|
17
|
+
* "specialist" when unset).
|
|
18
18
|
*/
|
|
19
19
|
export function getSelfServeSpecialty(
|
|
20
20
|
env: Record<string, string | undefined> = process.env,
|
|
@@ -7,8 +7,8 @@ import { describe, expect, it } from "bun:test";
|
|
|
7
7
|
import { getSelfServeSpecialty } from "./account-agent-create.ts";
|
|
8
8
|
|
|
9
9
|
describe("getSelfServeSpecialty", () => {
|
|
10
|
-
it("defaults to
|
|
11
|
-
expect(getSelfServeSpecialty({})).toBe("
|
|
10
|
+
it("defaults to specialist", () => {
|
|
11
|
+
expect(getSelfServeSpecialty({})).toBe("specialist");
|
|
12
12
|
expect(
|
|
13
13
|
getSelfServeSpecialty({ SHIPWRIGHT_DEFAULT_AGENT_TYPE: "fixture" }),
|
|
14
14
|
).toBe("fixture");
|
package/src/admin-boot.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
type AdminExtension,
|
|
66
66
|
type AdminExtensionContext,
|
|
67
67
|
collectServiceIntegrations,
|
|
68
|
+
collectSpecialtyDefaults,
|
|
68
69
|
mountRouteContributions,
|
|
69
70
|
resolveAdminExtensions,
|
|
70
71
|
startBackgroundPasses,
|
|
@@ -178,7 +179,15 @@ export async function startAdmin(
|
|
|
178
179
|
undefined,
|
|
179
180
|
anthropicFederationEnabled,
|
|
180
181
|
);
|
|
181
|
-
|
|
182
|
+
// Extension-supplied specialty defaults (crons/tools/plugins), consulted by
|
|
183
|
+
// the cron reconcile and create-time seeding before the manifest.
|
|
184
|
+
const specialtyDefaults = collectSpecialtyDefaults(extensions);
|
|
185
|
+
const agentCronJobService = new AgentCronJobService(
|
|
186
|
+
prisma,
|
|
187
|
+
undefined,
|
|
188
|
+
undefined,
|
|
189
|
+
specialtyDefaults,
|
|
190
|
+
);
|
|
182
191
|
const agentCronRunService = new AgentCronRunService(prisma);
|
|
183
192
|
const agentToolService = new AgentToolService(prisma);
|
|
184
193
|
const agentTokenService = new AgentTokenService(prisma);
|
|
@@ -450,6 +459,7 @@ export async function startAdmin(
|
|
|
450
459
|
agentMemberService,
|
|
451
460
|
callerScopeResolver,
|
|
452
461
|
specialtyRegistry: new SpecialtyRegistry(),
|
|
462
|
+
specialtyDefaults,
|
|
453
463
|
agentChatTokenService,
|
|
454
464
|
agentGitHubInstallationsService,
|
|
455
465
|
prisma,
|
|
@@ -541,6 +551,7 @@ export async function startAdmin(
|
|
|
541
551
|
agentService,
|
|
542
552
|
provisioner,
|
|
543
553
|
anthropicFederationEnabled,
|
|
554
|
+
specialtyDefaults,
|
|
544
555
|
serviceIntegrations,
|
|
545
556
|
chatService: deletionChatService,
|
|
546
557
|
slack: slackClient,
|
package/src/admin-extension.ts
CHANGED
|
@@ -24,6 +24,12 @@
|
|
|
24
24
|
* agentDetailCards(ctx) — extra cards on the agent detail page, each placed
|
|
25
25
|
* in the header or the Configuration group
|
|
26
26
|
* backgroundPasses(ctx) — interval loops, started after the platform sweepers
|
|
27
|
+
* specialtyDefaults(name) — a specialty's default crons/tools/plugins. Like
|
|
28
|
+
* serviceIntegrations it needs no context and is
|
|
29
|
+
* collected before the services are built; the cron
|
|
30
|
+
* reconcile and create-time seeding consult it
|
|
31
|
+
* before the specialty manifest, which remains the
|
|
32
|
+
* fallback when no extension returns anything
|
|
27
33
|
*
|
|
28
34
|
* openApi() is static (no context): scripts/generate-admin-spec.ts calls it to
|
|
29
35
|
* fold extension routes/schemas into admin/openapi.json without booting.
|
|
@@ -52,6 +58,10 @@ import type { AgentPhaseMethodologyServiceLike } from "./api.ts";
|
|
|
52
58
|
import type { createCallerScopeResolver } from "./caller-scope.ts";
|
|
53
59
|
import type { PushService } from "./push-service.ts";
|
|
54
60
|
import type { ServiceIntegration } from "./service-integration.ts";
|
|
61
|
+
import type {
|
|
62
|
+
SpecialtyDefaults,
|
|
63
|
+
SpecialtyDefaultsResolver,
|
|
64
|
+
} from "./specialty-defaults.ts";
|
|
55
65
|
|
|
56
66
|
/** The platform services an extension may build on. */
|
|
57
67
|
export interface AdminPlatformServices {
|
|
@@ -168,6 +178,8 @@ export interface AdminExtension {
|
|
|
168
178
|
name?: string;
|
|
169
179
|
/** See the header: env-only, resolved before the provisioner is built. */
|
|
170
180
|
serviceIntegrations?(env: NodeJS.ProcessEnv): ServiceIntegration[];
|
|
181
|
+
/** See the header: undefined means "no opinion" — the manifest applies. */
|
|
182
|
+
specialtyDefaults?(specialty: string): SpecialtyDefaults | undefined;
|
|
171
183
|
services?(ctx: AdminExtensionContext): AdminExtensionServices;
|
|
172
184
|
apiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
|
|
173
185
|
adminUiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
|
|
@@ -344,6 +356,23 @@ export function collectServiceIntegrations(
|
|
|
344
356
|
return extensions.flatMap((ext) => ext.serviceIntegrations?.(env) ?? []);
|
|
345
357
|
}
|
|
346
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Builds the resolver the cron reconcile and create-time seeding consult
|
|
361
|
+
* before the manifest. The first extension returning defaults wins; with none
|
|
362
|
+
* registered (or none answering) it returns undefined and the manifest applies.
|
|
363
|
+
*/
|
|
364
|
+
export function collectSpecialtyDefaults(
|
|
365
|
+
extensions: readonly AdminExtension[],
|
|
366
|
+
): SpecialtyDefaultsResolver {
|
|
367
|
+
return (specialty) => {
|
|
368
|
+
for (const ext of extensions) {
|
|
369
|
+
const defaults = ext.specialtyDefaults?.(specialty);
|
|
370
|
+
if (defaults) return defaults;
|
|
371
|
+
}
|
|
372
|
+
return undefined;
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
347
376
|
/** Mounts each contribution on root, in order. */
|
|
348
377
|
export function mountRouteContributions(
|
|
349
378
|
root: AnyHono,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
applyOpenApiContributions,
|
|
10
10
|
collectOpenApiContributions,
|
|
11
11
|
collectServiceIntegrations,
|
|
12
|
+
collectSpecialtyDefaults,
|
|
12
13
|
mountRouteContributions,
|
|
13
14
|
renderAgentDetailCardSlots,
|
|
14
15
|
renderAgentDetailCards,
|
|
@@ -530,3 +531,21 @@ describe("renderAgentDetailCardSlots", () => {
|
|
|
530
531
|
expect(rendered).toBe(1);
|
|
531
532
|
});
|
|
532
533
|
});
|
|
534
|
+
|
|
535
|
+
describe("collectSpecialtyDefaults", () => {
|
|
536
|
+
it("returns undefined with no extensions or hookless extensions", () => {
|
|
537
|
+
expect(collectSpecialtyDefaults([])("coding")).toBeUndefined();
|
|
538
|
+
expect(
|
|
539
|
+
collectSpecialtyDefaults([{ name: "bare" }])("coding"),
|
|
540
|
+
).toBeUndefined();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("returns the first extension's defaults that answers for the specialty", () => {
|
|
544
|
+
const resolve = collectSpecialtyDefaults([
|
|
545
|
+
{ specialtyDefaults: (s) => (s === "a" ? { tools: ["A"] } : undefined) },
|
|
546
|
+
{ specialtyDefaults: () => ({ tools: ["B"] }) },
|
|
547
|
+
]);
|
|
548
|
+
expect(resolve("a")).toEqual({ tools: ["A"] });
|
|
549
|
+
expect(resolve("b")).toEqual({ tools: ["B"] });
|
|
550
|
+
});
|
|
551
|
+
});
|
|
@@ -1523,7 +1523,7 @@ describe("admin UI — account users create / delete agents (SSP-4.2)", () => {
|
|
|
1523
1523
|
expect(res.status).toBe(403);
|
|
1524
1524
|
});
|
|
1525
1525
|
|
|
1526
|
-
it("POST /admin/agents — account user's agent is stamped with their account and forced to
|
|
1526
|
+
it("POST /admin/agents — account user's agent is stamped with their account and forced to the self-serve specialty", async () => {
|
|
1527
1527
|
const { deps, created } = depsCapturingCreate("acct-a");
|
|
1528
1528
|
const res = await createAdminUIApp(deps).request(
|
|
1529
1529
|
"/admin/agents",
|
package/src/admin-ui-pages.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { PROGRESS_LABELS } from "@app-vitals/agent-lib/progress-phases";
|
|
14
|
+
import { getSelfServeSpecialty } from "./account-agent-create.ts";
|
|
14
15
|
import { renderAdminPage } from "./admin-ui-layout.ts";
|
|
15
16
|
import {
|
|
16
17
|
BREAKPOINT_MOBILE_MAX,
|
|
@@ -18,7 +19,6 @@ import {
|
|
|
18
19
|
renderAdminToolbar,
|
|
19
20
|
} from "./admin-ui-styles.ts";
|
|
20
21
|
import type { ManualStep } from "./agent-deletion-checklist.ts";
|
|
21
|
-
import type { SpecialtyOption } from "./specialty-manifest-loader.ts";
|
|
22
22
|
import { isAudioFilename } from "./attachment-validation.ts";
|
|
23
23
|
import { parseChatMarkers } from "./chat-markers.ts";
|
|
24
24
|
import type {
|
|
@@ -28,6 +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-manifest-loader.ts";
|
|
31
32
|
|
|
32
33
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
33
34
|
|
|
@@ -79,7 +80,7 @@ export interface AgentDetail {
|
|
|
79
80
|
* Rendered as a checkbox on both the create and edit forms.
|
|
80
81
|
*/
|
|
81
82
|
restrictSlackToMembers: boolean;
|
|
82
|
-
/** The agent's type name (e.g. "
|
|
83
|
+
/** The agent's type name (e.g. "specialist"), resolved at creation time. */
|
|
83
84
|
specialty: string;
|
|
84
85
|
/**
|
|
85
86
|
* Required env keys declared by the agent's type manifest with no
|
|
@@ -487,7 +488,7 @@ export function renderNewLocalAgentPage(
|
|
|
487
488
|
/**
|
|
488
489
|
* Set for self-serve account users (SSP-4.2): shows the account's
|
|
489
490
|
* remaining agent quota and the prerequisites, and pins the type to
|
|
490
|
-
*
|
|
491
|
+
* the self-serve specialty (the server forces it regardless).
|
|
491
492
|
*/
|
|
492
493
|
selfServe?: { remaining: number; max: number; contactEmail: string };
|
|
493
494
|
},
|
|
@@ -566,7 +567,7 @@ export function renderNewLocalAgentPage(
|
|
|
566
567
|
</div>`
|
|
567
568
|
: "";
|
|
568
569
|
const typeFieldHtml = selfServe
|
|
569
|
-
? `<input type="hidden" name="type" value="
|
|
570
|
+
? `<input type="hidden" name="type" value="${escapeHtml(getSelfServeSpecialty())}" />`
|
|
570
571
|
: `<div class="form-group">
|
|
571
572
|
<label class="form-label" for="type">Specialty <span style="color:#ef4444">*</span></label>
|
|
572
573
|
<select id="type" name="type" class="form-input" required>
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* No I/O, no Hono, no HTTP — pure string → string.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
10
|
import { renderZeroQuotaNotice } from "./admin-ui-account-pages.ts";
|
|
11
11
|
import {
|
|
12
12
|
type AgentDetail,
|
|
@@ -4259,12 +4259,40 @@ describe("renderNewLocalAgentPage — self-serve account user (SSP-4.2)", () =>
|
|
|
4259
4259
|
expect(html).toContain("Claude API key");
|
|
4260
4260
|
});
|
|
4261
4261
|
|
|
4262
|
-
|
|
4263
|
-
const
|
|
4264
|
-
selfServe,
|
|
4262
|
+
describe("hidden type input", () => {
|
|
4263
|
+
const saved = {
|
|
4264
|
+
selfServe: process.env.SHIPWRIGHT_SELF_SERVE_AGENT_TYPE,
|
|
4265
|
+
defaultType: process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE,
|
|
4266
|
+
};
|
|
4267
|
+
const restore = (key: string, value: string | undefined) => {
|
|
4268
|
+
if (value === undefined) delete process.env[key];
|
|
4269
|
+
else process.env[key] = value;
|
|
4270
|
+
};
|
|
4271
|
+
afterEach(() => {
|
|
4272
|
+
restore("SHIPWRIGHT_SELF_SERVE_AGENT_TYPE", saved.selfServe);
|
|
4273
|
+
restore("SHIPWRIGHT_DEFAULT_AGENT_TYPE", saved.defaultType);
|
|
4274
|
+
});
|
|
4275
|
+
|
|
4276
|
+
test("carries the SHIPWRIGHT_SELF_SERVE_AGENT_TYPE value instead of a type select", () => {
|
|
4277
|
+
process.env.SHIPWRIGHT_SELF_SERVE_AGENT_TYPE = "base";
|
|
4278
|
+
const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
|
|
4279
|
+
selfServe,
|
|
4280
|
+
});
|
|
4281
|
+
expect(html).toContain('<input type="hidden" name="type" value="base"');
|
|
4282
|
+
expect(html).not.toContain('name="type" value="coding"');
|
|
4283
|
+
expect(html).not.toMatch(/<select[^>]*name="type"/);
|
|
4284
|
+
});
|
|
4285
|
+
|
|
4286
|
+
test("falls back to the resolved default specialty when the env is unset", () => {
|
|
4287
|
+
delete process.env.SHIPWRIGHT_SELF_SERVE_AGENT_TYPE;
|
|
4288
|
+
delete process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE;
|
|
4289
|
+
const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
|
|
4290
|
+
selfServe,
|
|
4291
|
+
});
|
|
4292
|
+
expect(html).toContain(
|
|
4293
|
+
'<input type="hidden" name="type" value="specialist"',
|
|
4294
|
+
);
|
|
4265
4295
|
});
|
|
4266
|
-
expect(html).toContain('<input type="hidden" name="type" value="coding"');
|
|
4267
|
-
expect(html).not.toMatch(/<select[^>]*name="type"/);
|
|
4268
4296
|
});
|
|
4269
4297
|
|
|
4270
4298
|
test("at zero remaining, explains the limit and points at the contact email", () => {
|
package/src/admin-ui.ts
CHANGED
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
type SpecialtyManifestResolver,
|
|
79
79
|
SpecialtyRegistry,
|
|
80
80
|
} from "./specialty-manifest-loader.ts";
|
|
81
|
+
import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
|
|
81
82
|
import type { AgentService } from "./agents.ts";
|
|
82
83
|
import { createAgent, describeRepoNameCollision } from "./agents.ts";
|
|
83
84
|
import { AnthropicCredentialsBlockedError } from "./anthropic-credential-guard.ts";
|
|
@@ -352,6 +353,8 @@ export interface AdminUIDeps {
|
|
|
352
353
|
* already used by agents-api.ts's AdminDeps.
|
|
353
354
|
*/
|
|
354
355
|
specialtyRegistry?: SpecialtyManifestResolver;
|
|
356
|
+
/** Extension-supplied specialty defaults, consulted before the manifest. */
|
|
357
|
+
specialtyDefaults?: SpecialtyDefaultsResolver;
|
|
355
358
|
/**
|
|
356
359
|
* Cleanup deps for the full delete-agent orchestration (POST
|
|
357
360
|
* /admin/agents/:id/delete → deleteAgentFully()). Matches
|
|
@@ -782,6 +785,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
782
785
|
accountCreatedNotifier,
|
|
783
786
|
accountServices,
|
|
784
787
|
specialtyRegistry = new SpecialtyRegistry(),
|
|
788
|
+
specialtyDefaults,
|
|
785
789
|
callerScopeResolver = memberOnlyCallerScopeResolver(agentMemberService),
|
|
786
790
|
serviceIntegrations,
|
|
787
791
|
chatService,
|
|
@@ -1507,7 +1511,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
1507
1511
|
return c.redirect("/admin/agents/new", 302);
|
|
1508
1512
|
}
|
|
1509
1513
|
const connectSlack = connectSlackRaw === "true";
|
|
1510
|
-
// SSP-4.2: account users always create
|
|
1514
|
+
// SSP-4.2: account users always create self-serve-specialty agents in their own
|
|
1511
1515
|
// account — any submitted type/accountId is ignored.
|
|
1512
1516
|
if (accountId !== null) specialty = getSelfServeSpecialty();
|
|
1513
1517
|
// Absent/unrecognized runtime means self-hosted — the historical behavior
|
|
@@ -1557,6 +1561,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
1557
1561
|
agentCronJobService,
|
|
1558
1562
|
provisioner,
|
|
1559
1563
|
specialtyRegistry,
|
|
1564
|
+
specialtyDefaults,
|
|
1560
1565
|
anthropicFederationEnabled: deps.anthropicFederationEnabled,
|
|
1561
1566
|
},
|
|
1562
1567
|
{
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
|
|
12
12
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
13
13
|
import type { PrismaClient } from "../prisma/client/client.ts";
|
|
14
|
+
import {
|
|
15
|
+
type AdminExtension,
|
|
16
|
+
collectSpecialtyDefaults,
|
|
17
|
+
} from "./admin-extension.ts";
|
|
14
18
|
import { AgentCronJobService } from "./agent-cron-jobs.ts";
|
|
15
19
|
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
16
20
|
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
@@ -581,3 +585,93 @@ describeOrSkip("AgentCronJobService (integration)", () => {
|
|
|
581
585
|
expect(await service.list(agentId)).toEqual([]);
|
|
582
586
|
});
|
|
583
587
|
});
|
|
588
|
+
|
|
589
|
+
// A fixture extension supplying defaults, wired the way admin-boot.ts does.
|
|
590
|
+
const fixtureExtension: AdminExtension = {
|
|
591
|
+
name: "fixture",
|
|
592
|
+
specialtyDefaults: (specialty) =>
|
|
593
|
+
specialty === FIXTURE_TYPE
|
|
594
|
+
? {
|
|
595
|
+
crons: [
|
|
596
|
+
{
|
|
597
|
+
name: "ext-loop",
|
|
598
|
+
schedule: "*/10 * * * *",
|
|
599
|
+
prompt: "/ext:loop",
|
|
600
|
+
enabled: true,
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
name: "ext-child",
|
|
604
|
+
schedule: "5 * * * *",
|
|
605
|
+
prompt: "/ext:child",
|
|
606
|
+
enabled: false,
|
|
607
|
+
parentCron: "ext-loop",
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
}
|
|
611
|
+
: undefined,
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
describeOrSkip("AgentCronJobService specialtyDefaults (integration)", () => {
|
|
615
|
+
let prisma: PrismaClient;
|
|
616
|
+
let service: AgentCronJobService;
|
|
617
|
+
|
|
618
|
+
beforeEach(async () => {
|
|
619
|
+
prisma = makePrisma();
|
|
620
|
+
await prisma.agentToken.deleteMany();
|
|
621
|
+
await prisma.agentCronJob.deleteMany();
|
|
622
|
+
await prisma.agentTool.deleteMany();
|
|
623
|
+
await prisma.agentEnv.deleteMany();
|
|
624
|
+
await prisma.agent.deleteMany();
|
|
625
|
+
service = new AgentCronJobService(
|
|
626
|
+
prisma,
|
|
627
|
+
undefined,
|
|
628
|
+
fixtureResolver,
|
|
629
|
+
collectSpecialtyDefaults([fixtureExtension]),
|
|
630
|
+
);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
afterEach(async () => {
|
|
634
|
+
await prisma.$disconnect();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("reconcile seeds the extension's crons instead of the manifest's", async () => {
|
|
638
|
+
const agentId = await createAgent(prisma);
|
|
639
|
+
const result = await service.reconcileSystemCrons(agentId);
|
|
640
|
+
expect(result).toEqual({ created: 2, updated: 0, deleted: 0 });
|
|
641
|
+
const jobs = await service.list(agentId);
|
|
642
|
+
expect(jobs.map((j) => j.name).sort()).toEqual(["ext-child", "ext-loop"]);
|
|
643
|
+
const loop = jobs.find((j) => j.name === "ext-loop");
|
|
644
|
+
const child = jobs.find((j) => j.name === "ext-child");
|
|
645
|
+
expect(child?.parentCronId).toBe(loop?.id as string);
|
|
646
|
+
expect(child?.enabled).toBe(false);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("re-reconcile keeps ids and enabled state and deletes orphaned system crons", async () => {
|
|
650
|
+
const agentId = await createAgent(prisma);
|
|
651
|
+
await service.reconcileSystemCrons(agentId);
|
|
652
|
+
const before = await service.list(agentId);
|
|
653
|
+
const loop = before.find((j) => j.name === "ext-loop");
|
|
654
|
+
await prisma.agentCronJob.update({
|
|
655
|
+
where: { id: loop?.id as string },
|
|
656
|
+
data: { enabled: false },
|
|
657
|
+
});
|
|
658
|
+
await prisma.agentCronJob.create({
|
|
659
|
+
data: {
|
|
660
|
+
agentId,
|
|
661
|
+
name: "stale-system",
|
|
662
|
+
system: true,
|
|
663
|
+
schedule: "* * * * *",
|
|
664
|
+
prompt: "x",
|
|
665
|
+
},
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
const result = await service.reconcileSystemCrons(agentId);
|
|
669
|
+
|
|
670
|
+
expect(result).toEqual({ created: 0, updated: 2, deleted: 1 });
|
|
671
|
+
const after = await service.list(agentId);
|
|
672
|
+
const loopAfter = after.find((j) => j.name === "ext-loop");
|
|
673
|
+
expect(loopAfter?.id).toBe(loop?.id as string);
|
|
674
|
+
expect(loopAfter?.enabled).toBe(false);
|
|
675
|
+
expect(after.some((j) => j.name === "stale-system")).toBe(false);
|
|
676
|
+
});
|
|
677
|
+
});
|
package/src/agent-cron-jobs.ts
CHANGED
|
@@ -8,16 +8,17 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
|
|
11
|
+
import { type Clock, SystemClock } from "./clock.ts";
|
|
12
|
+
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
13
|
+
import type {
|
|
14
|
+
SpecialtyDefaultCron,
|
|
15
|
+
SpecialtyDefaultsResolver,
|
|
16
|
+
} from "./specialty-defaults.ts";
|
|
11
17
|
import {
|
|
12
18
|
type SpecialtyManifestResolver,
|
|
13
19
|
SpecialtyRegistry,
|
|
14
|
-
missingDefaultSpecialtyError,
|
|
15
|
-
resolveDefaultSpecialtyName,
|
|
16
|
-
resolveSpecialtiesDir,
|
|
17
20
|
} from "./specialty-manifest-loader.ts";
|
|
18
21
|
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
19
|
-
import { type Clock, SystemClock } from "./clock.ts";
|
|
20
|
-
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* The shape of a single cron template resolved from a specialty's manifest.
|
|
@@ -87,6 +88,7 @@ export class AgentCronJobService {
|
|
|
87
88
|
private prisma: PrismaClient,
|
|
88
89
|
private clock: Clock = SystemClock(),
|
|
89
90
|
private specialtyRegistry: SpecialtyManifestResolver = new SpecialtyRegistry(),
|
|
91
|
+
private specialtyDefaults: SpecialtyDefaultsResolver = () => undefined,
|
|
90
92
|
) {}
|
|
91
93
|
|
|
92
94
|
/**
|
|
@@ -443,11 +445,10 @@ export class AgentCronJobService {
|
|
|
443
445
|
* The cron list is resolved from the agent's type manifest — the agent's
|
|
444
446
|
* stored `specialty` is looked up via the injected SpecialtyRegistry's
|
|
445
447
|
* `tryGetManifest()` (the parsed `specialties/{specialty}/manifest.yaml`),
|
|
446
|
-
* deliberately WITHOUT the
|
|
447
|
-
* loadable manifest logs an error and reconciles nothing
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
* startup bug. This replaced the former hardcoded
|
|
448
|
+
* deliberately WITHOUT the default-type fallback: any type (the default
|
|
449
|
+
* included) with no loadable manifest logs an error and reconciles nothing
|
|
450
|
+
* (no seeding of another type's crons, no deletion of existing system
|
|
451
|
+
* crons). This replaced the former hardcoded
|
|
451
452
|
* SYSTEM_CRONS array as the source of truth (ATS-3.2).
|
|
452
453
|
*
|
|
453
454
|
* Two passes so parent/child links resolve regardless of the manifest's
|
|
@@ -483,9 +484,9 @@ export class AgentCronJobService {
|
|
|
483
484
|
async reconcileSystemCrons(
|
|
484
485
|
agentId: string,
|
|
485
486
|
): Promise<{ created: number; updated: number; deleted: number }> {
|
|
486
|
-
// Resolve the agent's type manifest → its declared cron list. No
|
|
487
|
-
// fallback: seeding
|
|
488
|
-
//
|
|
487
|
+
// Resolve the agent's type manifest → its declared cron list. No default-type
|
|
488
|
+
// fallback: seeding another type's crons on an agent is worse than
|
|
489
|
+
// leaving its crons untouched.
|
|
489
490
|
const agent = await this.prisma.agent.findUnique({
|
|
490
491
|
where: { id: agentId },
|
|
491
492
|
select: { specialty: true },
|
|
@@ -494,22 +495,19 @@ export class AgentCronJobService {
|
|
|
494
495
|
throw new NotFoundError(`agent ${agentId} not found`);
|
|
495
496
|
}
|
|
496
497
|
const { specialty } = agent;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
resolveSpecialtiesDir(),
|
|
504
|
-
new Error(`reconcileSystemCrons for agent ${agentId}`),
|
|
505
|
-
);
|
|
506
|
-
}
|
|
498
|
+
// Extension-supplied crons win; the manifest is the fallback.
|
|
499
|
+
const extensionCrons = this.specialtyDefaults(specialty)?.crons;
|
|
500
|
+
const manifest = extensionCrons
|
|
501
|
+
? undefined
|
|
502
|
+
: this.specialtyRegistry.tryGetManifest(specialty);
|
|
503
|
+
if (!extensionCrons && !manifest) {
|
|
507
504
|
console.error(
|
|
508
505
|
`reconcileSystemCrons: no loadable manifest for specialty "${specialty}" (agent ${agentId}) — leaving crons untouched`,
|
|
509
506
|
);
|
|
510
507
|
return { created: 0, updated: 0, deleted: 0 };
|
|
511
508
|
}
|
|
512
|
-
const manifestCrons: readonly ManifestCron[] =
|
|
509
|
+
const manifestCrons: readonly (ManifestCron | SpecialtyDefaultCron)[] =
|
|
510
|
+
extensionCrons ?? manifest?.crons ?? [];
|
|
513
511
|
|
|
514
512
|
// Fetch all existing system crons for this agent
|
|
515
513
|
const existingSystemCrons = await this.prisma.agentCronJob.findMany({
|
|
@@ -566,7 +564,7 @@ export class AgentCronJobService {
|
|
|
566
564
|
preCheck: systemCron.preCheck ?? null,
|
|
567
565
|
channel: null,
|
|
568
566
|
user: null,
|
|
569
|
-
enabled: systemCron.enabled,
|
|
567
|
+
enabled: systemCron.enabled ?? true,
|
|
570
568
|
},
|
|
571
569
|
});
|
|
572
570
|
idByName.set(systemCron.name, createdCron.id);
|