@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
|
@@ -12,8 +12,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
12
12
|
import type { AgentCronJob, PrismaClient } from "../prisma/client/client.ts";
|
|
13
13
|
import { AgentCronJobService, isValidCron } from "./agent-cron-jobs.ts";
|
|
14
14
|
import {
|
|
15
|
-
type SpecialtyManifestResolver,
|
|
16
15
|
resolveDefaultSpecialtyName,
|
|
16
|
+
type SpecialtyManifestResolver,
|
|
17
17
|
} from "./specialty-manifest-loader.ts";
|
|
18
18
|
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
19
19
|
|
|
@@ -374,51 +374,159 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
374
374
|
expect(String(errors[0]?.[0])).toContain("agent-1");
|
|
375
375
|
});
|
|
376
376
|
|
|
377
|
-
test("the default manifest
|
|
377
|
+
test("the default type with no loadable manifest logs an error and changes nothing", async () => {
|
|
378
378
|
const registry = fakeRegistry({});
|
|
379
|
-
const { prisma } = makeFakePrisma({
|
|
380
|
-
specialty:
|
|
379
|
+
const { prisma, rows } = makeFakePrisma({
|
|
380
|
+
specialty: "specialist",
|
|
381
|
+
existing: [{ id: "sys-1", name: "old-system", system: true }],
|
|
381
382
|
});
|
|
382
383
|
const service = new AgentCronJobService(
|
|
383
384
|
prisma as unknown as PrismaClient,
|
|
384
385
|
undefined,
|
|
385
386
|
registry,
|
|
386
387
|
);
|
|
388
|
+
const original = console.error;
|
|
389
|
+
console.error = () => {};
|
|
390
|
+
try {
|
|
391
|
+
expect(await service.reconcileSystemCrons("agent-1")).toEqual({
|
|
392
|
+
created: 0,
|
|
393
|
+
updated: 0,
|
|
394
|
+
deleted: 0,
|
|
395
|
+
});
|
|
396
|
+
} finally {
|
|
397
|
+
console.error = original;
|
|
398
|
+
}
|
|
399
|
+
expect(Array.from(rows.keys())).toEqual(["sys-1"]);
|
|
400
|
+
});
|
|
387
401
|
|
|
388
|
-
|
|
389
|
-
|
|
402
|
+
test("a valid non-coding manifest reconciles normally", async () => {
|
|
403
|
+
const registry = fakeRegistry({ specialist: [loopCron] });
|
|
404
|
+
const { prisma, rows } = makeFakePrisma({ specialty: "specialist" });
|
|
405
|
+
const service = new AgentCronJobService(
|
|
406
|
+
prisma as unknown as PrismaClient,
|
|
407
|
+
undefined,
|
|
408
|
+
registry,
|
|
390
409
|
);
|
|
410
|
+
|
|
411
|
+
const result = await service.reconcileSystemCrons("agent-1");
|
|
412
|
+
|
|
413
|
+
expect(result.created).toBe(1);
|
|
414
|
+
expect(registry.lookups).toEqual(["specialist"]);
|
|
415
|
+
expect(Array.from(rows.values())[0]?.name).toBe("shipwright-loop");
|
|
391
416
|
});
|
|
417
|
+
});
|
|
392
418
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
419
|
+
describe("reconcileSystemCrons (specialtyDefaults hook)", () => {
|
|
420
|
+
const extCron = {
|
|
421
|
+
name: "ext-cron",
|
|
422
|
+
schedule: "*/5 * * * *",
|
|
423
|
+
prompt: "/ext",
|
|
424
|
+
enabled: false,
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
test("hook crons win over the manifest and the manifest is not consulted", async () => {
|
|
428
|
+
const registry = fakeRegistry({ coding: [loopCron] });
|
|
429
|
+
const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
|
|
430
|
+
const service = new AgentCronJobService(
|
|
431
|
+
prisma as unknown as PrismaClient,
|
|
432
|
+
undefined,
|
|
433
|
+
registry,
|
|
434
|
+
(specialty) =>
|
|
435
|
+
specialty === "coding" ? { crons: [extCron] } : undefined,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const result = await service.reconcileSystemCrons("agent-1");
|
|
439
|
+
|
|
440
|
+
expect(result.created).toBe(1);
|
|
441
|
+
expect(registry.lookups).toEqual([]);
|
|
442
|
+
const [row] = Array.from(rows.values());
|
|
443
|
+
expect(row?.name).toBe("ext-cron");
|
|
444
|
+
expect(row?.enabled).toBe(false);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("hook returning nothing falls back to the manifest", async () => {
|
|
448
|
+
const registry = fakeRegistry({ coding: [loopCron] });
|
|
449
|
+
const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
|
|
398
450
|
const service = new AgentCronJobService(
|
|
399
451
|
prisma as unknown as PrismaClient,
|
|
400
452
|
undefined,
|
|
401
453
|
registry,
|
|
454
|
+
() => undefined,
|
|
402
455
|
);
|
|
403
456
|
|
|
404
|
-
await
|
|
405
|
-
|
|
457
|
+
await service.reconcileSystemCrons("agent-1");
|
|
458
|
+
|
|
459
|
+
expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
|
|
460
|
+
"shipwright-loop",
|
|
461
|
+
]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("defaults without crons fall back to the manifest crons", async () => {
|
|
465
|
+
const registry = fakeRegistry({ coding: [loopCron] });
|
|
466
|
+
const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
|
|
467
|
+
const service = new AgentCronJobService(
|
|
468
|
+
prisma as unknown as PrismaClient,
|
|
469
|
+
undefined,
|
|
470
|
+
registry,
|
|
471
|
+
() => ({ tools: ["Read"] }),
|
|
406
472
|
);
|
|
473
|
+
|
|
474
|
+
await service.reconcileSystemCrons("agent-1");
|
|
475
|
+
|
|
476
|
+
expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
|
|
477
|
+
"shipwright-loop",
|
|
478
|
+
]);
|
|
407
479
|
});
|
|
408
480
|
|
|
409
|
-
test("
|
|
410
|
-
const registry = fakeRegistry({
|
|
411
|
-
const { prisma, rows } = makeFakePrisma({ specialty: "
|
|
481
|
+
test("reconciles hook crons without any manifest for the specialty", async () => {
|
|
482
|
+
const registry = fakeRegistry({});
|
|
483
|
+
const { prisma, rows } = makeFakePrisma({ specialty: "ext-only" });
|
|
412
484
|
const service = new AgentCronJobService(
|
|
413
485
|
prisma as unknown as PrismaClient,
|
|
414
486
|
undefined,
|
|
415
487
|
registry,
|
|
488
|
+
() => ({ crons: [extCron] }),
|
|
416
489
|
);
|
|
417
490
|
|
|
418
491
|
const result = await service.reconcileSystemCrons("agent-1");
|
|
419
492
|
|
|
420
493
|
expect(result.created).toBe(1);
|
|
421
|
-
expect(
|
|
422
|
-
|
|
494
|
+
expect(Array.from(rows.values())[0]?.name).toBe("ext-cron");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("preserves row id and enabled state, links parents, and deletes orphans", async () => {
|
|
498
|
+
const registry = fakeRegistry({});
|
|
499
|
+
const { prisma, rows } = makeFakePrisma({
|
|
500
|
+
specialty: "coding",
|
|
501
|
+
existing: [
|
|
502
|
+
{ id: "stable-id", name: "ext-cron", enabled: true },
|
|
503
|
+
{ id: "orphan", name: "removed-cron" },
|
|
504
|
+
],
|
|
505
|
+
});
|
|
506
|
+
const service = new AgentCronJobService(
|
|
507
|
+
prisma as unknown as PrismaClient,
|
|
508
|
+
undefined,
|
|
509
|
+
registry,
|
|
510
|
+
() => ({
|
|
511
|
+
crons: [
|
|
512
|
+
extCron,
|
|
513
|
+
{
|
|
514
|
+
name: "ext-child",
|
|
515
|
+
schedule: "* * * * *",
|
|
516
|
+
prompt: "/c",
|
|
517
|
+
parentCron: "ext-cron",
|
|
518
|
+
},
|
|
519
|
+
],
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const result = await service.reconcileSystemCrons("agent-1");
|
|
524
|
+
|
|
525
|
+
expect(result).toEqual({ created: 1, updated: 1, deleted: 1 });
|
|
526
|
+
expect(rows.get("stable-id")?.enabled).toBe(true);
|
|
527
|
+
expect(rows.has("orphan")).toBe(false);
|
|
528
|
+
const child = Array.from(rows.values()).find((r) => r.name === "ext-child");
|
|
529
|
+
expect(child?.parentCronId).toBe("stable-id");
|
|
530
|
+
expect(child?.enabled).toBe(true);
|
|
423
531
|
});
|
|
424
532
|
});
|
package/src/agents-api.ts
CHANGED
|
@@ -156,6 +156,7 @@ import {
|
|
|
156
156
|
UpsertChatTokenDailyBodySchema,
|
|
157
157
|
} from "./openapi-schemas.ts";
|
|
158
158
|
import type { SelfServeConfig } from "./self-serve-config.ts";
|
|
159
|
+
import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
|
|
159
160
|
import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
|
|
160
161
|
import { managedReconcileTargets } from "./startup-policy-reconcile.ts";
|
|
161
162
|
|
|
@@ -225,6 +226,8 @@ export interface AdminDeps {
|
|
|
225
226
|
* Required (not optional) because the route needs it unconditionally.
|
|
226
227
|
*/
|
|
227
228
|
specialtyRegistry: SpecialtyManifestResolver;
|
|
229
|
+
/** Extension-supplied specialty defaults, consulted before the manifest. */
|
|
230
|
+
specialtyDefaults?: SpecialtyDefaultsResolver;
|
|
228
231
|
agentChatTokenService: Pick<
|
|
229
232
|
AgentChatTokenService,
|
|
230
233
|
"upsertDailyByModel" | "queryStats"
|
|
@@ -1522,6 +1525,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
|
1522
1525
|
agentPluginService,
|
|
1523
1526
|
agentMemberService,
|
|
1524
1527
|
specialtyRegistry,
|
|
1528
|
+
specialtyDefaults,
|
|
1525
1529
|
agentChatTokenService,
|
|
1526
1530
|
agentGitHubInstallationsService,
|
|
1527
1531
|
prisma,
|
|
@@ -1930,7 +1934,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
|
1930
1934
|
// SSP-4.2: platform admins keep today's behavior (and may create
|
|
1931
1935
|
// unassigned agents). A session user with an account (flag on) may create
|
|
1932
1936
|
// agents in THEIR account only: accountId comes from the resolved scope —
|
|
1933
|
-
// never the body — and the type is forced to
|
|
1937
|
+
// never the body — and the type is forced to the self-serve specialty.
|
|
1934
1938
|
let accountId: string | undefined;
|
|
1935
1939
|
if (c.get("isAdmin") !== true) {
|
|
1936
1940
|
const email = c.get("callerEmail");
|
|
@@ -1965,6 +1969,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
|
|
|
1965
1969
|
agentCronJobService,
|
|
1966
1970
|
provisioner,
|
|
1967
1971
|
specialtyRegistry,
|
|
1972
|
+
specialtyDefaults,
|
|
1968
1973
|
anthropicFederationEnabled: deps.anthropicFederationEnabled,
|
|
1969
1974
|
},
|
|
1970
1975
|
{
|
package/src/agents.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type SpecialtyManifestResolver,
|
|
24
24
|
SpecialtyRegistry,
|
|
25
25
|
} from "./specialty-manifest-loader.ts";
|
|
26
|
+
import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
|
|
26
27
|
import { ANTHROPIC_CREDENTIALS_BLOCKED_CODE } from "./anthropic-credential-guard.ts";
|
|
27
28
|
import type { PrismaTransactionClient } from "./prisma-tx.ts";
|
|
28
29
|
|
|
@@ -784,6 +785,8 @@ export interface CreateAgentDeps {
|
|
|
784
785
|
agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
|
|
785
786
|
provisioner: Pick<AgentProvisioner, "canProvision" | "provision">;
|
|
786
787
|
specialtyRegistry: Pick<SpecialtyManifestResolver, "tryGetManifest">;
|
|
788
|
+
/** Extension-supplied tools/plugins, consulted before the manifest's. */
|
|
789
|
+
specialtyDefaults?: SpecialtyDefaultsResolver;
|
|
787
790
|
/** WIF-2.1: Anthropic federation is enabled; defaults to false. */
|
|
788
791
|
anthropicFederationEnabled?: boolean;
|
|
789
792
|
}
|
|
@@ -878,7 +881,10 @@ export async function createAgent(
|
|
|
878
881
|
const manifest = specialty
|
|
879
882
|
? deps.specialtyRegistry.tryGetManifest(specialty)
|
|
880
883
|
: undefined;
|
|
881
|
-
|
|
884
|
+
const extensionDefaults = specialty
|
|
885
|
+
? deps.specialtyDefaults?.(specialty)
|
|
886
|
+
: undefined;
|
|
887
|
+
if (!specialty || (!manifest && !extensionDefaults)) {
|
|
882
888
|
return { ok: false, errorCode: "invalid_type" };
|
|
883
889
|
}
|
|
884
890
|
|
|
@@ -940,10 +946,14 @@ export async function createAgent(
|
|
|
940
946
|
// handling below, and the manifest's members/repos arrays are empty
|
|
941
947
|
// for every specialty that exists today.
|
|
942
948
|
try {
|
|
943
|
-
for (const pattern of
|
|
949
|
+
for (const pattern of extensionDefaults?.tools ??
|
|
950
|
+
manifest?.tools ??
|
|
951
|
+
[]) {
|
|
944
952
|
await deps.agentToolService.add(agent.id, pattern, tx);
|
|
945
953
|
}
|
|
946
|
-
for (const pluginName of
|
|
954
|
+
for (const pluginName of extensionDefaults?.plugins ??
|
|
955
|
+
manifest?.plugins ??
|
|
956
|
+
[]) {
|
|
947
957
|
await deps.agentPluginService.add(
|
|
948
958
|
agent.id,
|
|
949
959
|
pluginName,
|
package/src/agents.unit.test.ts
CHANGED
|
@@ -1319,6 +1319,76 @@ function baseCreateAgentInput(
|
|
|
1319
1319
|
};
|
|
1320
1320
|
}
|
|
1321
1321
|
|
|
1322
|
+
describe("createAgent() specialtyDefaults hook", () => {
|
|
1323
|
+
it("seeds tools and plugins from the hook instead of the manifest", async () => {
|
|
1324
|
+
const { deps, calls } = makeCreateAgentHarness();
|
|
1325
|
+
deps.specialtyDefaults = () => ({
|
|
1326
|
+
tools: ["Grep"],
|
|
1327
|
+
plugins: ["ext-plugin"],
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
const result = await createAgent(deps, baseCreateAgentInput());
|
|
1331
|
+
|
|
1332
|
+
expect(result.ok).toBe(true);
|
|
1333
|
+
expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(["Grep"]);
|
|
1334
|
+
expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["ext-plugin"]);
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
it("falls back to the manifest when the hook returns nothing", async () => {
|
|
1338
|
+
const { deps, calls } = makeCreateAgentHarness();
|
|
1339
|
+
deps.specialtyDefaults = () => undefined;
|
|
1340
|
+
|
|
1341
|
+
await createAgent(deps, baseCreateAgentInput());
|
|
1342
|
+
|
|
1343
|
+
expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(
|
|
1344
|
+
CODING_MANIFEST.tools,
|
|
1345
|
+
);
|
|
1346
|
+
expect(calls.pluginsAdded.map((p) => p.name)).toEqual(
|
|
1347
|
+
CODING_MANIFEST.plugins,
|
|
1348
|
+
);
|
|
1349
|
+
});
|
|
1350
|
+
|
|
1351
|
+
it("falls back per field when the hook omits tools or plugins", async () => {
|
|
1352
|
+
const { deps, calls } = makeCreateAgentHarness();
|
|
1353
|
+
deps.specialtyDefaults = () => ({ plugins: ["ext-plugin"] });
|
|
1354
|
+
|
|
1355
|
+
await createAgent(deps, baseCreateAgentInput());
|
|
1356
|
+
|
|
1357
|
+
expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(
|
|
1358
|
+
CODING_MANIFEST.tools,
|
|
1359
|
+
);
|
|
1360
|
+
expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["ext-plugin"]);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it("accepts a specialty known only to the hook", async () => {
|
|
1364
|
+
const { deps, calls } = makeCreateAgentHarness();
|
|
1365
|
+
deps.specialtyDefaults = (s) =>
|
|
1366
|
+
s === "ext-only" ? { tools: ["Read"] } : undefined;
|
|
1367
|
+
|
|
1368
|
+
const result = await createAgent(
|
|
1369
|
+
deps,
|
|
1370
|
+
baseCreateAgentInput({ specialty: "ext-only" }),
|
|
1371
|
+
);
|
|
1372
|
+
|
|
1373
|
+
expect(result.ok).toBe(true);
|
|
1374
|
+
expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(["Read"]);
|
|
1375
|
+
expect(calls.pluginsAdded).toEqual([]);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
it("still rejects a specialty unknown to both hook and manifest", async () => {
|
|
1379
|
+
const { deps, calls } = makeCreateAgentHarness();
|
|
1380
|
+
deps.specialtyDefaults = () => undefined;
|
|
1381
|
+
|
|
1382
|
+
const result = await createAgent(
|
|
1383
|
+
deps,
|
|
1384
|
+
baseCreateAgentInput({ specialty: "nope" }),
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1387
|
+
expect(result).toEqual({ ok: false, errorCode: "invalid_type" });
|
|
1388
|
+
expect(calls.created).toHaveLength(0);
|
|
1389
|
+
});
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1322
1392
|
describe("createAgent()", () => {
|
|
1323
1393
|
it("passes accountId through to agentService.create and tolerates an undefined tx (SSP-4.1)", async () => {
|
|
1324
1394
|
const { deps, calls } = makeCreateAgentHarness();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/specialty-defaults.ts
|
|
3
|
+
*
|
|
4
|
+
* The shape of a specialty's default crons, tools and plugins as supplied by
|
|
5
|
+
* an AdminExtension's specialtyDefaults() hook (admin-extension.ts). The cron
|
|
6
|
+
* reconcile (agent-cron-jobs.ts) and create-time seeding (agents.ts) consult
|
|
7
|
+
* the hook first and fall back to the specialty manifest when it returns
|
|
8
|
+
* nothing. Kept in its own module so those services can name the types without
|
|
9
|
+
* importing admin-extension.ts.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A default cron template; same fields as a manifest cron, defaults optional. */
|
|
13
|
+
export interface SpecialtyDefaultCron {
|
|
14
|
+
name: string;
|
|
15
|
+
schedule: string;
|
|
16
|
+
prompt: string;
|
|
17
|
+
preCheck?: string;
|
|
18
|
+
silent?: boolean;
|
|
19
|
+
/** Initial enabled state for a newly created row; defaults to true. */
|
|
20
|
+
enabled?: boolean;
|
|
21
|
+
/** Name of another cron in this same list. */
|
|
22
|
+
parentCron?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Any field left undefined falls back to the manifest for that field. */
|
|
26
|
+
export interface SpecialtyDefaults {
|
|
27
|
+
crons?: readonly SpecialtyDefaultCron[];
|
|
28
|
+
tools?: readonly string[];
|
|
29
|
+
plugins?: readonly string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Resolves a specialty name to extension-supplied defaults, if any. */
|
|
33
|
+
export type SpecialtyDefaultsResolver = (
|
|
34
|
+
specialty: string,
|
|
35
|
+
) => SpecialtyDefaults | undefined;
|
|
@@ -50,13 +50,17 @@ describe("SpecialtyRegistry with the fixture specialty", () => {
|
|
|
50
50
|
expect(fixtureRegistry().tryGetManifest("renamed-type")).toBeUndefined();
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
it("
|
|
53
|
+
it("warns and returns an empty-crons manifest when the default specialty is not on disk", () => {
|
|
54
|
+
const warnings: string[] = [];
|
|
54
55
|
const registry = new SpecialtyRegistry({
|
|
55
56
|
typesDir: FIXTURE_SPECIALTIES_DIR,
|
|
56
57
|
defaultTypeName: "not-there",
|
|
57
|
-
warn: () =>
|
|
58
|
+
warn: (m) => warnings.push(m),
|
|
58
59
|
});
|
|
59
|
-
|
|
60
|
+
const manifest = registry.getManifest("fixture-or-anything");
|
|
61
|
+
expect(manifest.crons).toEqual([]);
|
|
62
|
+
expect(manifest.plugins).toEqual([]);
|
|
63
|
+
expect(warnings.join("\n")).toMatch(
|
|
60
64
|
/SHIPWRIGHT_SPECIALTIES_DIR.*SHIPWRIGHT_DEFAULT_AGENT_TYPE/s,
|
|
61
65
|
);
|
|
62
66
|
});
|
|
@@ -2,24 +2,25 @@
|
|
|
2
2
|
* admin/src/specialty-manifest-loader.ts
|
|
3
3
|
*
|
|
4
4
|
* SpecialtyRegistry — resolves an agent's stored `specialty` to its parsed
|
|
5
|
-
* Specialty manifest by loading `
|
|
6
|
-
* disk (
|
|
5
|
+
* Specialty manifest by loading `{typesDir}/{specialty}/manifest.yaml` from
|
|
6
|
+
* disk (typesDir defaults to the shipped agent-types/) and validating it via
|
|
7
7
|
* parseSpecialtyManifest().
|
|
8
8
|
*
|
|
9
9
|
* This is the runtime source of truth for an agent's default system crons
|
|
10
10
|
* (and, in time, its default plugins/tools/env contract). It replaces the
|
|
11
11
|
* former hardcoded SYSTEM_CRONS array in admin/src/system-crons.ts.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* whose type was renamed or removed.
|
|
13
|
+
* Defaults: the default type is `specialist` and the type directory is the
|
|
14
|
+
* shipped `agent-types/` (both resolved by @app-vitals/agent-lib/default-specialty,
|
|
15
|
+
* the single shared helper; SHIPWRIGHT_DEFAULT_AGENT_TYPE /
|
|
16
|
+
* SHIPWRIGHT_SPECIALTIES_DIR override them).
|
|
18
17
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* Fallback contract: getManifest() never throws. An unknown `specialty` (no
|
|
19
|
+
* matching directory under the type dir) resolves to the default type's
|
|
20
|
+
* manifest with a logged warning. If even the default cannot be loaded (bad
|
|
21
|
+
* env override, missing/invalid manifest) it warns and returns a built-in
|
|
22
|
+
* minimal manifest with no crons/plugins, so the boot-path
|
|
23
|
+
* POST /agents/:id/crons/reconcile route never 5xxs over configuration.
|
|
23
24
|
*
|
|
24
25
|
* The class takes an injectable reader/warner so unit tests can drive it
|
|
25
26
|
* without touching the filesystem (see agent-cron-jobs.unit.test.ts).
|
|
@@ -28,12 +29,47 @@
|
|
|
28
29
|
import { readdirSync, readFileSync } from "node:fs";
|
|
29
30
|
import { join } from "node:path";
|
|
30
31
|
import {
|
|
31
|
-
|
|
32
|
+
DEFAULT_SPECIALTY_NAME,
|
|
33
|
+
resolveDefaultSpecialtyName,
|
|
34
|
+
resolveSpecialtiesDir,
|
|
35
|
+
} from "@app-vitals/agent-lib/default-specialty";
|
|
36
|
+
import {
|
|
32
37
|
parseSpecialtyManifest,
|
|
38
|
+
type SpecialtyManifest,
|
|
33
39
|
} from "./specialty-registry.ts";
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
export {
|
|
42
|
+
DEFAULT_SPECIALTY_NAME,
|
|
43
|
+
resolveDefaultSpecialtyName,
|
|
44
|
+
resolveSpecialtiesDir,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Built-in last-resort manifest: used only when the default type itself cannot
|
|
49
|
+
* be loaded. Declares nothing (no crons, plugins, tools) so it never seeds
|
|
50
|
+
* anything unexpected.
|
|
51
|
+
*/
|
|
52
|
+
const BUILT_IN_FALLBACK_MANIFEST_YAML = `apiVersion: shipwright.dev/v1alpha1
|
|
53
|
+
kind: Specialty
|
|
54
|
+
metadata:
|
|
55
|
+
name: ${DEFAULT_SPECIALTY_NAME}
|
|
56
|
+
displayName: Specialist Agent
|
|
57
|
+
description: Built-in fallback used when the default specialty manifest cannot be loaded.
|
|
58
|
+
version: 0.0.0
|
|
59
|
+
skills: []
|
|
60
|
+
identity:
|
|
61
|
+
templatesDir: workspace/
|
|
62
|
+
crons: []
|
|
63
|
+
plugins: []
|
|
64
|
+
tools: []
|
|
65
|
+
env:
|
|
66
|
+
required: []
|
|
67
|
+
optional: []
|
|
68
|
+
members: []
|
|
69
|
+
repos: []
|
|
70
|
+
chat: true
|
|
71
|
+
voice: true
|
|
72
|
+
`;
|
|
37
73
|
|
|
38
74
|
/** Reads a manifest's raw YAML text for a type; throws if it does not exist. */
|
|
39
75
|
type ManifestReader = (specialty: string) => string;
|
|
@@ -54,51 +90,6 @@ export interface SpecialtyOption {
|
|
|
54
90
|
displayName: string;
|
|
55
91
|
}
|
|
56
92
|
|
|
57
|
-
/**
|
|
58
|
-
* Repo root resolved relative to this module (admin/src/ → ../../). The
|
|
59
|
-
* specialties/ directory lives at the repo root alongside admin/.
|
|
60
|
-
*/
|
|
61
|
-
const REPO_ROOT = join(import.meta.dir, "..", "..");
|
|
62
|
-
const BUILT_IN_SPECIALTIES_DIR = join(REPO_ROOT, "specialties");
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* The specialties directory: SHIPWRIGHT_SPECIALTIES_DIR when set, else the
|
|
66
|
-
* repo-root specialties/ (today's location).
|
|
67
|
-
*/
|
|
68
|
-
export function resolveSpecialtiesDir(
|
|
69
|
-
env: Record<string, string | undefined> = process.env,
|
|
70
|
-
): string {
|
|
71
|
-
return env.SHIPWRIGHT_SPECIALTIES_DIR || BUILT_IN_SPECIALTIES_DIR;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The universal fallback type: SHIPWRIGHT_DEFAULT_AGENT_TYPE when set, else
|
|
76
|
-
* "coding".
|
|
77
|
-
*/
|
|
78
|
-
/**
|
|
79
|
-
* The actionable error for a default specialty that cannot be loaded. The
|
|
80
|
-
* platform ships no specialty of its own, so this is a configuration problem:
|
|
81
|
-
* the operator must point the two env vars at a real specialties directory.
|
|
82
|
-
*/
|
|
83
|
-
export function missingDefaultSpecialtyError(
|
|
84
|
-
defaultTypeName: string,
|
|
85
|
-
typesDir: string,
|
|
86
|
-
cause?: unknown,
|
|
87
|
-
): Error {
|
|
88
|
-
const detail = cause instanceof Error ? ` (${cause.message})` : "";
|
|
89
|
-
return new Error(
|
|
90
|
-
`SpecialtyRegistry: the default specialty "${defaultTypeName}" could not be loaded from "${typesDir}"${detail}. ` +
|
|
91
|
-
"Set SHIPWRIGHT_SPECIALTIES_DIR to a directory containing <type>/manifest.yaml and " +
|
|
92
|
-
"SHIPWRIGHT_DEFAULT_AGENT_TYPE to the name of a specialty in it.",
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function resolveDefaultSpecialtyName(
|
|
97
|
-
env: Record<string, string | undefined> = process.env,
|
|
98
|
-
): string {
|
|
99
|
-
return env.SHIPWRIGHT_DEFAULT_AGENT_TYPE || DEFAULT_SPECIALTY_NAME;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
93
|
/** Default reader: loads {typesDir}/{specialty}/manifest.yaml from disk. */
|
|
103
94
|
export function defaultManifestReader(
|
|
104
95
|
specialty: string,
|
|
@@ -122,18 +113,19 @@ export function defaultTypeDirLister(
|
|
|
122
113
|
*/
|
|
123
114
|
export interface SpecialtyManifestResolver {
|
|
124
115
|
/**
|
|
125
|
-
* Resolve `specialty` to its parsed manifest. Falls back to the
|
|
126
|
-
* manifest (with a warning) when the type is unknown
|
|
127
|
-
*
|
|
116
|
+
* Resolve `specialty` to its parsed manifest. Falls back to the default
|
|
117
|
+
* type's manifest (with a warning) when the type is unknown, and to a
|
|
118
|
+
* built-in empty-crons manifest (with a warning) if the default cannot load
|
|
119
|
+
* either. Never throws.
|
|
128
120
|
*/
|
|
129
121
|
getManifest(specialty: string): SpecialtyManifest;
|
|
130
122
|
/**
|
|
131
123
|
* Resolve `specialty` to its parsed manifest WITHOUT falling back to
|
|
132
|
-
*
|
|
124
|
+
* the default type — returns undefined for an unknown type instead. No warning is
|
|
133
125
|
* logged (an unknown type here is an expected, callers-handle-it outcome,
|
|
134
126
|
* not a degraded-boot condition). Used by POST /admin/agents to reject an
|
|
135
127
|
* unknown requested `type` with 400 rather than silently seeding a
|
|
136
|
-
*
|
|
128
|
+
* default-type agent under a different label.
|
|
137
129
|
*/
|
|
138
130
|
tryGetManifest(specialty: string): SpecialtyManifest | undefined;
|
|
139
131
|
/**
|
|
@@ -157,8 +149,8 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
157
149
|
|
|
158
150
|
/**
|
|
159
151
|
* `typesDir` and `defaultTypeName` default to SHIPWRIGHT_SPECIALTIES_DIR /
|
|
160
|
-
* SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to
|
|
161
|
-
* and "
|
|
152
|
+
* SHIPWRIGHT_DEFAULT_AGENT_TYPE, then to the shipped agent-types/
|
|
153
|
+
* and "specialist". An injected `read`/`list` takes precedence over `typesDir`.
|
|
162
154
|
*/
|
|
163
155
|
constructor(
|
|
164
156
|
opts: {
|
|
@@ -179,33 +171,29 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
179
171
|
}
|
|
180
172
|
|
|
181
173
|
getManifest(specialty: string): SpecialtyManifest {
|
|
182
|
-
const resolved = this.
|
|
174
|
+
const resolved = this.tryLoadSafe(specialty);
|
|
183
175
|
if (resolved) return resolved;
|
|
184
176
|
|
|
185
|
-
// Unknown type — warn once and fall back to "coding". The fallback load is
|
|
186
|
-
// deliberately NOT wrapped in tryLoad: if "coding" can't load, that's a
|
|
187
|
-
// hard startup bug and must surface, not silently become a no-op.
|
|
188
177
|
this.warn(
|
|
189
178
|
`SpecialtyRegistry: unknown agent specialty "${specialty}" — ` +
|
|
190
179
|
`falling back to "${this.defaultTypeName}" manifest`,
|
|
191
180
|
);
|
|
192
|
-
if (specialty
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
try {
|
|
197
|
-
return this.loadOrThrow(this.defaultTypeName);
|
|
198
|
-
} catch (err) {
|
|
199
|
-
throw missingDefaultSpecialtyError(
|
|
200
|
-
this.defaultTypeName,
|
|
201
|
-
this.typesDir,
|
|
202
|
-
err,
|
|
203
|
-
);
|
|
181
|
+
if (specialty !== this.defaultTypeName) {
|
|
182
|
+
const fallback = this.tryLoadSafe(this.defaultTypeName);
|
|
183
|
+
if (fallback) return fallback;
|
|
204
184
|
}
|
|
185
|
+
// Even the default is unloadable (bad SHIPWRIGHT_SPECIALTIES_DIR /
|
|
186
|
+
// SHIPWRIGHT_DEFAULT_AGENT_TYPE override). Degrade to an empty manifest
|
|
187
|
+
// rather than failing boot; the warning names the knobs to fix.
|
|
188
|
+
this.warn(
|
|
189
|
+
`SpecialtyRegistry: the default specialty "${this.defaultTypeName}" could not be loaded from "${this.typesDir}" — ` +
|
|
190
|
+
"using a built-in manifest with no crons. Check SHIPWRIGHT_SPECIALTIES_DIR and SHIPWRIGHT_DEFAULT_AGENT_TYPE.",
|
|
191
|
+
);
|
|
192
|
+
return parseSpecialtyManifest(BUILT_IN_FALLBACK_MANIFEST_YAML);
|
|
205
193
|
}
|
|
206
194
|
|
|
207
195
|
/**
|
|
208
|
-
* Resolve `specialty` without falling back to
|
|
196
|
+
* Resolve `specialty` without falling back to the default type — see the interface
|
|
209
197
|
* doc comment. Thin public wrapper around the existing tryLoad() used
|
|
210
198
|
* internally by getManifest().
|
|
211
199
|
*/
|
|
@@ -227,6 +215,15 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
227
215
|
return options;
|
|
228
216
|
}
|
|
229
217
|
|
|
218
|
+
/** tryLoad, but an invalid (unparseable) manifest also yields undefined. */
|
|
219
|
+
private tryLoadSafe(specialty: string): SpecialtyManifest | undefined {
|
|
220
|
+
try {
|
|
221
|
+
return this.tryLoad(specialty);
|
|
222
|
+
} catch {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
230
227
|
/** Load + cache a manifest, returning undefined only if the file is missing. */
|
|
231
228
|
private tryLoad(specialty: string): SpecialtyManifest | undefined {
|
|
232
229
|
const cached = this.cache.get(specialty);
|
|
@@ -240,13 +237,6 @@ export class SpecialtyRegistry implements SpecialtyManifestResolver {
|
|
|
240
237
|
return this.parseAndCache(specialty, raw);
|
|
241
238
|
}
|
|
242
239
|
|
|
243
|
-
/** Load + cache a manifest, propagating any read/parse error. */
|
|
244
|
-
private loadOrThrow(specialty: string): SpecialtyManifest {
|
|
245
|
-
const cached = this.cache.get(specialty);
|
|
246
|
-
if (cached) return cached;
|
|
247
|
-
return this.parseAndCache(specialty, this.read(specialty));
|
|
248
|
-
}
|
|
249
|
-
|
|
250
240
|
private parseAndCache(specialty: string, raw: string): SpecialtyManifest {
|
|
251
241
|
const manifest = parseSpecialtyManifest(raw);
|
|
252
242
|
this.cache.set(specialty, manifest);
|