@app-vitals/agent-admin 0.2.0 → 0.3.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.
@@ -27,6 +27,8 @@ export type * from './models/AgentCronRunSkillUsage.ts'
27
27
  export type * from './models/AgentCronRunModelBreakdown.ts'
28
28
  export type * from './models/AgentChatTokenUsageDailyByModel.ts'
29
29
  export type * from './models/AgentAsk.ts'
30
+ export type * from './models/AgentCronRunTranscript.ts'
31
+ export type * from './models/AgentTranscriptRead.ts'
30
32
  export type * from './models/AgentGitHubInstallationsSnapshot.ts'
31
33
  export type * from './models/PushSubscription.ts'
32
34
  export type * from './models/ChatThreadWatch.ts'
@@ -0,0 +1,37 @@
1
+ -- COS-43.1: redacted cron-run transcripts + per-read audit rows (additive only).
2
+ -- CreateTable
3
+ CREATE TABLE "AgentCronRunTranscript" (
4
+ "id" TEXT NOT NULL,
5
+ "runId" TEXT NOT NULL,
6
+ "agentId" TEXT NOT NULL,
7
+ "cronId" TEXT NOT NULL,
8
+ "sessionId" TEXT,
9
+ "bytes" INTEGER NOT NULL,
10
+ "truncated" BOOLEAN NOT NULL,
11
+ "redactions" INTEGER NOT NULL,
12
+ "body" TEXT NOT NULL,
13
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14
+
15
+ CONSTRAINT "AgentCronRunTranscript_pkey" PRIMARY KEY ("id")
16
+ );
17
+
18
+ -- CreateTable
19
+ CREATE TABLE "AgentTranscriptRead" (
20
+ "id" TEXT NOT NULL,
21
+ "runId" TEXT NOT NULL,
22
+ "agentId" TEXT NOT NULL,
23
+ "reader" TEXT NOT NULL,
24
+ "scopeKind" TEXT NOT NULL,
25
+ "at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
26
+
27
+ CONSTRAINT "AgentTranscriptRead_pkey" PRIMARY KEY ("id")
28
+ );
29
+
30
+ -- CreateIndex
31
+ CREATE UNIQUE INDEX "AgentCronRunTranscript_runId_key" ON "AgentCronRunTranscript"("runId");
32
+
33
+ -- CreateIndex
34
+ CREATE INDEX "AgentCronRunTranscript_agentId_createdAt_idx" ON "AgentCronRunTranscript"("agentId", "createdAt");
35
+
36
+ -- CreateIndex
37
+ CREATE INDEX "AgentTranscriptRead_agentId_at_idx" ON "AgentTranscriptRead"("agentId", "at");
@@ -426,6 +426,40 @@ model AgentAsk {
426
426
  @@index([fromAgentId, createdAt])
427
427
  }
428
428
 
429
+ // ─── Cron-run transcripts (COS-43.1) ────────────────────────────────────────
430
+ // One redacted session transcript per cron run, uploaded by the agent runtime
431
+ // (already redacted + tail-truncated to 1 MiB) and served only to admin /
432
+ // platform callers behind SHIPWRIGHT_TRANSCRIPT_API_ENABLED. No FK on runId:
433
+ // the retention sweep (newest 200 per agent) is the only lifecycle. The body
434
+ // is never logged. AgentTranscriptRead is the audit row written before every
435
+ // transcript read.
436
+
437
+ model AgentCronRunTranscript {
438
+ id String @id @default(cuid())
439
+ runId String @unique
440
+ agentId String
441
+ cronId String
442
+ sessionId String?
443
+ bytes Int
444
+ truncated Boolean
445
+ redactions Int
446
+ body String @db.Text
447
+ createdAt DateTime @default(now())
448
+
449
+ @@index([agentId, createdAt])
450
+ }
451
+
452
+ model AgentTranscriptRead {
453
+ id String @id @default(cuid())
454
+ runId String
455
+ agentId String
456
+ reader String
457
+ scopeKind String
458
+ at DateTime @default(now())
459
+
460
+ @@index([agentId, at])
461
+ }
462
+
429
463
  // ─── Agent GitHub Installations Snapshot ────────────────────────────────────
430
464
  // One row per agent holding the agent's most recently reported GitHub App
431
465
  // installations (owner, installation id, state, sanitized lastError). PUT
package/src/admin-boot.ts CHANGED
@@ -92,6 +92,7 @@ import {
92
92
  } from "./caller-scope.ts";
93
93
  import { parseChatCredentialConfig } from "./chat-credential-config.ts";
94
94
  import { HttpChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
95
+ import { CronRunTranscriptService } from "./cron-run-transcripts.ts";
95
96
  import { isDevAuthAllowed } from "./dev-auth-guard.ts";
96
97
  import { HttpGithubAppProvisioningClient } from "./github-app-provisioning-client.ts";
97
98
  import { HttpGoogleAuthClient } from "./google-auth-client.ts";
@@ -106,6 +107,7 @@ import { HttpSlackProvisioningClient } from "./slack-provisioning-client.ts";
106
107
  import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
107
108
  import { runStartupPolicyReconcile } from "./startup-policy-reconcile.ts";
108
109
  import { makeTokenCrypto } from "./token-crypto.ts";
110
+ import { parseTranscriptConfig } from "./transcript-config.ts";
109
111
  import {
110
112
  resolveTrialExpirySweepIntervalMs,
111
113
  TrialExpirySweeper,
@@ -291,6 +293,9 @@ export async function startAdmin(
291
293
  // Agent-to-agent ask (COS-31.1). Throws at boot on a malformed allowlist.
292
294
  const agentAskConfig = parseAgentAskConfig(process.env);
293
295
 
296
+ // Cron-run transcript API (COS-43.1): flag off by default.
297
+ const transcriptConfig = parseTranscriptConfig(process.env);
298
+
294
299
  // Web Push (CFB-4.2). Enabled ONLY when public + private + subject are all
295
300
  // set — a partial config must not half-enable the feature. When disabled,
296
301
  // pushService is undefined, the push routes 503, and the toggle renders "".
@@ -415,6 +420,7 @@ export async function startAdmin(
415
420
  adminApiKeys,
416
421
  agentTokenService,
417
422
  anthropicFederationEnabled,
423
+ transcriptUploadEnabled: transcriptConfig.enabled,
418
424
  });
419
425
 
420
426
  root.route("/agents", runtimeApp);
@@ -457,6 +463,10 @@ export async function startAdmin(
457
463
  store: new AgentAskService(prisma),
458
464
  },
459
465
  ...(chatClient ? { chatClient } : {}),
466
+ transcripts: {
467
+ config: transcriptConfig,
468
+ store: new CronRunTranscriptService(prisma),
469
+ },
460
470
  ...resolvedExtensions.apiDeps,
461
471
  });
462
472
  root.route("/", adminApiApp);
package/src/agents-api.ts CHANGED
@@ -83,6 +83,10 @@ import {
83
83
  memberOnlyCallerScopeResolver,
84
84
  } from "./caller-scope.ts";
85
85
  import type { ChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
86
+ import {
87
+ type CronRunTranscriptDeps,
88
+ registerCronRunTranscriptRoutes,
89
+ } from "./cron-run-transcripts.ts";
86
90
  import {
87
91
  ApiError,
88
92
  BadRequestError,
@@ -291,6 +295,11 @@ export interface AdminDeps {
291
295
  * route 503s, even with the flag on.
292
296
  */
293
297
  chatClient?: AskChatClient;
298
+ /**
299
+ * Cron-run transcripts (COS-43.1): flag config + store. Absent or flag off
300
+ * -> the transcript routes 404. See cron-run-transcripts.ts.
301
+ */
302
+ transcripts?: CronRunTranscriptDeps;
294
303
  }
295
304
 
296
305
  export type { AdminApiKey };
@@ -2335,6 +2344,10 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
2335
2344
  agentService,
2336
2345
  });
2337
2346
 
2347
+ // ─── Cron-run transcripts (COS-43.1) ──────────────────────────────────────
2348
+
2349
+ registerCronRunTranscriptRoutes(app, { transcripts: deps.transcripts });
2350
+
2338
2351
  // ─── Plugins ───────────────────────────────────────────────────────────────
2339
2352
 
2340
2353
  // POST /agents/:id/plugins — add a plugin
@@ -236,6 +236,7 @@ function buildApp(opts?: {
236
236
  maxFindings?: number;
237
237
  cleanupMergedWorktrees?: boolean;
238
238
  cleanupAfterDays?: number;
239
+ transcriptUploadEnabled?: boolean;
239
240
  }) {
240
241
  const hasAgent = opts?.hasAgent ?? true;
241
242
  const bundle: AgentEnvBundle | null =
@@ -308,6 +309,7 @@ function buildApp(opts?: {
308
309
  agentTokenService: { validate: async () => null },
309
310
  sessionSecret: SESSION_SECRET,
310
311
  anthropicFederationEnabled: opts?.anthropicFederationEnabled,
312
+ transcriptUploadEnabled: opts?.transcriptUploadEnabled,
311
313
  });
312
314
  }
313
315
 
@@ -326,6 +328,31 @@ describe("GET /:id/config with no phase-methodology service (platform only)", ()
326
328
  });
327
329
  });
328
330
 
331
+ describe("GET /:id/config features (COS-43.1)", () => {
332
+ const get = (app: ReturnType<typeof buildApp>) =>
333
+ app.request(`/${KNOWN_AGENT_ID}/config`, {
334
+ headers: { Authorization: `Bearer ${VALID_ADMIN_KEY}` },
335
+ });
336
+
337
+ test("features default to all off", async () => {
338
+ const body = await (await get(buildApp())).json();
339
+ expect(body.features).toEqual({
340
+ transcriptUpload: false,
341
+ repoAccessCheck: false,
342
+ });
343
+ });
344
+
345
+ test("features.transcriptUpload follows the transcript flag", async () => {
346
+ const body = await (
347
+ await get(buildApp({ transcriptUploadEnabled: true }))
348
+ ).json();
349
+ expect(body.features).toEqual({
350
+ transcriptUpload: true,
351
+ repoAccessCheck: false,
352
+ });
353
+ });
354
+ });
355
+
329
356
  describe("GET /:id/config (mounted as GET /agents/:id/config from root)", () => {
330
357
  test("200 with full bundle when agent exists with env vars, tools, plugins", async () => {
331
358
  const app = buildApp();
package/src/api.ts CHANGED
@@ -93,6 +93,11 @@ export interface AgentConfigResponse {
93
93
  maxFindings: number;
94
94
  cleanupMergedWorktrees: boolean;
95
95
  cleanupAfterDays: number;
96
+ /**
97
+ * COS-43.1: feature flags the runtime acts on. Optional so fixtures built
98
+ * before it keep compiling; the live handler always populates it.
99
+ */
100
+ features?: { transcriptUpload: boolean; repoAccessCheck: boolean };
96
101
  }
97
102
 
98
103
  interface AgentEnvServiceLike {
@@ -169,6 +174,11 @@ export interface AgentRuntimeDeps {
169
174
  agentTokenService: Pick<AgentTokenService, "validate">;
170
175
  /** WIF-2.1: Anthropic federation is enabled; defaults to false. */
171
176
  anthropicFederationEnabled?: boolean;
177
+ /**
178
+ * COS-43.1: SHIPWRIGHT_TRANSCRIPT_API_ENABLED is on — surfaced to the runtime
179
+ * as `features.transcriptUpload` in the config bundle. Defaults to false.
180
+ */
181
+ transcriptUploadEnabled?: boolean;
172
182
  }
173
183
 
174
184
  // ─── Route definitions ────────────────────────────────────────────────────────
@@ -342,6 +352,11 @@ export function createAgentRuntimeApp(deps: AgentRuntimeDeps): OpenAPIHono {
342
352
  maxFindings: agent.maxFindings,
343
353
  cleanupMergedWorktrees: agent.cleanupMergedWorktrees,
344
354
  cleanupAfterDays: agent.cleanupAfterDays,
355
+ // COS-43.1: repoAccessCheck is wired by COS-44.1; false until then.
356
+ features: {
357
+ transcriptUpload: deps.transcriptUploadEnabled === true,
358
+ repoAccessCheck: false,
359
+ },
345
360
  };
346
361
 
347
362
  // The route's Zod schema requires phaseMethodology; a platform-only admin
@@ -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
+ });