@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,314 @@
1
+ /**
2
+ * admin/src/cron-run-transcripts.ts
3
+ *
4
+ * Redacted cron-run transcripts (COS-43.1):
5
+ * PUT /agents/{id}/crons/{cronId}/runs/{runId}/transcript (agent key or admin)
6
+ * GET /agents/{id}/crons/{cronId}/runs/{runId}/transcript (admin / platform only)
7
+ *
8
+ * The runtime redacts and tail-truncates before uploading; admin only caps the
9
+ * size (1 MiB, 413) and stores. Gates: flag (SHIPWRIGHT_TRANSCRIPT_API_ENABLED,
10
+ * 404 when off) -> caller. GET is closed to agent keys, even for their own
11
+ * runs, and writes an AgentTranscriptRead audit row BEFORE responding. The
12
+ * body is never logged. Retention: PUT keeps the newest 200 transcripts per
13
+ * agent.
14
+ */
15
+
16
+ import { createRoute, type OpenAPIHono } from "@hono/zod-openapi";
17
+ import type { PrismaClient } from "../prisma/client/client.ts";
18
+ import type { AdminAuthEnv, AdminScope } from "./api-auth.ts";
19
+ import {
20
+ ForbiddenError,
21
+ NotFoundError,
22
+ PayloadTooLargeError,
23
+ } from "./errors.ts";
24
+ import {
25
+ CronRunIdParamSchema,
26
+ CronRunTranscriptSchema,
27
+ CronRunTranscriptUploadResultSchema,
28
+ ErrorSchema,
29
+ UploadCronRunTranscriptBodySchema,
30
+ } from "./openapi-schemas.ts";
31
+ import type { TranscriptConfig } from "./transcript-config.ts";
32
+
33
+ /** Largest accepted transcript body, in UTF-8 bytes. */
34
+ export const MAX_TRANSCRIPT_BODY_BYTES = 1024 * 1024;
35
+
36
+ /** Transcripts kept per agent; older ones are deleted on each upload. */
37
+ export const TRANSCRIPT_RETENTION_PER_AGENT = 200;
38
+
39
+ export interface TranscriptRow {
40
+ runId: string;
41
+ agentId: string;
42
+ cronId: string;
43
+ sessionId: string | null;
44
+ bytes: number;
45
+ truncated: boolean;
46
+ redactions: number;
47
+ body: string;
48
+ createdAt: Date;
49
+ }
50
+
51
+ export interface TranscriptReadRow {
52
+ runId: string;
53
+ agentId: string;
54
+ reader: string;
55
+ scopeKind: string;
56
+ at: Date;
57
+ }
58
+
59
+ export type TranscriptUpsertInput = Omit<TranscriptRow, "createdAt">;
60
+
61
+ export interface TranscriptStore {
62
+ /**
63
+ * Validates run -> cron -> agent ownership (NotFoundError otherwise), upserts
64
+ * by runId, then sweeps the agent down to the newest 200 transcripts.
65
+ */
66
+ upsert(input: TranscriptUpsertInput): Promise<TranscriptRow>;
67
+ get(
68
+ agentId: string,
69
+ cronId: string,
70
+ runId: string,
71
+ ): Promise<TranscriptRow | null>;
72
+ recordRead(row: Omit<TranscriptReadRow, "at">): Promise<void>;
73
+ }
74
+
75
+ /** Prisma-backed transcript store. */
76
+ export class CronRunTranscriptService implements TranscriptStore {
77
+ constructor(
78
+ private prisma: Pick<
79
+ PrismaClient,
80
+ "agentCronRun" | "agentCronRunTranscript" | "agentTranscriptRead"
81
+ >,
82
+ ) {}
83
+
84
+ async upsert(input: TranscriptUpsertInput): Promise<TranscriptRow> {
85
+ // Same ownership rule as AgentCronRunService.patch.
86
+ const run = await this.prisma.agentCronRun.findUnique({
87
+ where: { id: input.runId },
88
+ });
89
+ if (!run || run.agentId !== input.agentId || run.cronId !== input.cronId) {
90
+ throw new NotFoundError(`cron run ${input.runId} not found`);
91
+ }
92
+ const fields = {
93
+ agentId: input.agentId,
94
+ cronId: input.cronId,
95
+ sessionId: input.sessionId,
96
+ bytes: input.bytes,
97
+ truncated: input.truncated,
98
+ redactions: input.redactions,
99
+ body: input.body,
100
+ };
101
+ const row = await this.prisma.agentCronRunTranscript.upsert({
102
+ where: { runId: input.runId },
103
+ create: { runId: input.runId, ...fields },
104
+ update: { ...fields, createdAt: new Date() },
105
+ });
106
+ await this.sweep(input.agentId);
107
+ return row;
108
+ }
109
+
110
+ /** Deletes every transcript of the agent beyond the newest 200. */
111
+ private async sweep(agentId: string): Promise<void> {
112
+ const stale = await this.prisma.agentCronRunTranscript.findMany({
113
+ where: { agentId },
114
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
115
+ skip: TRANSCRIPT_RETENTION_PER_AGENT,
116
+ select: { id: true },
117
+ });
118
+ if (stale.length === 0) return;
119
+ await this.prisma.agentCronRunTranscript.deleteMany({
120
+ where: { id: { in: stale.map((r) => r.id) } },
121
+ });
122
+ }
123
+
124
+ async get(
125
+ agentId: string,
126
+ cronId: string,
127
+ runId: string,
128
+ ): Promise<TranscriptRow | null> {
129
+ const row = await this.prisma.agentCronRunTranscript.findUnique({
130
+ where: { runId },
131
+ });
132
+ if (!row || row.agentId !== agentId || row.cronId !== cronId) return null;
133
+ return row;
134
+ }
135
+
136
+ async recordRead(row: Omit<TranscriptReadRow, "at">): Promise<void> {
137
+ await this.prisma.agentTranscriptRead.create({ data: row });
138
+ }
139
+ }
140
+
141
+ // ─── Deps ─────────────────────────────────────────────────────────────────────
142
+
143
+ export interface CronRunTranscriptDeps {
144
+ config: TranscriptConfig;
145
+ store: TranscriptStore;
146
+ }
147
+
148
+ // ─── Routes ───────────────────────────────────────────────────────────────────
149
+
150
+ const jsonError = {
151
+ content: { "application/json": { schema: ErrorSchema } },
152
+ };
153
+
154
+ const putTranscriptRoute = createRoute({
155
+ method: "put",
156
+ path: "/agents/{id}/crons/{cronId}/runs/{runId}/transcript",
157
+ summary: "Upload a cron run transcript",
158
+ description:
159
+ "Stores (upserts) the runtime's already-redacted session transcript for a cron run, then keeps only the agent's newest 200 transcripts. Callers: the agent's own key, or `*`. `body` over 1 MiB (UTF-8 bytes) is a 413. 404 unless SHIPWRIGHT_TRANSCRIPT_API_ENABLED is `enabled`, or when the run is not owned by the cron/agent.",
160
+ request: {
161
+ params: CronRunIdParamSchema,
162
+ body: {
163
+ content: {
164
+ "application/json": { schema: UploadCronRunTranscriptBodySchema },
165
+ },
166
+ },
167
+ },
168
+ responses: {
169
+ 200: {
170
+ description: "Transcript stored",
171
+ content: {
172
+ "application/json": { schema: CronRunTranscriptUploadResultSchema },
173
+ },
174
+ },
175
+ 400: { description: "Bad request", ...jsonError },
176
+ 403: { description: "Caller not allowed", ...jsonError },
177
+ 404: {
178
+ description: "Run not found, or transcript API disabled",
179
+ ...jsonError,
180
+ },
181
+ 413: { description: "Transcript body over 1 MiB", ...jsonError },
182
+ },
183
+ });
184
+
185
+ const getTranscriptRoute = createRoute({
186
+ method: "get",
187
+ path: "/agents/{id}/crons/{cronId}/runs/{runId}/transcript",
188
+ summary: "Get a cron run transcript",
189
+ description:
190
+ "Returns the stored redacted transcript. `*`, `platform.read` and `platform.ops` callers only: an agent key is 403 even for its own runs. Every read writes an AgentTranscriptRead audit row before the response. 404 when SHIPWRIGHT_TRANSCRIPT_API_ENABLED is not `enabled` or no transcript exists.",
191
+ request: { params: CronRunIdParamSchema },
192
+ responses: {
193
+ 200: {
194
+ description: "The transcript",
195
+ content: { "application/json": { schema: CronRunTranscriptSchema } },
196
+ },
197
+ 403: { description: "Forbidden", ...jsonError },
198
+ 404: {
199
+ description: "No transcript, or transcript API disabled",
200
+ ...jsonError,
201
+ },
202
+ },
203
+ });
204
+
205
+ /**
206
+ * Register the transcript routes on the admin app. Must run after the
207
+ * /agents/* auth middleware is mounted.
208
+ */
209
+ export function registerCronRunTranscriptRoutes(
210
+ app: OpenAPIHono<AdminAuthEnv>,
211
+ deps: { transcripts?: CronRunTranscriptDeps },
212
+ ): void {
213
+ const { transcripts } = deps;
214
+ const enabled = transcripts?.config.enabled === true;
215
+
216
+ // Flag gate ahead of body validation: a disabled route is a plain 404.
217
+ app.use(
218
+ "/agents/:id/crons/:cronId/runs/:runId/transcript",
219
+ async (c, next) => {
220
+ if (!enabled) return c.json({ error: "Not found" }, 404);
221
+ await next();
222
+ },
223
+ );
224
+
225
+ app.openapi(putTranscriptRoute, async (c) => {
226
+ if (!transcripts) throw new NotFoundError();
227
+ const { id: agentId, cronId, runId } = c.req.valid("param");
228
+ const scope = c.get("scope") as AdminScope | undefined;
229
+ const allowed =
230
+ c.get("isAdmin") === true ||
231
+ (scope?.kind === "agent" && scope.agentId === agentId);
232
+ if (!allowed) {
233
+ throw new ForbiddenError(
234
+ "Only * or the agent's own key can upload a transcript",
235
+ );
236
+ }
237
+ const body = c.req.valid("json");
238
+ const bytes = Buffer.byteLength(body.body, "utf8");
239
+ if (bytes > MAX_TRANSCRIPT_BODY_BYTES) {
240
+ throw new PayloadTooLargeError(
241
+ `transcript body exceeds ${MAX_TRANSCRIPT_BODY_BYTES} bytes`,
242
+ );
243
+ }
244
+ const row = await transcripts.store.upsert({
245
+ runId,
246
+ agentId,
247
+ cronId,
248
+ sessionId: body.sessionId ?? null,
249
+ bytes,
250
+ truncated: body.truncated,
251
+ redactions: body.redactions,
252
+ body: body.body,
253
+ });
254
+ // Metadata only; the body is never logged.
255
+ console.log(
256
+ JSON.stringify({
257
+ event: "transcript.upload",
258
+ agentId,
259
+ cronId,
260
+ runId,
261
+ bytes,
262
+ truncated: row.truncated,
263
+ redactions: row.redactions,
264
+ }),
265
+ );
266
+ return c.json(
267
+ {
268
+ runId,
269
+ bytes: row.bytes,
270
+ truncated: row.truncated,
271
+ redactions: row.redactions,
272
+ },
273
+ 200,
274
+ );
275
+ });
276
+
277
+ app.openapi(getTranscriptRoute, async (c) => {
278
+ if (!transcripts) throw new NotFoundError();
279
+ const scope = c.get("scope") as AdminScope | undefined;
280
+ const kind = scope?.kind;
281
+ const allowed =
282
+ c.get("isAdmin") === true ||
283
+ kind === "platform-read" ||
284
+ kind === "platform-ops";
285
+ if (!allowed) {
286
+ throw new ForbiddenError(
287
+ "Only *, platform.read and platform.ops callers can read transcripts",
288
+ );
289
+ }
290
+ const { id: agentId, cronId, runId } = c.req.valid("param");
291
+ const row = await transcripts.store.get(agentId, cronId, runId);
292
+ if (!row) throw new NotFoundError("transcript not found");
293
+ // Audit first: if the write fails, the body is not served.
294
+ await transcripts.store.recordRead({
295
+ runId,
296
+ agentId,
297
+ reader: c.get("caller")?.name ?? "anonymous",
298
+ scopeKind: kind ?? (c.get("isAdmin") === true ? "admin" : "session"),
299
+ });
300
+ return c.json(
301
+ {
302
+ runId: row.runId,
303
+ cronId: row.cronId,
304
+ sessionId: row.sessionId,
305
+ bytes: row.bytes,
306
+ truncated: row.truncated,
307
+ redactions: row.redactions,
308
+ createdAt: row.createdAt.toISOString(),
309
+ body: row.body,
310
+ },
311
+ 200,
312
+ );
313
+ });
314
+ }
package/src/errors.ts CHANGED
@@ -62,6 +62,13 @@ export class ForbiddenError extends ApiError {
62
62
  }
63
63
  }
64
64
 
65
+ export class PayloadTooLargeError extends ApiError {
66
+ constructor(message = "Payload too large") {
67
+ super(413, message);
68
+ this.name = "PayloadTooLargeError";
69
+ }
70
+ }
71
+
65
72
  export class UnprocessableEntityError extends ApiError {
66
73
  constructor(message = "Unprocessable entity") {
67
74
  super(422, message);
@@ -227,6 +227,11 @@ export const AgentCronJobSchema = z
227
227
  name: z.string().nullable().openapi({ example: "morning-brief" }),
228
228
  system: z.boolean().openapi({ example: false }),
229
229
  parentCronId: z.string().nullable().openapi({ example: null }),
230
+ runRequestedAt: z.string().datetime().nullable().openapi({
231
+ example: null,
232
+ description:
233
+ "Set by POST .../run-now; the runtime honours it once while fresh (15 minutes) and run creation with commandVariant run-now clears it.",
234
+ }),
230
235
  createdAt: z
231
236
  .string()
232
237
  .datetime()
@@ -698,6 +703,51 @@ export const ChatCredentialsWrapperSchema = z
698
703
  .object({ credentials: z.array(ChatCredentialSchema) })
699
704
  .openapi("ChatCredentialsWrapper");
700
705
 
706
+ // ─── Cron-run transcripts (COS-43.1) ─────────────────────────────────────────
707
+
708
+ export const UploadCronRunTranscriptBodySchema = z
709
+ .object({
710
+ sessionId: z.string().min(1).optional().openapi({
711
+ description: "Claude session id the transcript was read from.",
712
+ example: "7c1c8d1e-0000-4000-8000-000000000000",
713
+ }),
714
+ body: z.string().openapi({
715
+ description:
716
+ "The already-redacted transcript (JSONL text). Over 1 MiB (UTF-8 bytes) is a 413. Never logged.",
717
+ }),
718
+ truncated: z.boolean().openapi({
719
+ description: "True when the runtime dropped earlier lines to fit.",
720
+ example: false,
721
+ }),
722
+ redactions: z.number().int().min(0).openapi({
723
+ description: "How many secrets the runtime redacted.",
724
+ example: 0,
725
+ }),
726
+ })
727
+ .openapi("UploadCronRunTranscriptBody");
728
+
729
+ export const CronRunTranscriptUploadResultSchema = z
730
+ .object({
731
+ runId: z.string().openapi({ example: "clx1111111111" }),
732
+ bytes: z.number().int().openapi({ example: 2048 }),
733
+ truncated: z.boolean().openapi({ example: false }),
734
+ redactions: z.number().int().openapi({ example: 0 }),
735
+ })
736
+ .openapi("CronRunTranscriptUploadResult");
737
+
738
+ export const CronRunTranscriptSchema = z
739
+ .object({
740
+ runId: z.string().openapi({ example: "clx1111111111" }),
741
+ cronId: z.string().openapi({ example: "clx0987654321" }),
742
+ sessionId: z.string().nullable().openapi({ example: null }),
743
+ bytes: z.number().int().openapi({ example: 2048 }),
744
+ truncated: z.boolean().openapi({ example: false }),
745
+ redactions: z.number().int().openapi({ example: 0 }),
746
+ createdAt: z.string().openapi({ example: "2026-10-26T12:00:00.000Z" }),
747
+ body: z.string().openapi({ description: "Redacted JSONL transcript." }),
748
+ })
749
+ .openapi("CronRunTranscript");
750
+
701
751
  // ─── Agent ask (COS-31.1) ─────────────────────────────────────────────────────
702
752
 
703
753
  export const AgentAskBodySchema = z
@@ -729,6 +779,15 @@ export const AgentAskBodySchema = z
729
779
  })
730
780
  .openapi("AgentAskBody");
731
781
 
782
+ export const CronRunNowResponseSchema = z
783
+ .object({
784
+ requestedAt: z
785
+ .string()
786
+ .datetime()
787
+ .openapi({ example: "2026-10-26T12:00:00.000Z" }),
788
+ })
789
+ .openapi("CronRunNowResponse");
790
+
732
791
  export const AgentAskResponseSchema = z
733
792
  .object({
734
793
  askId: z.string().openapi({ example: "clx1111111111" }),
@@ -1249,6 +1308,25 @@ export const AgentConfigResponseSchema = z
1249
1308
  "dev-task": null,
1250
1309
  },
1251
1310
  }),
1311
+ /**
1312
+ * COS-43.1: feature flags the runtime acts on. Optional (additive) so an
1313
+ * older runtime or fixture that omits it keeps working; the handler
1314
+ * always populates it.
1315
+ */
1316
+ features: z
1317
+ .object({
1318
+ transcriptUpload: z.boolean().openapi({
1319
+ description:
1320
+ "True when SHIPWRIGHT_TRANSCRIPT_API_ENABLED is on: the runtime uploads a redacted transcript after each cron run.",
1321
+ example: false,
1322
+ }),
1323
+ repoAccessCheck: z.boolean().openapi({
1324
+ description: "Reserved for the repo-access check (COS-44.1).",
1325
+ example: false,
1326
+ }),
1327
+ })
1328
+ .optional()
1329
+ .openapi("AgentConfigFeatures"),
1252
1330
  })
1253
1331
  .openapi("AgentConfigResponse");
1254
1332
 
@@ -37,6 +37,8 @@ const validCronJob = {
37
37
  system: false,
38
38
  parentCronId: null,
39
39
  lockdownDisabledAt: null,
40
+ runRequestedAt: null,
41
+ runRequestedBy: null,
40
42
  createdAt: now,
41
43
  updatedAt: now,
42
44
  };
@@ -102,6 +104,8 @@ describe("AgentCronJobSchema", () => {
102
104
  system: false,
103
105
  parentCronId: null,
104
106
  lockdownDisabledAt: null,
107
+ runRequestedAt: null,
108
+ runRequestedBy: null,
105
109
  createdAt: now,
106
110
  updatedAt: now,
107
111
  };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * admin/src/transcript-config.ts
3
+ *
4
+ * Pure parser for the cron-run transcript API flag (COS-43.1). Takes an
5
+ * injected env object (no process.env reads) so it is deterministically
6
+ * unit-testable; admin-boot reads it once at startup and injects the result.
7
+ * Same "enabled" convention as agent-ask-config.ts.
8
+ */
9
+
10
+ type TranscriptEnv = Readonly<Record<string, string | undefined>>;
11
+
12
+ export interface TranscriptConfig {
13
+ /** True only when SHIPWRIGHT_TRANSCRIPT_API_ENABLED === "enabled". */
14
+ enabled: boolean;
15
+ }
16
+
17
+ export function parseTranscriptConfig(env: TranscriptEnv): TranscriptConfig {
18
+ return { enabled: env.SHIPWRIGHT_TRANSCRIPT_API_ENABLED === "enabled" };
19
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * admin/src/transcript-config.unit.test.ts
3
+ * Pure flag parser for the cron-run transcript API (COS-43.1).
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import { parseTranscriptConfig } from "./transcript-config.ts";
8
+
9
+ describe("parseTranscriptConfig", () => {
10
+ it('is on only for the literal "enabled"', () => {
11
+ expect(
12
+ parseTranscriptConfig({ SHIPWRIGHT_TRANSCRIPT_API_ENABLED: "enabled" })
13
+ .enabled,
14
+ ).toBe(true);
15
+ });
16
+
17
+ it("every other value is off", () => {
18
+ for (const v of [
19
+ "true",
20
+ "",
21
+ "Enabled",
22
+ " enabled",
23
+ "disabled",
24
+ undefined,
25
+ ]) {
26
+ expect(
27
+ parseTranscriptConfig({ SHIPWRIGHT_TRANSCRIPT_API_ENABLED: v }).enabled,
28
+ ).toBe(false);
29
+ }
30
+ expect(parseTranscriptConfig({}).enabled).toBe(false);
31
+ });
32
+ });