@app-vitals/agent-admin 0.2.1 → 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.
- package/openapi.json +291 -0
- package/openapi.platform.json +291 -0
- package/package.json +2 -2
- package/prisma/client/browser.ts +10 -0
- package/prisma/client/client.ts +10 -0
- package/prisma/client/internal/class.ts +24 -4
- package/prisma/client/internal/prismaNamespace.ts +181 -1
- package/prisma/client/internal/prismaNamespaceBrowser.ts +30 -0
- package/prisma/client/models/AgentCronRunTranscript.ts +1339 -0
- package/prisma/client/models/AgentTranscriptRead.ts +1177 -0
- package/prisma/client/models.ts +2 -0
- package/prisma/migrations/20261027000000_add_cron_run_transcripts/migration.sql +37 -0
- package/prisma/schema/schema.prisma +34 -0
- package/src/admin-boot.ts +10 -0
- package/src/agents-api.ts +13 -0
- package/src/api.smoke.test.ts +27 -0
- package/src/api.ts +15 -0
- package/src/cron-run-transcripts.integration.test.ts +220 -0
- package/src/cron-run-transcripts.smoke.test.ts +286 -0
- package/src/cron-run-transcripts.ts +314 -0
- package/src/errors.ts +7 -0
- package/src/openapi-schemas.ts +64 -0
- package/src/transcript-config.ts +19 -0
- package/src/transcript-config.unit.test.ts +32 -0
|
@@ -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
|
+
});
|
|
@@ -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);
|
package/src/openapi-schemas.ts
CHANGED
|
@@ -698,6 +698,51 @@ export const ChatCredentialsWrapperSchema = z
|
|
|
698
698
|
.object({ credentials: z.array(ChatCredentialSchema) })
|
|
699
699
|
.openapi("ChatCredentialsWrapper");
|
|
700
700
|
|
|
701
|
+
// ─── Cron-run transcripts (COS-43.1) ─────────────────────────────────────────
|
|
702
|
+
|
|
703
|
+
export const UploadCronRunTranscriptBodySchema = z
|
|
704
|
+
.object({
|
|
705
|
+
sessionId: z.string().min(1).optional().openapi({
|
|
706
|
+
description: "Claude session id the transcript was read from.",
|
|
707
|
+
example: "7c1c8d1e-0000-4000-8000-000000000000",
|
|
708
|
+
}),
|
|
709
|
+
body: z.string().openapi({
|
|
710
|
+
description:
|
|
711
|
+
"The already-redacted transcript (JSONL text). Over 1 MiB (UTF-8 bytes) is a 413. Never logged.",
|
|
712
|
+
}),
|
|
713
|
+
truncated: z.boolean().openapi({
|
|
714
|
+
description: "True when the runtime dropped earlier lines to fit.",
|
|
715
|
+
example: false,
|
|
716
|
+
}),
|
|
717
|
+
redactions: z.number().int().min(0).openapi({
|
|
718
|
+
description: "How many secrets the runtime redacted.",
|
|
719
|
+
example: 0,
|
|
720
|
+
}),
|
|
721
|
+
})
|
|
722
|
+
.openapi("UploadCronRunTranscriptBody");
|
|
723
|
+
|
|
724
|
+
export const CronRunTranscriptUploadResultSchema = z
|
|
725
|
+
.object({
|
|
726
|
+
runId: z.string().openapi({ example: "clx1111111111" }),
|
|
727
|
+
bytes: z.number().int().openapi({ example: 2048 }),
|
|
728
|
+
truncated: z.boolean().openapi({ example: false }),
|
|
729
|
+
redactions: z.number().int().openapi({ example: 0 }),
|
|
730
|
+
})
|
|
731
|
+
.openapi("CronRunTranscriptUploadResult");
|
|
732
|
+
|
|
733
|
+
export const CronRunTranscriptSchema = z
|
|
734
|
+
.object({
|
|
735
|
+
runId: z.string().openapi({ example: "clx1111111111" }),
|
|
736
|
+
cronId: z.string().openapi({ example: "clx0987654321" }),
|
|
737
|
+
sessionId: z.string().nullable().openapi({ example: null }),
|
|
738
|
+
bytes: z.number().int().openapi({ example: 2048 }),
|
|
739
|
+
truncated: z.boolean().openapi({ example: false }),
|
|
740
|
+
redactions: z.number().int().openapi({ example: 0 }),
|
|
741
|
+
createdAt: z.string().openapi({ example: "2026-10-26T12:00:00.000Z" }),
|
|
742
|
+
body: z.string().openapi({ description: "Redacted JSONL transcript." }),
|
|
743
|
+
})
|
|
744
|
+
.openapi("CronRunTranscript");
|
|
745
|
+
|
|
701
746
|
// ─── Agent ask (COS-31.1) ─────────────────────────────────────────────────────
|
|
702
747
|
|
|
703
748
|
export const AgentAskBodySchema = z
|
|
@@ -1249,6 +1294,25 @@ export const AgentConfigResponseSchema = z
|
|
|
1249
1294
|
"dev-task": null,
|
|
1250
1295
|
},
|
|
1251
1296
|
}),
|
|
1297
|
+
/**
|
|
1298
|
+
* COS-43.1: feature flags the runtime acts on. Optional (additive) so an
|
|
1299
|
+
* older runtime or fixture that omits it keeps working; the handler
|
|
1300
|
+
* always populates it.
|
|
1301
|
+
*/
|
|
1302
|
+
features: z
|
|
1303
|
+
.object({
|
|
1304
|
+
transcriptUpload: z.boolean().openapi({
|
|
1305
|
+
description:
|
|
1306
|
+
"True when SHIPWRIGHT_TRANSCRIPT_API_ENABLED is on: the runtime uploads a redacted transcript after each cron run.",
|
|
1307
|
+
example: false,
|
|
1308
|
+
}),
|
|
1309
|
+
repoAccessCheck: z.boolean().openapi({
|
|
1310
|
+
description: "Reserved for the repo-access check (COS-44.1).",
|
|
1311
|
+
example: false,
|
|
1312
|
+
}),
|
|
1313
|
+
})
|
|
1314
|
+
.optional()
|
|
1315
|
+
.openapi("AgentConfigFeatures"),
|
|
1252
1316
|
})
|
|
1253
1317
|
.openapi("AgentConfigResponse");
|
|
1254
1318
|
|