@app-vitals/agent-admin 0.2.1 → 0.4.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.
Files changed (41) hide show
  1. package/openapi.json +291 -0
  2. package/openapi.platform.json +392 -0
  3. package/package.json +2 -2
  4. package/prisma/client/browser.ts +15 -0
  5. package/prisma/client/client.ts +15 -0
  6. package/prisma/client/internal/class.ts +34 -4
  7. package/prisma/client/internal/prismaNamespace.ts +273 -1
  8. package/prisma/client/internal/prismaNamespaceBrowser.ts +47 -0
  9. package/prisma/client/models/AgentCronJob.ts +107 -1
  10. package/prisma/client/models/AgentCronRunRequest.ts +1233 -0
  11. package/prisma/client/models/AgentCronRunTranscript.ts +1339 -0
  12. package/prisma/client/models/AgentTranscriptRead.ts +1177 -0
  13. package/prisma/client/models.ts +3 -0
  14. package/prisma/migrations/20261027000000_add_cron_run_transcripts/migration.sql +37 -0
  15. package/prisma/migrations/20261028000000_add_cron_run_now/migration.sql +24 -0
  16. package/prisma/schema/schema.prisma +58 -0
  17. package/src/admin-boot.ts +18 -0
  18. package/src/admin-ui.fixtures.ts +2 -0
  19. package/src/admin-ui.smoke.test.ts +2 -0
  20. package/src/agent-cron-jobs.unit.test.ts +4 -0
  21. package/src/agent-cron-run-stats.smoke.test.ts +10 -0
  22. package/src/agent-cron-runs.integration.test.ts +93 -0
  23. package/src/agent-cron-runs.ts +47 -15
  24. package/src/agents-api.smoke.test.ts +18 -0
  25. package/src/agents-api.ts +30 -0
  26. package/src/api-auth.ts +1 -0
  27. package/src/api.smoke.test.ts +39 -0
  28. package/src/api.ts +15 -0
  29. package/src/chat.smoke.test.ts +2 -0
  30. package/src/cron-run-now-config.ts +20 -0
  31. package/src/cron-run-now-config.unit.test.ts +26 -0
  32. package/src/cron-run-now.smoke.test.ts +218 -0
  33. package/src/cron-run-now.ts +207 -0
  34. package/src/cron-run-transcripts.integration.test.ts +220 -0
  35. package/src/cron-run-transcripts.smoke.test.ts +286 -0
  36. package/src/cron-run-transcripts.ts +314 -0
  37. package/src/errors.ts +7 -0
  38. package/src/openapi-schemas.ts +78 -0
  39. package/src/openapi-schemas.unit.test.ts +4 -0
  40. package/src/transcript-config.ts +19 -0
  41. package/src/transcript-config.unit.test.ts +32 -0
@@ -0,0 +1,220 @@
1
+ /**
2
+ * admin/src/cron-run-transcripts.integration.test.ts
3
+ * Round-trip and retention sweep against a real Postgres (COS-43.1).
4
+ *
5
+ * Requires DATABASE_URL_ADMIN_TEST to be set; skips otherwise.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import type { PrismaClient } from "../prisma/client/client.ts";
10
+ import { AgentService } from "./agents.ts";
11
+ import {
12
+ type AdminDeps,
13
+ createAdminApp,
14
+ parseAdminApiKeys,
15
+ } from "./agents-api.ts";
16
+ import {
17
+ CronRunTranscriptService,
18
+ TRANSCRIPT_RETENTION_PER_AGENT,
19
+ } from "./cron-run-transcripts.ts";
20
+ import { createAdminPrismaClient } from "./prisma-client.ts";
21
+ import { parseTranscriptConfig } from "./transcript-config.ts";
22
+
23
+ const TEST_DB = process.env.DATABASE_URL_ADMIN_TEST;
24
+ const describeOrSkip = TEST_DB ? describe : describe.skip;
25
+
26
+ const ADMIN = "admin-key";
27
+
28
+ describeOrSkip("cron-run transcripts (integration)", () => {
29
+ let prisma: PrismaClient;
30
+ let agentId: string;
31
+ let otherAgentId: string;
32
+ let cronId: string;
33
+ let otherCronId: string;
34
+
35
+ beforeEach(async () => {
36
+ prisma = createAdminPrismaClient(TEST_DB as string);
37
+ await prisma.agentTranscriptRead.deleteMany();
38
+ await prisma.agentCronRunTranscript.deleteMany();
39
+ agentId = (await prisma.agent.create({ data: { name: "TxA" } })).id;
40
+ otherAgentId = (await prisma.agent.create({ data: { name: "TxB" } })).id;
41
+ cronId = (
42
+ await prisma.agentCronJob.create({
43
+ data: { agentId, name: "c", schedule: "* * * * *", prompt: "p" },
44
+ })
45
+ ).id;
46
+ otherCronId = (
47
+ await prisma.agentCronJob.create({
48
+ data: {
49
+ agentId: otherAgentId,
50
+ name: "c",
51
+ schedule: "* * * * *",
52
+ prompt: "p",
53
+ },
54
+ })
55
+ ).id;
56
+ });
57
+
58
+ afterEach(async () => {
59
+ await prisma.agentTranscriptRead.deleteMany();
60
+ await prisma.agentCronRunTranscript.deleteMany();
61
+ await prisma.agent.deleteMany({
62
+ where: { id: { in: [agentId, otherAgentId] } },
63
+ });
64
+ await prisma.$disconnect();
65
+ });
66
+
67
+ function makeApp() {
68
+ const deps = {
69
+ agentService: new AgentService(prisma),
70
+ agentTokenService: { validate: async () => null },
71
+ adminApiKeys: parseAdminApiKeys(`root:${ADMIN}:*`),
72
+ sessionSecret: "s".repeat(32),
73
+ prisma,
74
+ transcripts: {
75
+ config: parseTranscriptConfig({
76
+ SHIPWRIGHT_TRANSCRIPT_API_ENABLED: "enabled",
77
+ }),
78
+ store: new CronRunTranscriptService(prisma),
79
+ },
80
+ } as unknown as AdminDeps;
81
+ return createAdminApp(deps);
82
+ }
83
+
84
+ const newRun = (agent: string, cron: string, startedAt = new Date()) =>
85
+ prisma.agentCronRun.create({
86
+ data: { agentId: agent, cronId: cron, startedAt },
87
+ });
88
+
89
+ const put = (
90
+ app: ReturnType<typeof createAdminApp>,
91
+ runId: string,
92
+ body: string,
93
+ cron = cronId,
94
+ agent = agentId,
95
+ ) =>
96
+ app.request(`/agents/${agent}/crons/${cron}/runs/${runId}/transcript`, {
97
+ method: "PUT",
98
+ headers: {
99
+ "Content-Type": "application/json",
100
+ Authorization: `Bearer ${ADMIN}`,
101
+ },
102
+ body: JSON.stringify({
103
+ sessionId: "s",
104
+ body,
105
+ truncated: false,
106
+ redactions: 1,
107
+ }),
108
+ });
109
+
110
+ it("round-trips PUT then GET and writes an audit row", async () => {
111
+ const app = makeApp();
112
+ const run = await newRun(agentId, cronId);
113
+ expect((await put(app, run.id, "hello\n")).status).toBe(200);
114
+ const res = await app.request(
115
+ `/agents/${agentId}/crons/${cronId}/runs/${run.id}/transcript`,
116
+ { headers: { Authorization: `Bearer ${ADMIN}` } },
117
+ );
118
+ expect(res.status).toBe(200);
119
+ const json = (await res.json()) as Record<string, unknown>;
120
+ expect(json).toMatchObject({
121
+ runId: run.id,
122
+ cronId,
123
+ sessionId: "s",
124
+ bytes: 6,
125
+ truncated: false,
126
+ redactions: 1,
127
+ body: "hello\n",
128
+ });
129
+ const reads = await prisma.agentTranscriptRead.findMany();
130
+ expect(reads).toHaveLength(1);
131
+ expect(reads[0]).toMatchObject({
132
+ runId: run.id,
133
+ agentId,
134
+ scopeKind: "admin",
135
+ });
136
+ });
137
+
138
+ it("upserts: a second PUT replaces the body, one row per run", async () => {
139
+ const app = makeApp();
140
+ const run = await newRun(agentId, cronId);
141
+ await put(app, run.id, "one");
142
+ await put(app, run.id, "two-two");
143
+ const rows = await prisma.agentCronRunTranscript.findMany();
144
+ expect(rows).toHaveLength(1);
145
+ expect(rows[0]?.body).toBe("two-two");
146
+ expect(rows[0]?.bytes).toBe(7);
147
+ });
148
+
149
+ it("404s a run owned by a different agent or cron", async () => {
150
+ const app = makeApp();
151
+ const run = await newRun(otherAgentId, otherCronId);
152
+ expect((await put(app, run.id, "x")).status).toBe(404);
153
+ const mine = await newRun(agentId, cronId);
154
+ expect((await put(app, mine.id, "x", otherCronId)).status).toBe(404);
155
+ expect(await prisma.agentCronRunTranscript.count()).toBe(0);
156
+ });
157
+
158
+ it(`keeps only the newest ${TRANSCRIPT_RETENTION_PER_AGENT} per agent`, async () => {
159
+ const service = new CronRunTranscriptService(prisma);
160
+ const total = TRANSCRIPT_RETENTION_PER_AGENT + 5;
161
+ const base = Date.now() - total * 1000;
162
+ const runs: Array<{ id: string }> = [];
163
+ for (let i = 0; i < total; i++) {
164
+ runs.push(await newRun(agentId, cronId, new Date(base + i)));
165
+ }
166
+ for (const [i, run] of runs.entries()) {
167
+ await prisma.agentCronRunTranscript.create({
168
+ data: {
169
+ runId: run.id,
170
+ agentId,
171
+ cronId,
172
+ bytes: 1,
173
+ truncated: false,
174
+ redactions: 0,
175
+ body: "x",
176
+ createdAt: new Date(base + i * 10),
177
+ },
178
+ });
179
+ }
180
+ const otherRun = await newRun(otherAgentId, otherCronId);
181
+ await prisma.agentCronRunTranscript.create({
182
+ data: {
183
+ runId: otherRun.id,
184
+ agentId: otherAgentId,
185
+ cronId: otherCronId,
186
+ bytes: 1,
187
+ truncated: false,
188
+ redactions: 0,
189
+ body: "x",
190
+ createdAt: new Date(base),
191
+ },
192
+ });
193
+
194
+ const fresh = await newRun(agentId, cronId);
195
+ await service.upsert({
196
+ runId: fresh.id,
197
+ agentId,
198
+ cronId,
199
+ sessionId: null,
200
+ bytes: 1,
201
+ truncated: false,
202
+ redactions: 0,
203
+ body: "new",
204
+ });
205
+
206
+ const mine = await prisma.agentCronRunTranscript.findMany({
207
+ where: { agentId },
208
+ orderBy: { createdAt: "desc" },
209
+ });
210
+ expect(mine).toHaveLength(TRANSCRIPT_RETENTION_PER_AGENT);
211
+ expect(mine[0]?.runId).toBe(fresh.id);
212
+ // The oldest were dropped; the other agent's row is untouched.
213
+ expect(mine.some((r) => r.runId === runs[0]?.id)).toBe(false);
214
+ expect(
215
+ await prisma.agentCronRunTranscript.count({
216
+ where: { agentId: otherAgentId },
217
+ }),
218
+ ).toBe(1);
219
+ });
220
+ });
@@ -0,0 +1,286 @@
1
+ /**
2
+ * admin/src/cron-run-transcripts.smoke.test.ts
3
+ *
4
+ * PUT/GET /agents/{id}/crons/{cronId}/runs/{runId}/transcript (COS-43.1) over
5
+ * the real admin route table and auth middleware with an in-memory store.
6
+ * Uses app.request() — no real server, no real DB.
7
+ */
8
+
9
+ import { describe, expect, it, spyOn } from "bun:test";
10
+ import {
11
+ type AdminDeps,
12
+ createAdminApp,
13
+ parseAdminApiKeys,
14
+ } from "./agents-api.ts";
15
+ import {
16
+ MAX_TRANSCRIPT_BODY_BYTES,
17
+ type TranscriptReadRow,
18
+ type TranscriptRow,
19
+ type TranscriptStore,
20
+ } from "./cron-run-transcripts.ts";
21
+ import { NotFoundError } from "./errors.ts";
22
+ import { parseTranscriptConfig } from "./transcript-config.ts";
23
+
24
+ const AGENT = "agent-1";
25
+ const CRON = "cron-1";
26
+ const RUN = "run-1";
27
+ const ADMIN = "admin-key";
28
+ const READ = "platform-read-token";
29
+ const OPS = "platform-ops-token";
30
+ const AGENT_KEY = "agent-1-key";
31
+ const OTHER_KEY = "agent-2-key";
32
+ const SECRET_BODY = "TRANSCRIPT-BODY-MUST-NOT-BE-LOGGED";
33
+
34
+ function memoryStore() {
35
+ const rows = new Map<string, TranscriptRow>();
36
+ const reads: TranscriptReadRow[] = [];
37
+ const order: string[] = [];
38
+ const store: TranscriptStore = {
39
+ async upsert(input) {
40
+ if (
41
+ input.agentId !== AGENT ||
42
+ input.cronId !== CRON ||
43
+ input.runId !== RUN
44
+ ) {
45
+ throw new NotFoundError(`cron run ${input.runId} not found`);
46
+ }
47
+ const row: TranscriptRow = { ...input, createdAt: new Date(0) };
48
+ rows.set(input.runId, row);
49
+ order.push("upsert");
50
+ return row;
51
+ },
52
+ async get(agentId, cronId, runId) {
53
+ const row = rows.get(runId);
54
+ return row && row.agentId === agentId && row.cronId === cronId
55
+ ? row
56
+ : null;
57
+ },
58
+ async recordRead(row) {
59
+ order.push("recordRead");
60
+ reads.push({ ...row, at: new Date(0) });
61
+ },
62
+ };
63
+ return { store, rows, reads, order };
64
+ }
65
+
66
+ function makeApp(enabled = true, mem = memoryStore()) {
67
+ const deps = {
68
+ agentService: { exists: async () => true },
69
+ agentTokenService: { validate: async () => null },
70
+ agentMemberService: { listByEmail: async () => [] },
71
+ adminApiKeys: parseAdminApiKeys(
72
+ `root:${ADMIN}:*,reader:${READ}:platform.read,operator:${OPS}:platform.ops,a1:${AGENT_KEY}:${AGENT},a2:${OTHER_KEY}:agent-2`,
73
+ ),
74
+ sessionSecret: "s".repeat(32),
75
+ transcripts: {
76
+ config: parseTranscriptConfig({
77
+ SHIPWRIGHT_TRANSCRIPT_API_ENABLED: enabled ? "enabled" : undefined,
78
+ }),
79
+ store: mem.store,
80
+ },
81
+ } as unknown as AdminDeps;
82
+ return { app: createAdminApp(deps), ...mem };
83
+ }
84
+
85
+ const path = (agent = AGENT, cron = CRON, run = RUN) =>
86
+ `/agents/${agent}/crons/${cron}/runs/${run}/transcript`;
87
+
88
+ function put(
89
+ app: ReturnType<typeof createAdminApp>,
90
+ token: string | null,
91
+ body: Record<string, unknown> = {},
92
+ p = path(),
93
+ ) {
94
+ return app.request(p, {
95
+ method: "PUT",
96
+ body: JSON.stringify({
97
+ sessionId: "sess-1",
98
+ body: SECRET_BODY,
99
+ truncated: false,
100
+ redactions: 2,
101
+ ...body,
102
+ }),
103
+ headers: {
104
+ "Content-Type": "application/json",
105
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
106
+ },
107
+ });
108
+ }
109
+
110
+ function get(
111
+ app: ReturnType<typeof createAdminApp>,
112
+ token: string | null,
113
+ p = path(),
114
+ ) {
115
+ return app.request(p, {
116
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
117
+ });
118
+ }
119
+
120
+ describe("transcript routes — flag", () => {
121
+ it("404s PUT and GET when the flag is off, for every caller", async () => {
122
+ const { app } = makeApp(false);
123
+ expect((await put(app, ADMIN)).status).toBe(404);
124
+ expect((await put(app, AGENT_KEY)).status).toBe(404);
125
+ expect((await get(app, ADMIN)).status).toBe(404);
126
+ expect((await get(app, READ)).status).toBe(404);
127
+ });
128
+
129
+ it("404s when no transcript deps are wired at all", async () => {
130
+ const deps = {
131
+ agentService: { exists: async () => true },
132
+ agentTokenService: { validate: async () => null },
133
+ adminApiKeys: parseAdminApiKeys(`root:${ADMIN}:*`),
134
+ sessionSecret: "s".repeat(32),
135
+ } as unknown as AdminDeps;
136
+ const app = createAdminApp(deps);
137
+ expect((await get(app, ADMIN)).status).toBe(404);
138
+ });
139
+ });
140
+
141
+ describe("PUT transcript — auth matrix", () => {
142
+ it("accepts the agent's own key and the admin key", async () => {
143
+ const { app, rows } = makeApp();
144
+ expect((await put(app, AGENT_KEY)).status).toBe(200);
145
+ expect((await put(app, ADMIN)).status).toBe(200);
146
+ expect(rows.get(RUN)?.redactions).toBe(2);
147
+ });
148
+
149
+ it("rejects another agent's key, platform keys and anonymous callers", async () => {
150
+ const { app } = makeApp();
151
+ expect((await put(app, OTHER_KEY)).status).toBe(403);
152
+ expect((await put(app, READ)).status).toBe(403);
153
+ expect((await put(app, OPS)).status).toBe(403);
154
+ expect((await put(app, null)).status).toBe(401);
155
+ });
156
+
157
+ it("404s when the run is not owned by the cron/agent", async () => {
158
+ const { app } = makeApp();
159
+ expect((await put(app, ADMIN, {}, path(AGENT, "other-cron"))).status).toBe(
160
+ 404,
161
+ );
162
+ });
163
+
164
+ it("returns the stored size and flags", async () => {
165
+ const { app } = makeApp();
166
+ const res = await put(app, AGENT_KEY, { truncated: true });
167
+ expect(await res.json()).toEqual({
168
+ runId: RUN,
169
+ bytes: Buffer.byteLength(SECRET_BODY),
170
+ truncated: true,
171
+ redactions: 2,
172
+ });
173
+ });
174
+ });
175
+
176
+ describe("PUT transcript — size cap", () => {
177
+ it("accepts exactly 1 MiB and 413s one byte over", async () => {
178
+ const { app } = makeApp();
179
+ const ok = await put(app, AGENT_KEY, {
180
+ body: "a".repeat(MAX_TRANSCRIPT_BODY_BYTES),
181
+ });
182
+ expect(ok.status).toBe(200);
183
+ const big = await put(app, AGENT_KEY, {
184
+ body: "a".repeat(MAX_TRANSCRIPT_BODY_BYTES + 1),
185
+ });
186
+ expect(big.status).toBe(413);
187
+ });
188
+
189
+ it("counts bytes, not characters", async () => {
190
+ const { app } = makeApp();
191
+ const res = await put(app, AGENT_KEY, {
192
+ body: "é".repeat(MAX_TRANSCRIPT_BODY_BYTES / 2 + 1),
193
+ });
194
+ expect(res.status).toBe(413);
195
+ });
196
+
197
+ it("400s a malformed body", async () => {
198
+ const { app } = makeApp();
199
+ expect((await put(app, AGENT_KEY, { body: 5 })).status).toBe(400);
200
+ });
201
+ });
202
+
203
+ describe("GET transcript — auth matrix and audit", () => {
204
+ it("403s every agent key, even for its own run", async () => {
205
+ const mem = memoryStore();
206
+ const { app, reads } = makeApp(true, mem);
207
+ await put(app, ADMIN);
208
+ expect((await get(app, AGENT_KEY)).status).toBe(403);
209
+ expect((await get(app, OTHER_KEY)).status).toBe(403);
210
+ expect(reads).toHaveLength(0);
211
+ });
212
+
213
+ it("401s anonymous callers", async () => {
214
+ const { app } = makeApp();
215
+ expect((await get(app, null)).status).toBe(401);
216
+ });
217
+
218
+ it("serves admin, platform.read and platform.ops and audits each read first", async () => {
219
+ const mem = memoryStore();
220
+ const { app, reads, order } = makeApp(true, mem);
221
+ await put(app, AGENT_KEY);
222
+ order.length = 0;
223
+ for (const [token, kind, reader] of [
224
+ [ADMIN, "admin", "root"],
225
+ [READ, "platform-read", "reader"],
226
+ [OPS, "platform-ops", "operator"],
227
+ ] as const) {
228
+ const res = await get(app, token);
229
+ expect(res.status).toBe(200);
230
+ expect(await res.json()).toEqual({
231
+ runId: RUN,
232
+ cronId: CRON,
233
+ sessionId: "sess-1",
234
+ bytes: Buffer.byteLength(SECRET_BODY),
235
+ truncated: false,
236
+ redactions: 2,
237
+ createdAt: new Date(0).toISOString(),
238
+ body: SECRET_BODY,
239
+ });
240
+ expect(reads.at(-1)).toMatchObject({
241
+ runId: RUN,
242
+ agentId: AGENT,
243
+ scopeKind: kind,
244
+ reader,
245
+ });
246
+ }
247
+ expect(order).toEqual(["recordRead", "recordRead", "recordRead"]);
248
+ expect(reads).toHaveLength(3);
249
+ });
250
+
251
+ it("404s an absent transcript and writes no audit row", async () => {
252
+ const { app, reads } = makeApp();
253
+ expect((await get(app, ADMIN)).status).toBe(404);
254
+ expect(reads).toHaveLength(0);
255
+ });
256
+
257
+ it("does not respond when the audit write fails", async () => {
258
+ const mem = memoryStore();
259
+ const { app } = makeApp(true, mem);
260
+ await put(app, ADMIN);
261
+ mem.store.recordRead = async () => {
262
+ throw new Error("db down");
263
+ };
264
+ const errSpy = spyOn(console, "error").mockImplementation(() => {});
265
+ const res = await get(app, ADMIN);
266
+ errSpy.mockRestore();
267
+ expect(res.status).toBe(500);
268
+ expect(await res.text()).not.toContain(SECRET_BODY);
269
+ });
270
+ });
271
+
272
+ describe("transcript routes — logging", () => {
273
+ it("never logs the transcript body", async () => {
274
+ const lines: string[] = [];
275
+ const sinks = (["log", "warn", "error", "info"] as const).map((m) =>
276
+ spyOn(console, m).mockImplementation((...a: unknown[]) => {
277
+ lines.push(a.map(String).join(" "));
278
+ }),
279
+ );
280
+ const { app } = makeApp();
281
+ await put(app, AGENT_KEY);
282
+ await get(app, ADMIN);
283
+ for (const s of sinks) s.mockRestore();
284
+ expect(lines.join("\n")).not.toContain(SECRET_BODY);
285
+ });
286
+ });