@app-vitals/agent-admin 0.13.0 → 0.13.2
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/test-server.ts +5 -5
- package/openapi.json +12 -19
- package/openapi.platform.json +11 -18
- package/package.json +2 -2
- package/prisma/client/internal/class.ts +4 -4
- package/prisma/client/internal/prismaNamespace.ts +0 -1
- package/prisma/client/internal/prismaNamespaceBrowser.ts +0 -1
- package/prisma/client/models/Agent.ts +62 -137
- package/prisma/migrations/20261030000000_drop_agent_type_name/migration.sql +8 -0
- package/prisma/schema/schema.prisma +3 -4
- package/src/account-lifecycle.integration.test.ts +1 -1
- package/src/account-trial-warning-sweeper.integration.test.ts +1 -1
- package/src/admin-local-agent.smoke.test.ts +12 -12
- package/src/admin-ui-pages.ts +2 -2
- package/src/admin-ui-pages.unit.test.ts +2 -2
- package/src/admin-ui.fixtures.ts +6 -6
- package/src/admin-ui.smoke.test.ts +39 -39
- package/src/admin-ui.ts +8 -8
- package/src/agent-cron-jobs.integration.test.ts +8 -7
- package/src/agent-cron-jobs.ts +4 -5
- package/src/agent-cron-jobs.unit.test.ts +23 -23
- package/src/agent-patch-author-allowlist.integration.test.ts +4 -4
- package/src/agent-specialty-backfill.integration.test.ts +98 -53
- package/src/agents-api.integration.test.ts +20 -20
- package/src/agents-api.smoke.test.ts +76 -77
- package/src/agents-api.ts +9 -14
- package/src/agents.integration.test.ts +40 -53
- package/src/agents.ts +33 -46
- package/src/agents.unit.test.ts +28 -35
- package/src/api.smoke.test.ts +24 -2
- package/src/api.ts +15 -16
- package/src/openapi-schemas.ts +14 -7
- package/src/slack-provisioning-service.integration.test.ts +2 -2
- package/src/slack-provisioning.integration.test.ts +2 -2
- package/src/specialty-manifest-loader.ts +32 -32
- package/src/specialty-manifest-loader.unit.test.ts +8 -8
- package/src/startup-policy-reconcile.unit.test.ts +1 -1
- package/src/trial-expiry-sweeper.integration.test.ts +1 -1
- package/src/trial-expiry-warning-sweeper.integration.test.ts +1 -1
- package/src/agent-specialty.ts +0 -22
- package/src/agent-specialty.unit.test.ts +0 -43
|
@@ -72,12 +72,13 @@ const fixtureManifest = {
|
|
|
72
72
|
|
|
73
73
|
/** Resolves only FIXTURE_TYPE; every other type is unknown. */
|
|
74
74
|
const fixtureResolver: SpecialtyManifestResolver = {
|
|
75
|
-
getManifest(
|
|
76
|
-
if (
|
|
75
|
+
getManifest(specialty: string): SpecialtyManifest {
|
|
76
|
+
if (specialty !== FIXTURE_TYPE)
|
|
77
|
+
throw new Error(`no manifest: ${specialty}`);
|
|
77
78
|
return fixtureManifest;
|
|
78
79
|
},
|
|
79
|
-
tryGetManifest(
|
|
80
|
-
return
|
|
80
|
+
tryGetManifest(specialty: string): SpecialtyManifest | undefined {
|
|
81
|
+
return specialty === FIXTURE_TYPE ? fixtureManifest : undefined;
|
|
81
82
|
},
|
|
82
83
|
listTypes() {
|
|
83
84
|
return [{ name: FIXTURE_TYPE, displayName: FIXTURE_TYPE }];
|
|
@@ -87,9 +88,9 @@ const fixtureResolver: SpecialtyManifestResolver = {
|
|
|
87
88
|
async function createAgent(
|
|
88
89
|
prisma: PrismaClient,
|
|
89
90
|
name = "Test Agent",
|
|
90
|
-
|
|
91
|
+
specialty = FIXTURE_TYPE,
|
|
91
92
|
): Promise<string> {
|
|
92
|
-
const agent = await prisma.agent.create({ data: { name,
|
|
93
|
+
const agent = await prisma.agent.create({ data: { name, specialty } });
|
|
93
94
|
return agent.id;
|
|
94
95
|
}
|
|
95
96
|
|
|
@@ -570,7 +571,7 @@ describeOrSkip("AgentCronJobService (integration)", () => {
|
|
|
570
571
|
expect(systemJobs.length).toBe(FIXTURE_CRONS.length);
|
|
571
572
|
});
|
|
572
573
|
|
|
573
|
-
it("reconcileSystemCrons() for an unknown
|
|
574
|
+
it("reconcileSystemCrons() for an unknown specialty changes nothing (no fallback)", async () => {
|
|
574
575
|
const agentId = await createAgent(prisma, "Renamed Agent", "renamed-type");
|
|
575
576
|
|
|
576
577
|
// Must not throw, and must not seed another specialty's crons.
|
package/src/agent-cron-jobs.ts
CHANGED
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
} from "./specialty-manifest-loader.ts";
|
|
18
18
|
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
19
19
|
import { type Clock, SystemClock } from "./clock.ts";
|
|
20
|
-
import { resolveSpecialty } from "./agent-specialty.ts";
|
|
21
20
|
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
22
21
|
|
|
23
22
|
/**
|
|
@@ -442,8 +441,8 @@ export class AgentCronJobService {
|
|
|
442
441
|
* Reconcile system crons for the given agent.
|
|
443
442
|
*
|
|
444
443
|
* The cron list is resolved from the agent's type manifest — the agent's
|
|
445
|
-
* stored `
|
|
446
|
-
* `tryGetManifest()` (the parsed `specialties/{
|
|
444
|
+
* stored `specialty` is looked up via the injected SpecialtyRegistry's
|
|
445
|
+
* `tryGetManifest()` (the parsed `specialties/{specialty}/manifest.yaml`),
|
|
447
446
|
* deliberately WITHOUT the "coding" fallback: a non-default type with no
|
|
448
447
|
* loadable manifest logs an error and reconciles nothing (no seeding of
|
|
449
448
|
* coding crons, no deletion of existing system crons), while a "coding"
|
|
@@ -489,12 +488,12 @@ export class AgentCronJobService {
|
|
|
489
488
|
// than leaving its crons untouched.
|
|
490
489
|
const agent = await this.prisma.agent.findUnique({
|
|
491
490
|
where: { id: agentId },
|
|
492
|
-
select: {
|
|
491
|
+
select: { specialty: true },
|
|
493
492
|
});
|
|
494
493
|
if (!agent) {
|
|
495
494
|
throw new NotFoundError(`agent ${agentId} not found`);
|
|
496
495
|
}
|
|
497
|
-
const specialty =
|
|
496
|
+
const { specialty } = agent;
|
|
498
497
|
const manifest = this.specialtyRegistry.tryGetManifest(specialty);
|
|
499
498
|
if (!manifest) {
|
|
500
499
|
const defaultName = resolveDefaultSpecialtyName();
|
|
@@ -69,15 +69,15 @@ function fakeRegistry(
|
|
|
69
69
|
const lookups: string[] = [];
|
|
70
70
|
return {
|
|
71
71
|
lookups,
|
|
72
|
-
getManifest(
|
|
73
|
-
lookups.push(
|
|
74
|
-
const crons = byType[
|
|
75
|
-
if (!crons) throw new Error(`no manifest for ${
|
|
72
|
+
getManifest(specialty: string): SpecialtyManifest {
|
|
73
|
+
lookups.push(specialty);
|
|
74
|
+
const crons = byType[specialty] ?? byType[fallbackType];
|
|
75
|
+
if (!crons) throw new Error(`no manifest for ${specialty}`);
|
|
76
76
|
return manifestWithCrons(crons);
|
|
77
77
|
},
|
|
78
|
-
tryGetManifest(
|
|
79
|
-
lookups.push(
|
|
80
|
-
const crons = byType[
|
|
78
|
+
tryGetManifest(specialty: string): SpecialtyManifest | undefined {
|
|
79
|
+
lookups.push(specialty);
|
|
80
|
+
const crons = byType[specialty];
|
|
81
81
|
return crons ? manifestWithCrons(crons) : undefined;
|
|
82
82
|
},
|
|
83
83
|
listTypes() {
|
|
@@ -90,12 +90,12 @@ type FakeCronRow = AgentCronJob;
|
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
92
|
* In-memory prisma double covering only what reconcileSystemCrons() touches:
|
|
93
|
-
* agent.findUnique (
|
|
93
|
+
* agent.findUnique (specialty resolution),
|
|
94
94
|
* agentCronJob.findMany, and a $transaction exposing
|
|
95
95
|
* agentCronJob.{create,update,delete}.
|
|
96
96
|
*/
|
|
97
97
|
function makeFakePrisma(opts: {
|
|
98
|
-
|
|
98
|
+
specialty: string;
|
|
99
99
|
existing?: Partial<FakeCronRow>[];
|
|
100
100
|
}) {
|
|
101
101
|
let nextId = 1;
|
|
@@ -193,8 +193,8 @@ function makeFakePrisma(opts: {
|
|
|
193
193
|
where,
|
|
194
194
|
}: {
|
|
195
195
|
where: { id: string };
|
|
196
|
-
}): Promise<{
|
|
197
|
-
return where.id === "agent-1" ? {
|
|
196
|
+
}): Promise<{ specialty: string } | null> {
|
|
197
|
+
return where.id === "agent-1" ? { specialty: opts.specialty } : null;
|
|
198
198
|
},
|
|
199
199
|
};
|
|
200
200
|
|
|
@@ -229,9 +229,9 @@ const childCron: ManifestCron = {
|
|
|
229
229
|
};
|
|
230
230
|
|
|
231
231
|
describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
232
|
-
test("resolves the agent's
|
|
232
|
+
test("resolves the agent's specialty via the injected registry", async () => {
|
|
233
233
|
const registry = fakeRegistry({ coding: [loopCron] });
|
|
234
|
-
const { prisma } = makeFakePrisma({
|
|
234
|
+
const { prisma } = makeFakePrisma({ specialty: "coding" });
|
|
235
235
|
const service = new AgentCronJobService(
|
|
236
236
|
prisma as unknown as PrismaClient,
|
|
237
237
|
undefined,
|
|
@@ -245,7 +245,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
245
245
|
|
|
246
246
|
test("creates one row per manifest cron on a fresh agent", async () => {
|
|
247
247
|
const registry = fakeRegistry({ coding: [loopCron, childCron] });
|
|
248
|
-
const { prisma, rows } = makeFakePrisma({
|
|
248
|
+
const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
|
|
249
249
|
const service = new AgentCronJobService(
|
|
250
250
|
prisma as unknown as PrismaClient,
|
|
251
251
|
undefined,
|
|
@@ -265,7 +265,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
265
265
|
|
|
266
266
|
test("links a child cron to its parent on first reconcile", async () => {
|
|
267
267
|
const registry = fakeRegistry({ coding: [childCron, loopCron] });
|
|
268
|
-
const { prisma, rows } = makeFakePrisma({
|
|
268
|
+
const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
|
|
269
269
|
const service = new AgentCronJobService(
|
|
270
270
|
prisma as unknown as PrismaClient,
|
|
271
271
|
undefined,
|
|
@@ -284,7 +284,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
284
284
|
test("updates a matching existing row in place, preserving its id", async () => {
|
|
285
285
|
const registry = fakeRegistry({ coding: [loopCron] });
|
|
286
286
|
const { prisma, rows } = makeFakePrisma({
|
|
287
|
-
|
|
287
|
+
specialty: "coding",
|
|
288
288
|
existing: [{ id: "stable-id", name: "shipwright-loop", enabled: false }],
|
|
289
289
|
});
|
|
290
290
|
const service = new AgentCronJobService(
|
|
@@ -303,7 +303,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
303
303
|
test("orphan pass deletes a system cron absent from the manifest", async () => {
|
|
304
304
|
const registry = fakeRegistry({ coding: [loopCron] });
|
|
305
305
|
const { prisma, rows } = makeFakePrisma({
|
|
306
|
-
|
|
306
|
+
specialty: "coding",
|
|
307
307
|
existing: [{ id: "orphan", name: "removed-cron" }],
|
|
308
308
|
});
|
|
309
309
|
const service = new AgentCronJobService(
|
|
@@ -321,7 +321,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
321
321
|
test("clears a stale parentCronId when the entry no longer declares a resolvable parent", async () => {
|
|
322
322
|
const registry = fakeRegistry({ coding: [loopCron] });
|
|
323
323
|
const { prisma, rows } = makeFakePrisma({
|
|
324
|
-
|
|
324
|
+
specialty: "coding",
|
|
325
325
|
existing: [
|
|
326
326
|
{
|
|
327
327
|
id: "loop-id",
|
|
@@ -341,10 +341,10 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
341
341
|
expect(rows.get("loop-id")?.parentCronId).toBeNull();
|
|
342
342
|
});
|
|
343
343
|
|
|
344
|
-
test("an unknown non-coding
|
|
344
|
+
test("an unknown non-coding specialty logs an error and changes nothing", async () => {
|
|
345
345
|
const registry = fakeRegistry({ coding: [loopCron] });
|
|
346
346
|
const { prisma, rows } = makeFakePrisma({
|
|
347
|
-
|
|
347
|
+
specialty: "specialist",
|
|
348
348
|
existing: [
|
|
349
349
|
{ id: "sys-1", name: "old-system", system: true },
|
|
350
350
|
{ id: "user-1", name: "mine", system: false },
|
|
@@ -377,7 +377,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
377
377
|
test("the default manifest that cannot load still throws", async () => {
|
|
378
378
|
const registry = fakeRegistry({});
|
|
379
379
|
const { prisma } = makeFakePrisma({
|
|
380
|
-
|
|
380
|
+
specialty: resolveDefaultSpecialtyName(),
|
|
381
381
|
});
|
|
382
382
|
const service = new AgentCronJobService(
|
|
383
383
|
prisma as unknown as PrismaClient,
|
|
@@ -393,7 +393,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
393
393
|
test("the default-manifest failure names the env vars that configure it", async () => {
|
|
394
394
|
const registry = fakeRegistry({});
|
|
395
395
|
const { prisma } = makeFakePrisma({
|
|
396
|
-
|
|
396
|
+
specialty: resolveDefaultSpecialtyName(),
|
|
397
397
|
});
|
|
398
398
|
const service = new AgentCronJobService(
|
|
399
399
|
prisma as unknown as PrismaClient,
|
|
@@ -408,7 +408,7 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
|
|
|
408
408
|
|
|
409
409
|
test("a valid non-coding manifest reconciles normally", async () => {
|
|
410
410
|
const registry = fakeRegistry({ specialist: [loopCron] });
|
|
411
|
-
const { prisma, rows } = makeFakePrisma({
|
|
411
|
+
const { prisma, rows } = makeFakePrisma({ specialty: "specialist" });
|
|
412
412
|
const service = new AgentCronJobService(
|
|
413
413
|
prisma as unknown as PrismaClient,
|
|
414
414
|
undefined,
|
|
@@ -87,11 +87,11 @@ const CODING_MANIFEST: SpecialtyManifest = {
|
|
|
87
87
|
function fakeSpecialtyRegistry(): SpecialtyManifestResolver {
|
|
88
88
|
const byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST };
|
|
89
89
|
return {
|
|
90
|
-
getManifest(
|
|
91
|
-
return byType[
|
|
90
|
+
getManifest(specialty: string): SpecialtyManifest {
|
|
91
|
+
return byType[specialty] ?? (byType.coding as SpecialtyManifest);
|
|
92
92
|
},
|
|
93
|
-
tryGetManifest(
|
|
94
|
-
return byType[
|
|
93
|
+
tryGetManifest(specialty: string): SpecialtyManifest | undefined {
|
|
94
|
+
return byType[specialty];
|
|
95
95
|
},
|
|
96
96
|
listTypes() {
|
|
97
97
|
return Object.entries(byType).map(([name, manifest]) => ({
|
|
@@ -1,81 +1,126 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Migration test for the
|
|
4
|
-
* (
|
|
2
|
+
* admin/src/agent-specialty-backfill.integration.test.ts
|
|
3
|
+
* Migration-ordering test for the typeName -> specialty rename
|
|
4
|
+
* (AGR-6.1 ADD, AGR-6.3 REMOVE).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* After AGR-6.3 the live test database no longer has an "Agent"."typeName"
|
|
7
|
+
* column, so this test cannot seed the pre-drop state through Prisma. Instead
|
|
8
|
+
* it replays the real migration files, in order, against a skeleton "Agent"
|
|
9
|
+
* table inside a throwaway Postgres schema:
|
|
10
|
+
*
|
|
11
|
+
* 20260724000000_add_agent_type_name adds typeName NOT NULL DEFAULT 'coding'
|
|
12
|
+
* 20261029000000_add_agent_specialty adds nullable specialty + backfill
|
|
13
|
+
* 20261030000000_drop_agent_type_name backfills stragglers, specialty NOT NULL,
|
|
14
|
+
* drops typeName
|
|
15
|
+
*
|
|
16
|
+
* Seeded from the live distinct values (admin API, 2026-10-27): coding
|
|
17
|
+
* (11 agents) and specialist (1 agent). A row written between the AGR-6.1
|
|
18
|
+
* backfill and the AGR-6.3 migration (specialty still NULL) must be
|
|
19
|
+
* backfilled from typeName by the drop migration.
|
|
9
20
|
*
|
|
10
21
|
* Requires DATABASE_URL_ADMIN_TEST; skips otherwise.
|
|
11
22
|
*/
|
|
12
23
|
|
|
13
24
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
14
25
|
import { readFileSync } from "node:fs";
|
|
15
|
-
import
|
|
16
|
-
import { createAdminPrismaClient } from "./prisma-client.ts";
|
|
26
|
+
import { Client } from "pg";
|
|
17
27
|
|
|
18
28
|
const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
|
|
19
29
|
const describeOrSkip = TEST_DB ? describe : describe.skip;
|
|
20
30
|
|
|
21
|
-
const
|
|
31
|
+
const LIVE_SPECIALTIES = ["coding", "specialist"] as const;
|
|
32
|
+
|
|
33
|
+
function migrationSql(dir: string): string {
|
|
34
|
+
return readFileSync(
|
|
35
|
+
new URL(`../prisma/migrations/${dir}/migration.sql`, import.meta.url),
|
|
36
|
+
"utf8",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ADD_TYPE_NAME = migrationSql("20260724000000_add_agent_type_name");
|
|
41
|
+
const ADD_SPECIALTY = migrationSql("20261029000000_add_agent_specialty");
|
|
42
|
+
const DROP_TYPE_NAME = migrationSql("20261030000000_drop_agent_type_name");
|
|
22
43
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
import.meta.url,
|
|
27
|
-
),
|
|
28
|
-
"utf8",
|
|
29
|
-
)
|
|
30
|
-
.split("\n")
|
|
31
|
-
.filter((l) => !l.trimStart().startsWith("--"))
|
|
32
|
-
.join("\n")
|
|
33
|
-
.split(";")
|
|
34
|
-
.map((s) => s.trim())
|
|
35
|
-
.find((s) => s.startsWith("UPDATE")) as string;
|
|
44
|
+
describeOrSkip("typeName -> specialty migrations (AGR-6.1 / AGR-6.3)", () => {
|
|
45
|
+
const schema = `agr63_${process.pid}_${Date.now()}`;
|
|
46
|
+
let client: Client;
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
const columns = async (): Promise<string[]> =>
|
|
49
|
+
(
|
|
50
|
+
await client.query<{ column_name: string }>(
|
|
51
|
+
`SELECT column_name FROM information_schema.columns
|
|
52
|
+
WHERE table_schema = $1 AND table_name = 'Agent'`,
|
|
53
|
+
[schema],
|
|
54
|
+
)
|
|
55
|
+
).rows.map((r) => r.column_name);
|
|
39
56
|
|
|
40
57
|
beforeEach(async () => {
|
|
41
|
-
|
|
42
|
-
await
|
|
43
|
-
|
|
44
|
-
});
|
|
58
|
+
client = new Client({ connectionString: TEST_DB as string });
|
|
59
|
+
await client.connect();
|
|
60
|
+
await client.query(`CREATE SCHEMA "${schema}"`);
|
|
61
|
+
await client.query(`SET search_path TO "${schema}"`);
|
|
62
|
+
await client.query(
|
|
63
|
+
`CREATE TABLE "Agent" ("id" TEXT PRIMARY KEY, "name" TEXT NOT NULL)`,
|
|
64
|
+
);
|
|
45
65
|
});
|
|
46
66
|
|
|
47
67
|
afterEach(async () => {
|
|
48
|
-
await
|
|
49
|
-
|
|
50
|
-
});
|
|
51
|
-
await prisma.$disconnect();
|
|
68
|
+
await client.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
|
|
69
|
+
await client.end();
|
|
52
70
|
});
|
|
53
71
|
|
|
54
|
-
it("
|
|
55
|
-
|
|
72
|
+
it("add_agent_specialty backfills specialty from typeName and leaves it nullable", async () => {
|
|
73
|
+
await client.query(ADD_TYPE_NAME);
|
|
74
|
+
for (const [i, s] of LIVE_SPECIALTIES.entries()) {
|
|
75
|
+
await client.query(
|
|
76
|
+
`INSERT INTO "Agent" ("id","name","typeName") VALUES ($1,$2,$3)`,
|
|
77
|
+
[`a${i}`, `agent-${i}`, s],
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
await client.query(ADD_SPECIALTY);
|
|
81
|
+
|
|
82
|
+
const { rows } = await client.query<{ specialty: string | null }>(
|
|
83
|
+
`SELECT "specialty" FROM "Agent" ORDER BY "id"`,
|
|
84
|
+
);
|
|
85
|
+
expect(rows.map((r) => r.specialty)).toEqual([...LIVE_SPECIALTIES]);
|
|
56
86
|
});
|
|
57
87
|
|
|
58
|
-
it("
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
it("drop_agent_type_name backfills NULL specialty, makes it required and drops typeName", async () => {
|
|
89
|
+
await client.query(ADD_TYPE_NAME);
|
|
90
|
+
await client.query(ADD_SPECIALTY);
|
|
91
|
+
// Rows written after the AGR-6.1 backfill: one with specialty set, one
|
|
92
|
+
// straggler with specialty NULL and a non-default typeName.
|
|
93
|
+
await client.query(
|
|
94
|
+
`INSERT INTO "Agent" ("id","name","typeName","specialty")
|
|
95
|
+
VALUES ('a0','agent-0','coding','coding')`,
|
|
96
|
+
);
|
|
97
|
+
await client.query(
|
|
98
|
+
`INSERT INTO "Agent" ("id","name","typeName") VALUES ('a1','agent-1','specialist')`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
await client.query(DROP_TYPE_NAME);
|
|
65
102
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
`${BACKFILL_SQL} WHERE "name" LIKE 'agr62a-%'`,
|
|
103
|
+
expect(await columns()).not.toContain("typeName");
|
|
104
|
+
const { rows } = await client.query<{ id: string; specialty: string }>(
|
|
105
|
+
`SELECT "id","specialty" FROM "Agent" ORDER BY "id"`,
|
|
70
106
|
);
|
|
107
|
+
expect(rows).toEqual([
|
|
108
|
+
{ id: "a0", specialty: "coding" },
|
|
109
|
+
{ id: "a1", specialty: "specialist" },
|
|
110
|
+
]);
|
|
71
111
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
112
|
+
// specialty is now NOT NULL with a 'coding' default.
|
|
113
|
+
await expect(
|
|
114
|
+
client.query(
|
|
115
|
+
`INSERT INTO "Agent" ("id","name","specialty") VALUES ('a2','agent-2',NULL)`,
|
|
116
|
+
),
|
|
117
|
+
).rejects.toThrow();
|
|
118
|
+
await client.query(
|
|
119
|
+
`INSERT INTO "Agent" ("id","name") VALUES ('a3','agent-3')`,
|
|
120
|
+
);
|
|
121
|
+
const def = await client.query<{ specialty: string }>(
|
|
122
|
+
`SELECT "specialty" FROM "Agent" WHERE "id" = 'a3'`,
|
|
123
|
+
);
|
|
124
|
+
expect(def.rows[0]?.specialty).toBe("coding");
|
|
80
125
|
});
|
|
81
126
|
});
|
|
@@ -104,18 +104,18 @@ const CODING_MANIFEST: SpecialtyManifest = {
|
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
106
|
* Fake SpecialtyManifestResolver — resolves a fixed map of manifests by
|
|
107
|
-
*
|
|
107
|
+
* specialty. tryGetManifest() returns undefined (no fallback) for anything
|
|
108
108
|
* not in the map, mirroring SpecialtyRegistry's real contract.
|
|
109
109
|
*/
|
|
110
110
|
function fakeSpecialtyRegistry(
|
|
111
111
|
byType: Record<string, SpecialtyManifest> = { coding: CODING_MANIFEST },
|
|
112
112
|
): SpecialtyManifestResolver {
|
|
113
113
|
return {
|
|
114
|
-
getManifest(
|
|
115
|
-
return byType[
|
|
114
|
+
getManifest(specialty: string): SpecialtyManifest {
|
|
115
|
+
return byType[specialty] ?? (byType.coding as SpecialtyManifest);
|
|
116
116
|
},
|
|
117
|
-
tryGetManifest(
|
|
118
|
-
return byType[
|
|
117
|
+
tryGetManifest(specialty: string): SpecialtyManifest | undefined {
|
|
118
|
+
return byType[specialty];
|
|
119
119
|
},
|
|
120
120
|
listTypes() {
|
|
121
121
|
return Object.entries(byType).map(([name, manifest]) => ({
|
|
@@ -309,9 +309,9 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
309
309
|
expect(listedToken.id).toBe(dbRecord?.id);
|
|
310
310
|
});
|
|
311
311
|
|
|
312
|
-
// ───
|
|
312
|
+
// ─── specialty field ─────────────────────────────────────────────────────────
|
|
313
313
|
|
|
314
|
-
it("GET /agents includes
|
|
314
|
+
it("GET /agents includes specialty:'coding' for an agent created before specialty existed", async () => {
|
|
315
315
|
const res = await app.request("/agents", {
|
|
316
316
|
headers: { Cookie: `admin_session=${cookie}` },
|
|
317
317
|
});
|
|
@@ -319,30 +319,30 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
319
319
|
const body = await res.json();
|
|
320
320
|
const entry = body.find((a: { id: string }) => a.id === agentId);
|
|
321
321
|
expect(entry).toBeDefined();
|
|
322
|
-
expect(entry.
|
|
322
|
+
expect(entry.specialty).toBe("coding");
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
-
it("GET /agents/:id includes
|
|
325
|
+
it("GET /agents/:id includes specialty:'coding' by default", async () => {
|
|
326
326
|
const res = await app.request(`/agents/${agentId}`, {
|
|
327
327
|
headers: { Cookie: `admin_session=${cookie}` },
|
|
328
328
|
});
|
|
329
329
|
expect(res.status).toBe(200);
|
|
330
330
|
const body = await res.json();
|
|
331
|
-
expect(body.
|
|
331
|
+
expect(body.specialty).toBe("coding");
|
|
332
332
|
});
|
|
333
333
|
|
|
334
334
|
// ─── Migration backfill read-back (pre-existing rows + child rows) ────────────
|
|
335
335
|
//
|
|
336
336
|
// Simulates the exact scenario the 20260724000000_add_agent_type_name
|
|
337
|
-
// migration must handle: rows that existed *before* the
|
|
338
|
-
// added (never received an explicit value) must read back
|
|
337
|
+
// migration must handle: rows that existed *before* the specialty column was
|
|
338
|
+
// added (never received an explicit value) must read back specialty="coding"
|
|
339
339
|
// via the column DEFAULT, and every child row (cron/plugin/tool/env/member)
|
|
340
340
|
// must be completely untouched — same count, same updatedAt — since the
|
|
341
341
|
// migration only ALTERs the Agent table.
|
|
342
342
|
|
|
343
|
-
it("migration backfill: pre-existing agent rows read back
|
|
343
|
+
it("migration backfill: pre-existing agent rows read back specialty='coding' and child rows (cron/plugin/tool/env/member) are untouched", async () => {
|
|
344
344
|
// Seed a second "pre-existing" agent with one row in every child table,
|
|
345
|
-
// simulating data that existed prior to the
|
|
345
|
+
// simulating data that existed prior to the specialty migration landing.
|
|
346
346
|
const preExisting = await prisma.agent.create({
|
|
347
347
|
data: { name: "Pre-Existing Agent" },
|
|
348
348
|
});
|
|
@@ -379,11 +379,11 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
379
379
|
|
|
380
380
|
// Read back via the raw Agent row (bypasses the API layer entirely) —
|
|
381
381
|
// this is the direct DB-level assertion that the column DEFAULT applies
|
|
382
|
-
// to a row that never set
|
|
382
|
+
// to a row that never set specialty explicitly.
|
|
383
383
|
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
384
384
|
where: { id: preExisting.id },
|
|
385
385
|
});
|
|
386
|
-
expect(rawAgent.
|
|
386
|
+
expect(rawAgent.specialty).toBe("coding");
|
|
387
387
|
|
|
388
388
|
// Read back via the HTTP API too — end-to-end confirmation.
|
|
389
389
|
const res = await app.request(`/agents/${preExisting.id}`, {
|
|
@@ -391,7 +391,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
391
391
|
});
|
|
392
392
|
expect(res.status).toBe(200);
|
|
393
393
|
const body = await res.json();
|
|
394
|
-
expect(body.
|
|
394
|
+
expect(body.specialty).toBe("coding");
|
|
395
395
|
|
|
396
396
|
// Child row counts are unchanged — exactly one of each, still linked to
|
|
397
397
|
// the same agent.
|
|
@@ -411,7 +411,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
411
411
|
await prisma.agentMember.count({ where: { agentId: preExisting.id } }),
|
|
412
412
|
).toBe(1);
|
|
413
413
|
|
|
414
|
-
// Child row updatedAt values are unchanged — the
|
|
414
|
+
// Child row updatedAt values are unchanged — the specialty column only
|
|
415
415
|
// touches the Agent table, so nothing here should have been rewritten.
|
|
416
416
|
const cronAfter = await prisma.agentCronJob.findUniqueOrThrow({
|
|
417
417
|
where: { id: cron.id },
|
|
@@ -438,7 +438,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
438
438
|
expect(memberAfter.createdAt.getTime()).toBe(member.createdAt.getTime());
|
|
439
439
|
});
|
|
440
440
|
|
|
441
|
-
it("prisma migrate deploy is idempotent — running it twice does not error or duplicate the
|
|
441
|
+
it("prisma migrate deploy is idempotent — running it twice does not error or duplicate the specialty column", async () => {
|
|
442
442
|
// The migration has already been applied once (this test DB was
|
|
443
443
|
// provisioned via `prisma migrate deploy` before the suite ran). Re-run
|
|
444
444
|
// deploy here and confirm the column is still readable with a single,
|
|
@@ -455,7 +455,7 @@ describeOrSkip("admin CRUD API (integration)", () => {
|
|
|
455
455
|
const rawAgent = await prisma.agent.findUniqueOrThrow({
|
|
456
456
|
where: { id: agentId },
|
|
457
457
|
});
|
|
458
|
-
expect(rawAgent.
|
|
458
|
+
expect(rawAgent.specialty).toBe("coding");
|
|
459
459
|
});
|
|
460
460
|
|
|
461
461
|
// ─── repos field round-trip ───────────────────────────────────────────────────
|