@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.
- package/openapi.json +291 -0
- package/openapi.platform.json +392 -0
- package/package.json +2 -2
- package/prisma/client/browser.ts +15 -0
- package/prisma/client/client.ts +15 -0
- package/prisma/client/internal/class.ts +34 -4
- package/prisma/client/internal/prismaNamespace.ts +273 -1
- package/prisma/client/internal/prismaNamespaceBrowser.ts +47 -0
- package/prisma/client/models/AgentCronJob.ts +107 -1
- package/prisma/client/models/AgentCronRunRequest.ts +1233 -0
- package/prisma/client/models/AgentCronRunTranscript.ts +1339 -0
- package/prisma/client/models/AgentTranscriptRead.ts +1177 -0
- package/prisma/client/models.ts +3 -0
- package/prisma/migrations/20261027000000_add_cron_run_transcripts/migration.sql +37 -0
- package/prisma/migrations/20261028000000_add_cron_run_now/migration.sql +24 -0
- package/prisma/schema/schema.prisma +58 -0
- package/src/admin-boot.ts +18 -0
- package/src/admin-ui.fixtures.ts +2 -0
- package/src/admin-ui.smoke.test.ts +2 -0
- package/src/agent-cron-jobs.unit.test.ts +4 -0
- package/src/agent-cron-run-stats.smoke.test.ts +10 -0
- package/src/agent-cron-runs.integration.test.ts +93 -0
- package/src/agent-cron-runs.ts +47 -15
- package/src/agents-api.smoke.test.ts +18 -0
- package/src/agents-api.ts +30 -0
- package/src/api-auth.ts +1 -0
- package/src/api.smoke.test.ts +39 -0
- package/src/api.ts +15 -0
- package/src/chat.smoke.test.ts +2 -0
- package/src/cron-run-now-config.ts +20 -0
- package/src/cron-run-now-config.unit.test.ts +26 -0
- package/src/cron-run-now.smoke.test.ts +218 -0
- package/src/cron-run-now.ts +207 -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 +78 -0
- package/src/openapi-schemas.unit.test.ts +4 -0
- package/src/transcript-config.ts +19 -0
- 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);
|
package/src/openapi-schemas.ts
CHANGED
|
@@ -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
|
+
});
|