@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
@@ -27,6 +27,9 @@ 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/AgentCronRunRequest.ts'
31
+ export type * from './models/AgentCronRunTranscript.ts'
32
+ export type * from './models/AgentTranscriptRead.ts'
30
33
  export type * from './models/AgentGitHubInstallationsSnapshot.ts'
31
34
  export type * from './models/PushSubscription.ts'
32
35
  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");
@@ -0,0 +1,24 @@
1
+ -- COS-42.1: cron run-now request columns and audit table (additive only).
2
+ -- AlterTable
3
+ ALTER TABLE "AgentCronJob" ADD COLUMN "runRequestedAt" TIMESTAMP(3),
4
+ ADD COLUMN "runRequestedBy" TEXT;
5
+
6
+ -- CreateTable
7
+ CREATE TABLE "AgentCronRunRequest" (
8
+ "id" TEXT NOT NULL,
9
+ "cronId" TEXT NOT NULL,
10
+ "agentId" TEXT NOT NULL,
11
+ "requestedBy" TEXT NOT NULL,
12
+ "requestedAt" TIMESTAMP(3) NOT NULL,
13
+ "status" TEXT NOT NULL DEFAULT 'requested',
14
+ "honouredRunId" TEXT,
15
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16
+
17
+ CONSTRAINT "AgentCronRunRequest_pkey" PRIMARY KEY ("id")
18
+ );
19
+
20
+ -- CreateIndex
21
+ CREATE INDEX "AgentCronRunRequest_cronId_requestedAt_idx" ON "AgentCronRunRequest"("cronId", "requestedAt");
22
+
23
+ -- CreateIndex
24
+ CREATE INDEX "AgentCronRunRequest_agentId_requestedAt_idx" ON "AgentCronRunRequest"("agentId", "requestedAt");
@@ -134,6 +134,11 @@ model AgentCronJob {
134
134
  // restoreLockdownDisabled() can re-enable exactly those crons. Null for
135
135
  // enabled crons and for crons the user disabled manually.
136
136
  lockdownDisabledAt DateTime?
137
+ // Run-now request (COS-42.1): stamped by POST .../run-now, cleared when the
138
+ // runtime creates the run (commandVariant "run-now"). The runtime honours a
139
+ // stamp at most once and only while it is fresh (15 minutes).
140
+ runRequestedAt DateTime?
141
+ runRequestedBy String?
137
142
  createdAt DateTime @default(now())
138
143
  updatedAt DateTime @updatedAt
139
144
 
@@ -426,6 +431,59 @@ model AgentAsk {
426
431
  @@index([fromAgentId, createdAt])
427
432
  }
428
433
 
434
+ // ─── Agent Cron Run Requests ────────────────────────────────────────────────
435
+ // Audit row per POST /agents/{id}/crons/{cronId}/run-now (COS-42.1), written
436
+ // before the 202 returns. status: requested | honoured | expired. Holds no
437
+ // prompt text. honouredRunId links the AgentCronRun the runtime created.
438
+
439
+ model AgentCronRunRequest {
440
+ id String @id @default(cuid())
441
+ cronId String
442
+ agentId String
443
+ requestedBy String
444
+ requestedAt DateTime
445
+ status String @default("requested")
446
+ honouredRunId String?
447
+ createdAt DateTime @default(now())
448
+
449
+ @@index([cronId, requestedAt])
450
+ @@index([agentId, requestedAt])
451
+ }
452
+
453
+ // ─── Cron-run transcripts (COS-43.1) ────────────────────────────────────────
454
+ // One redacted session transcript per cron run, uploaded by the agent runtime
455
+ // (already redacted + tail-truncated to 1 MiB) and served only to admin /
456
+ // platform callers behind SHIPWRIGHT_TRANSCRIPT_API_ENABLED. No FK on runId:
457
+ // the retention sweep (newest 200 per agent) is the only lifecycle. The body
458
+ // is never logged. AgentTranscriptRead is the audit row written before every
459
+ // transcript read.
460
+
461
+ model AgentCronRunTranscript {
462
+ id String @id @default(cuid())
463
+ runId String @unique
464
+ agentId String
465
+ cronId String
466
+ sessionId String?
467
+ bytes Int
468
+ truncated Boolean
469
+ redactions Int
470
+ body String @db.Text
471
+ createdAt DateTime @default(now())
472
+
473
+ @@index([agentId, createdAt])
474
+ }
475
+
476
+ model AgentTranscriptRead {
477
+ id String @id @default(cuid())
478
+ runId String
479
+ agentId String
480
+ reader String
481
+ scopeKind String
482
+ at DateTime @default(now())
483
+
484
+ @@index([agentId, at])
485
+ }
486
+
429
487
  // ─── Agent GitHub Installations Snapshot ────────────────────────────────────
430
488
  // One row per agent holding the agent's most recently reported GitHub App
431
489
  // installations (owner, installation id, state, sanitized lastError). PUT
package/src/admin-boot.ts CHANGED
@@ -72,6 +72,8 @@ import {
72
72
  import { createAdminUIApp } from "./admin-ui.ts";
73
73
  import { AgentAskService } from "./agent-ask.ts";
74
74
  import { parseAgentAskConfig } from "./agent-ask-config.ts";
75
+ import { CronRunNowService } from "./cron-run-now.ts";
76
+ import { parseCronRunNowConfig } from "./cron-run-now-config.ts";
75
77
  import { AgentChatTokenService } from "./agent-chat-tokens.ts";
76
78
  import { AgentCronJobService } from "./agent-cron-jobs.ts";
77
79
  import { AgentCronRunStatsService } from "./agent-cron-run-stats.ts";
@@ -92,6 +94,7 @@ import {
92
94
  } from "./caller-scope.ts";
93
95
  import { parseChatCredentialConfig } from "./chat-credential-config.ts";
94
96
  import { HttpChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
97
+ import { CronRunTranscriptService } from "./cron-run-transcripts.ts";
95
98
  import { isDevAuthAllowed } from "./dev-auth-guard.ts";
96
99
  import { HttpGithubAppProvisioningClient } from "./github-app-provisioning-client.ts";
97
100
  import { HttpGoogleAuthClient } from "./google-auth-client.ts";
@@ -106,6 +109,7 @@ import { HttpSlackProvisioningClient } from "./slack-provisioning-client.ts";
106
109
  import { SpecialtyRegistry } from "./specialty-manifest-loader.ts";
107
110
  import { runStartupPolicyReconcile } from "./startup-policy-reconcile.ts";
108
111
  import { makeTokenCrypto } from "./token-crypto.ts";
112
+ import { parseTranscriptConfig } from "./transcript-config.ts";
109
113
  import {
110
114
  resolveTrialExpirySweepIntervalMs,
111
115
  TrialExpirySweeper,
@@ -290,6 +294,11 @@ export async function startAdmin(
290
294
 
291
295
  // Agent-to-agent ask (COS-31.1). Throws at boot on a malformed allowlist.
292
296
  const agentAskConfig = parseAgentAskConfig(process.env);
297
+ // Cron run-now (COS-42.1).
298
+ const cronRunNowConfig = parseCronRunNowConfig(process.env);
299
+
300
+ // Cron-run transcript API (COS-43.1): flag off by default.
301
+ const transcriptConfig = parseTranscriptConfig(process.env);
293
302
 
294
303
  // Web Push (CFB-4.2). Enabled ONLY when public + private + subject are all
295
304
  // set — a partial config must not half-enable the feature. When disabled,
@@ -415,6 +424,7 @@ export async function startAdmin(
415
424
  adminApiKeys,
416
425
  agentTokenService,
417
426
  anthropicFederationEnabled,
427
+ transcriptUploadEnabled: transcriptConfig.enabled,
418
428
  });
419
429
 
420
430
  root.route("/agents", runtimeApp);
@@ -456,7 +466,15 @@ export async function startAdmin(
456
466
  config: agentAskConfig,
457
467
  store: new AgentAskService(prisma),
458
468
  },
469
+ cronRunNow: {
470
+ config: cronRunNowConfig,
471
+ store: new CronRunNowService(prisma),
472
+ },
459
473
  ...(chatClient ? { chatClient } : {}),
474
+ transcripts: {
475
+ config: transcriptConfig,
476
+ store: new CronRunTranscriptService(prisma),
477
+ },
460
478
  ...resolvedExtensions.apiDeps,
461
479
  });
462
480
  root.route("/", adminApiApp);
@@ -51,6 +51,8 @@ export const MOCK_CRON = {
51
51
  system: false,
52
52
  parentCronId: null,
53
53
  lockdownDisabledAt: null,
54
+ runRequestedAt: null,
55
+ runRequestedBy: null,
54
56
  silent: false,
55
57
  preCheck: null,
56
58
  createdAt: new Date("2024-01-01"),
@@ -1060,6 +1060,8 @@ describe("admin UI — authenticated pages", () => {
1060
1060
  system: true,
1061
1061
  parentCronId: null,
1062
1062
  lockdownDisabledAt: null,
1063
+ runRequestedAt: null,
1064
+ runRequestedBy: null,
1063
1065
  lastRun: null,
1064
1066
  runCountToday: 0,
1065
1067
  };
@@ -116,6 +116,8 @@ function makeFakePrisma(opts: {
116
116
  system: true,
117
117
  parentCronId: null,
118
118
  lockdownDisabledAt: null,
119
+ runRequestedAt: null,
120
+ runRequestedBy: null,
119
121
  createdAt: new Date("2024-01-01"),
120
122
  updatedAt: new Date("2024-01-01"),
121
123
  ...seed,
@@ -156,6 +158,8 @@ function makeFakePrisma(opts: {
156
158
  system: true,
157
159
  parentCronId: null,
158
160
  lockdownDisabledAt: null,
161
+ runRequestedAt: null,
162
+ runRequestedBy: null,
159
163
  createdAt: new Date("2024-01-01"),
160
164
  updatedAt: new Date("2024-01-01"),
161
165
  ...data,
@@ -276,6 +276,8 @@ function makeMockDeps(): AdminDeps {
276
276
  system: false,
277
277
  parentCronId: null,
278
278
  lockdownDisabledAt: null,
279
+ runRequestedAt: null,
280
+ runRequestedBy: null,
279
281
  createdAt: new Date("2024-01-01"),
280
282
  updatedAt: new Date("2024-01-01"),
281
283
  }),
@@ -293,6 +295,8 @@ function makeMockDeps(): AdminDeps {
293
295
  system: false,
294
296
  parentCronId: null,
295
297
  lockdownDisabledAt: null,
298
+ runRequestedAt: null,
299
+ runRequestedBy: null,
296
300
  createdAt: new Date("2024-01-01"),
297
301
  updatedAt: new Date("2024-01-01"),
298
302
  }),
@@ -311,6 +315,8 @@ function makeMockDeps(): AdminDeps {
311
315
  system: false,
312
316
  parentCronId: null,
313
317
  lockdownDisabledAt: null,
318
+ runRequestedAt: null,
319
+ runRequestedBy: null,
314
320
  createdAt: new Date("2024-01-01"),
315
321
  updatedAt: new Date("2024-01-01"),
316
322
  }),
@@ -328,6 +334,8 @@ function makeMockDeps(): AdminDeps {
328
334
  system: false,
329
335
  parentCronId: null,
330
336
  lockdownDisabledAt: null,
337
+ runRequestedAt: null,
338
+ runRequestedBy: null,
331
339
  createdAt: new Date("2024-01-01"),
332
340
  updatedAt: new Date("2024-01-01"),
333
341
  }),
@@ -345,6 +353,8 @@ function makeMockDeps(): AdminDeps {
345
353
  system: false,
346
354
  parentCronId: null,
347
355
  lockdownDisabledAt: null,
356
+ runRequestedAt: null,
357
+ runRequestedBy: null,
348
358
  createdAt: new Date("2024-01-01"),
349
359
  updatedAt: new Date("2024-01-01"),
350
360
  }),
@@ -1489,3 +1489,96 @@ describeOrSkip("AgentCronRunService — run telemetry (integration)", () => {
1489
1489
  ).toBe(0);
1490
1490
  });
1491
1491
  });
1492
+
1493
+ describeOrSkip(
1494
+ "AgentCronRunService run-now clear-on-create (integration)",
1495
+ () => {
1496
+ let prisma: PrismaClient;
1497
+ let agentId: string;
1498
+ let cronId: string;
1499
+ let runService: AgentCronRunService;
1500
+
1501
+ async function stamp(requestedAt: Date) {
1502
+ await prisma.agentCronJob.update({
1503
+ where: { id: cronId },
1504
+ data: { runRequestedAt: requestedAt, runRequestedBy: "tester" },
1505
+ });
1506
+ return prisma.agentCronRunRequest.create({
1507
+ data: {
1508
+ cronId,
1509
+ agentId,
1510
+ requestedBy: "tester",
1511
+ requestedAt,
1512
+ status: "requested",
1513
+ },
1514
+ });
1515
+ }
1516
+
1517
+ beforeEach(async () => {
1518
+ prisma = makePrisma();
1519
+ await prisma.agentCronRunRequest.deleteMany();
1520
+ await prisma.agentCronRun.deleteMany();
1521
+ await prisma.agentCronJob.deleteMany();
1522
+ agentId = await createAgent(prisma, "Run-now Agent");
1523
+ cronId = await createCron(new AgentCronJobService(prisma), agentId);
1524
+ runService = new AgentCronRunService(prisma);
1525
+ });
1526
+
1527
+ afterEach(async () => {
1528
+ await prisma.agentCronRunRequest.deleteMany();
1529
+ await prisma.agentCronRun.deleteMany();
1530
+ await prisma.agentCronJob.deleteMany();
1531
+ await prisma.agent.deleteMany({ where: { id: agentId } });
1532
+ await prisma.$disconnect();
1533
+ });
1534
+
1535
+ it("commandVariant run-now clears the stamp and honours the newest request", async () => {
1536
+ await stamp(new Date("2026-10-26T11:00:00Z"));
1537
+ const newest = await stamp(new Date("2026-10-26T11:30:00Z"));
1538
+
1539
+ const run = await runService.create(cronId, agentId, {
1540
+ startedAt: new Date("2026-10-26T11:31:00Z"),
1541
+ commandVariant: "run-now",
1542
+ });
1543
+
1544
+ const job = await prisma.agentCronJob.findUniqueOrThrow({
1545
+ where: { id: cronId },
1546
+ });
1547
+ expect(job.runRequestedAt).toBeNull();
1548
+ expect(job.runRequestedBy).toBeNull();
1549
+ const honoured = await prisma.agentCronRunRequest.findUniqueOrThrow({
1550
+ where: { id: newest.id },
1551
+ });
1552
+ expect(honoured.status).toBe("honoured");
1553
+ expect(honoured.honouredRunId).toBe(run.id);
1554
+ const stillRequested = await prisma.agentCronRunRequest.count({
1555
+ where: { cronId, status: "requested" },
1556
+ });
1557
+ expect(stillRequested).toBe(0);
1558
+ const superseded = await prisma.agentCronRunRequest.count({
1559
+ where: { cronId, status: "expired" },
1560
+ });
1561
+ expect(superseded).toBe(1);
1562
+ });
1563
+
1564
+ it("any other variant leaves the stamp and the request alone", async () => {
1565
+ const req = await stamp(new Date("2026-10-26T11:30:00Z"));
1566
+ await runService.create(cronId, agentId, {
1567
+ startedAt: new Date("2026-10-26T11:31:00Z"),
1568
+ commandVariant: "default",
1569
+ });
1570
+ await runService.create(cronId, agentId, {
1571
+ startedAt: new Date("2026-10-26T11:32:00Z"),
1572
+ });
1573
+ const job = await prisma.agentCronJob.findUniqueOrThrow({
1574
+ where: { id: cronId },
1575
+ });
1576
+ expect(job.runRequestedAt).not.toBeNull();
1577
+ const row = await prisma.agentCronRunRequest.findUniqueOrThrow({
1578
+ where: { id: req.id },
1579
+ });
1580
+ expect(row.status).toBe("requested");
1581
+ expect(row.honouredRunId).toBeNull();
1582
+ });
1583
+ },
1584
+ );
@@ -15,6 +15,9 @@ import { NotFoundError } from "./errors.ts";
15
15
 
16
16
  export type { AgentCronRun };
17
17
 
18
+ /** commandVariant a runtime reports when it honours a run-now request. */
19
+ const RUN_NOW_VARIANT = "run-now";
20
+
18
21
  /** An AgentCronRun with its per-model token/cost breakdown rows attached. */
19
22
  type AgentCronRunWithModelBreakdown = Prisma.AgentCronRunGetPayload<{
20
23
  include: { modelBreakdown: true };
@@ -193,21 +196,50 @@ export class AgentCronRunService {
193
196
  throw new NotFoundError(`cron job ${cronId} not found`);
194
197
  }
195
198
 
196
- return this.prisma.agentCronRun.create({
197
- data: {
198
- cronId,
199
- agentId,
200
- startedAt: input.startedAt,
201
- completedAt: input.completedAt ?? null,
202
- skipped: input.skipped ?? false,
203
- skipReason: input.skipReason ?? null,
204
- outcome: input.outcome ?? null,
205
- error: input.error ?? null,
206
- phaseId: input.phaseId ?? null,
207
- itemType: input.itemType ?? null,
208
- itemId: input.itemId ?? null,
209
- commandVariant: input.commandVariant ?? null,
210
- },
199
+ const data = {
200
+ cronId,
201
+ agentId,
202
+ startedAt: input.startedAt,
203
+ completedAt: input.completedAt ?? null,
204
+ skipped: input.skipped ?? false,
205
+ skipReason: input.skipReason ?? null,
206
+ outcome: input.outcome ?? null,
207
+ error: input.error ?? null,
208
+ phaseId: input.phaseId ?? null,
209
+ itemType: input.itemType ?? null,
210
+ itemId: input.itemId ?? null,
211
+ commandVariant: input.commandVariant ?? null,
212
+ };
213
+
214
+ if (input.commandVariant !== RUN_NOW_VARIANT) {
215
+ return this.prisma.agentCronRun.create({ data });
216
+ }
217
+
218
+ // Run-now (COS-42.1): creating the run honours the pending request, so
219
+ // clear the stamp and link the newest requested audit row to this run.
220
+ return this.prisma.$transaction(async (tx) => {
221
+ const run = await tx.agentCronRun.create({ data });
222
+ await tx.agentCronJob.update({
223
+ where: { id: cronId },
224
+ data: { runRequestedAt: null, runRequestedBy: null },
225
+ });
226
+ const request = await tx.agentCronRunRequest.findFirst({
227
+ where: { cronId, status: "requested" },
228
+ orderBy: [{ requestedAt: "desc" }, { createdAt: "desc" }],
229
+ });
230
+ if (request) {
231
+ await tx.agentCronRunRequest.update({
232
+ where: { id: request.id },
233
+ data: { status: "honoured", honouredRunId: run.id },
234
+ });
235
+ // Older still-pending requests were superseded by this one and can
236
+ // never be honoured: record them as expired rather than pending.
237
+ await tx.agentCronRunRequest.updateMany({
238
+ where: { cronId, status: "requested", id: { not: request.id } },
239
+ data: { status: "expired" },
240
+ });
241
+ }
242
+ return run;
211
243
  });
212
244
  }
213
245
 
@@ -183,6 +183,8 @@ const MOCK_CRON = {
183
183
  system: false,
184
184
  parentCronId: null as string | null,
185
185
  lockdownDisabledAt: null,
186
+ runRequestedAt: null,
187
+ runRequestedBy: null,
186
188
  createdAt: new Date("2024-01-01"),
187
189
  updatedAt: new Date("2024-01-01"),
188
190
  };
@@ -380,6 +382,8 @@ function makeMockDeps(): AdminDeps {
380
382
  system: false,
381
383
  parentCronId: null,
382
384
  lockdownDisabledAt: null,
385
+ runRequestedAt: null,
386
+ runRequestedBy: null,
383
387
  createdAt: new Date("2024-01-01"),
384
388
  updatedAt: new Date("2024-01-01"),
385
389
  }),
@@ -397,6 +401,8 @@ function makeMockDeps(): AdminDeps {
397
401
  system: false,
398
402
  parentCronId: null,
399
403
  lockdownDisabledAt: null,
404
+ runRequestedAt: null,
405
+ runRequestedBy: null,
400
406
  createdAt: new Date("2024-01-01"),
401
407
  updatedAt: new Date("2024-01-01"),
402
408
  }),
@@ -415,6 +421,8 @@ function makeMockDeps(): AdminDeps {
415
421
  system: false,
416
422
  parentCronId: null,
417
423
  lockdownDisabledAt: null,
424
+ runRequestedAt: null,
425
+ runRequestedBy: null,
418
426
  createdAt: new Date("2024-01-01"),
419
427
  updatedAt: new Date("2024-01-01"),
420
428
  }),
@@ -432,6 +440,8 @@ function makeMockDeps(): AdminDeps {
432
440
  system: false,
433
441
  parentCronId: null,
434
442
  lockdownDisabledAt: null,
443
+ runRequestedAt: null,
444
+ runRequestedBy: null,
435
445
  createdAt: new Date("2024-01-01"),
436
446
  updatedAt: new Date("2024-01-01"),
437
447
  }),
@@ -449,6 +459,8 @@ function makeMockDeps(): AdminDeps {
449
459
  system: false,
450
460
  parentCronId: null,
451
461
  lockdownDisabledAt: null,
462
+ runRequestedAt: null,
463
+ runRequestedBy: null,
452
464
  createdAt: new Date("2024-01-01"),
453
465
  updatedAt: new Date("2024-01-01"),
454
466
  }),
@@ -4631,6 +4643,8 @@ describe("admin API — cron runs", () => {
4631
4643
  system: false,
4632
4644
  parentCronId: null,
4633
4645
  lockdownDisabledAt: null,
4646
+ runRequestedAt: null,
4647
+ runRequestedBy: null,
4634
4648
  createdAt: new Date("2024-01-01"),
4635
4649
  updatedAt: new Date("2024-01-01"),
4636
4650
  lastRun: null,
@@ -4649,6 +4663,8 @@ describe("admin API — cron runs", () => {
4649
4663
  name: "shipwright-dev-task",
4650
4664
  system: true,
4651
4665
  lockdownDisabledAt: null,
4666
+ runRequestedAt: null,
4667
+ runRequestedBy: null,
4652
4668
  parentCronId: CRON_ID,
4653
4669
  createdAt: new Date("2024-01-01"),
4654
4670
  updatedAt: new Date("2024-01-01"),
@@ -4936,6 +4952,8 @@ function makeMockDepsWithRunSummary(): AdminDeps {
4936
4952
  system: false,
4937
4953
  parentCronId: null,
4938
4954
  lockdownDisabledAt: null,
4955
+ runRequestedAt: null,
4956
+ runRequestedBy: null,
4939
4957
  createdAt: new Date("2024-01-01"),
4940
4958
  updatedAt: new Date("2024-01-01"),
4941
4959
  lastRun: {
package/src/agents-api.ts CHANGED
@@ -46,6 +46,10 @@ import {
46
46
  registerAgentAskRoutes,
47
47
  } from "./agent-ask.ts";
48
48
  import type { AgentChatTokenService } from "./agent-chat-tokens.ts";
49
+ import {
50
+ type CronRunNowDeps,
51
+ registerCronRunNowRoutes,
52
+ } from "./cron-run-now.ts";
49
53
  import type {
50
54
  AgentCronJobService,
51
55
  AgentCronJobWithRunSummary,
@@ -83,6 +87,10 @@ import {
83
87
  memberOnlyCallerScopeResolver,
84
88
  } from "./caller-scope.ts";
85
89
  import type { ChatServiceProvisioningClient } from "./chat-service-provisioning-client.ts";
90
+ import {
91
+ type CronRunTranscriptDeps,
92
+ registerCronRunTranscriptRoutes,
93
+ } from "./cron-run-transcripts.ts";
86
94
  import {
87
95
  ApiError,
88
96
  BadRequestError,
@@ -285,12 +293,23 @@ export interface AdminDeps {
285
293
  * 404. See agent-ask.ts.
286
294
  */
287
295
  agentAsk?: AgentAskDeps;
296
+ /**
297
+ * Cron run-now (COS-42.1): flag config, the stamp+audit store and an
298
+ * injectable clock. Absent or flag off -> the run-now route 404s. See
299
+ * cron-run-now.ts.
300
+ */
301
+ cronRunNow?: CronRunNowDeps;
288
302
  /**
289
303
  * Chat-service thread/message client behind POST /agents/:id/ask
290
304
  * (admin-boot wires the HttpChatClient the chat UI uses). Absent → the ask
291
305
  * route 503s, even with the flag on.
292
306
  */
293
307
  chatClient?: AskChatClient;
308
+ /**
309
+ * Cron-run transcripts (COS-43.1): flag config + store. Absent or flag off
310
+ * -> the transcript routes 404. See cron-run-transcripts.ts.
311
+ */
312
+ transcripts?: CronRunTranscriptDeps;
294
313
  }
295
314
 
296
315
  export type { AdminApiKey };
@@ -2335,6 +2354,14 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
2335
2354
  agentService,
2336
2355
  });
2337
2356
 
2357
+ // ─── Cron run-now (COS-42.1) ──────────────────────────────────────────────
2358
+
2359
+ registerCronRunNowRoutes(app, { cronRunNow: deps.cronRunNow });
2360
+
2361
+ // ─── Cron-run transcripts (COS-43.1) ──────────────────────────────────────
2362
+
2363
+ registerCronRunTranscriptRoutes(app, { transcripts: deps.transcripts });
2364
+
2338
2365
  // ─── Plugins ───────────────────────────────────────────────────────────────
2339
2366
 
2340
2367
  // POST /agents/:id/plugins — add a plugin
@@ -2516,10 +2543,12 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
2516
2543
  function serializeCron(cron: {
2517
2544
  createdAt: Date;
2518
2545
  updatedAt: Date;
2546
+ runRequestedAt?: Date | null;
2519
2547
  [k: string]: unknown;
2520
2548
  }): z.infer<typeof AgentCronJobSchema> {
2521
2549
  return {
2522
2550
  ...cron,
2551
+ runRequestedAt: cron.runRequestedAt?.toISOString() ?? null,
2523
2552
  createdAt: cron.createdAt.toISOString(),
2524
2553
  updatedAt: cron.updatedAt.toISOString(),
2525
2554
  } as z.infer<typeof AgentCronJobSchema>;
@@ -2549,6 +2578,7 @@ function serializeCronWithSummary(job: AgentCronJobWithRunSummary): z.infer<
2549
2578
  name: job.name,
2550
2579
  system: job.system,
2551
2580
  parentCronId: job.parentCronId,
2581
+ runRequestedAt: job.runRequestedAt?.toISOString() ?? null,
2552
2582
  createdAt: job.createdAt.toISOString(),
2553
2583
  updatedAt: job.updatedAt.toISOString(),
2554
2584
  lastRun: job.lastRun
package/src/api-auth.ts CHANGED
@@ -106,6 +106,7 @@ export const PLATFORM_OPS_ROUTES: readonly PlatformOpsRoute[] = [
106
106
  { method: "DELETE", path: "/agents/{id}/repos", subpaths: true },
107
107
  { method: "PATCH", path: "/agents/{id}/crons/{cronId}" },
108
108
  { method: "POST", path: "/agents/{id}/ask" },
109
+ { method: "POST", path: "/agents/{id}/crons/{cronId}/run-now" },
109
110
  { method: "POST", path: "/agents/{id}/chat-credentials", subpaths: true },
110
111
  { method: "DELETE", path: "/agents/{id}/chat-credentials", subpaths: true },
111
112
  ];