@app-vitals/agent-admin 0.3.0 → 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.
@@ -27,6 +27,7 @@ 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'
30
31
  export type * from './models/AgentCronRunTranscript.ts'
31
32
  export type * from './models/AgentTranscriptRead.ts'
32
33
  export type * from './models/AgentGitHubInstallationsSnapshot.ts'
@@ -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,25 @@ 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
+
429
453
  // ─── Cron-run transcripts (COS-43.1) ────────────────────────────────────────
430
454
  // One redacted session transcript per cron run, uploaded by the agent runtime
431
455
  // (already redacted + tail-truncated to 1 MiB) and served only to admin /
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";
@@ -292,6 +294,8 @@ export async function startAdmin(
292
294
 
293
295
  // Agent-to-agent ask (COS-31.1). Throws at boot on a malformed allowlist.
294
296
  const agentAskConfig = parseAgentAskConfig(process.env);
297
+ // Cron run-now (COS-42.1).
298
+ const cronRunNowConfig = parseCronRunNowConfig(process.env);
295
299
 
296
300
  // Cron-run transcript API (COS-43.1): flag off by default.
297
301
  const transcriptConfig = parseTranscriptConfig(process.env);
@@ -462,6 +466,10 @@ export async function startAdmin(
462
466
  config: agentAskConfig,
463
467
  store: new AgentAskService(prisma),
464
468
  },
469
+ cronRunNow: {
470
+ config: cronRunNowConfig,
471
+ store: new CronRunNowService(prisma),
472
+ },
465
473
  ...(chatClient ? { chatClient } : {}),
466
474
  transcripts: {
467
475
  config: transcriptConfig,
@@ -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,
@@ -289,6 +293,12 @@ export interface AdminDeps {
289
293
  * 404. See agent-ask.ts.
290
294
  */
291
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;
292
302
  /**
293
303
  * Chat-service thread/message client behind POST /agents/:id/ask
294
304
  * (admin-boot wires the HttpChatClient the chat UI uses). Absent → the ask
@@ -2344,6 +2354,10 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
2344
2354
  agentService,
2345
2355
  });
2346
2356
 
2357
+ // ─── Cron run-now (COS-42.1) ──────────────────────────────────────────────
2358
+
2359
+ registerCronRunNowRoutes(app, { cronRunNow: deps.cronRunNow });
2360
+
2347
2361
  // ─── Cron-run transcripts (COS-43.1) ──────────────────────────────────────
2348
2362
 
2349
2363
  registerCronRunTranscriptRoutes(app, { transcripts: deps.transcripts });
@@ -2529,10 +2543,12 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
2529
2543
  function serializeCron(cron: {
2530
2544
  createdAt: Date;
2531
2545
  updatedAt: Date;
2546
+ runRequestedAt?: Date | null;
2532
2547
  [k: string]: unknown;
2533
2548
  }): z.infer<typeof AgentCronJobSchema> {
2534
2549
  return {
2535
2550
  ...cron,
2551
+ runRequestedAt: cron.runRequestedAt?.toISOString() ?? null,
2536
2552
  createdAt: cron.createdAt.toISOString(),
2537
2553
  updatedAt: cron.updatedAt.toISOString(),
2538
2554
  } as z.infer<typeof AgentCronJobSchema>;
@@ -2562,6 +2578,7 @@ function serializeCronWithSummary(job: AgentCronJobWithRunSummary): z.infer<
2562
2578
  name: job.name,
2563
2579
  system: job.system,
2564
2580
  parentCronId: job.parentCronId,
2581
+ runRequestedAt: job.runRequestedAt?.toISOString() ?? null,
2565
2582
  createdAt: job.createdAt.toISOString(),
2566
2583
  updatedAt: job.updatedAt.toISOString(),
2567
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
  ];
@@ -208,6 +208,8 @@ function makeCron(agentId: string, id: string): AgentCronJob {
208
208
  system: false,
209
209
  parentCronId: null,
210
210
  lockdownDisabledAt: null,
211
+ runRequestedAt: null,
212
+ runRequestedBy: null,
211
213
  createdAt: makeDate(),
212
214
  updatedAt: makeDate(),
213
215
  };
@@ -814,6 +816,8 @@ function buildCombinedApp() {
814
816
  system: false,
815
817
  parentCronId: null,
816
818
  lockdownDisabledAt: null,
819
+ runRequestedAt: null,
820
+ runRequestedBy: null,
817
821
  createdAt: new Date(),
818
822
  updatedAt: new Date(),
819
823
  }),
@@ -831,6 +835,8 @@ function buildCombinedApp() {
831
835
  system: false,
832
836
  parentCronId: null,
833
837
  lockdownDisabledAt: null,
838
+ runRequestedAt: null,
839
+ runRequestedBy: null,
834
840
  createdAt: new Date(),
835
841
  updatedAt: new Date(),
836
842
  }),
@@ -849,6 +855,8 @@ function buildCombinedApp() {
849
855
  system: false,
850
856
  parentCronId: null,
851
857
  lockdownDisabledAt: null,
858
+ runRequestedAt: null,
859
+ runRequestedBy: null,
852
860
  createdAt: new Date(),
853
861
  updatedAt: new Date(),
854
862
  }),
@@ -866,6 +874,8 @@ function buildCombinedApp() {
866
874
  system: false,
867
875
  parentCronId: null,
868
876
  lockdownDisabledAt: null,
877
+ runRequestedAt: null,
878
+ runRequestedBy: null,
869
879
  createdAt: new Date(),
870
880
  updatedAt: new Date(),
871
881
  }),
@@ -888,6 +898,8 @@ function buildCombinedApp() {
888
898
  system: false,
889
899
  parentCronId: null,
890
900
  lockdownDisabledAt: null,
901
+ runRequestedAt: null,
902
+ runRequestedBy: null,
891
903
  createdAt: new Date(),
892
904
  updatedAt: new Date(),
893
905
  }),
@@ -187,6 +187,8 @@ const MOCK_CRON = {
187
187
  system: false,
188
188
  parentCronId: null,
189
189
  lockdownDisabledAt: null,
190
+ runRequestedAt: null,
191
+ runRequestedBy: null,
190
192
  silent: false,
191
193
  preCheck: null,
192
194
  createdAt: new Date("2024-01-01"),
@@ -0,0 +1,20 @@
1
+ /**
2
+ * admin/src/cron-run-now-config.ts
3
+ *
4
+ * Pure parser for the cron run-now flag (COS-42.1). Takes an injected env
5
+ * object (no process.env reads) so it is deterministically unit-testable;
6
+ * admin-boot reads it once at startup and injects the resolved config as
7
+ * deps. Same "enabled" convention as the agent-ask and self-serve flags.
8
+ */
9
+
10
+ /** Structural env shape — accepts `process.env` or a plain test object. */
11
+ type CronRunNowEnv = Readonly<Record<string, string | undefined>>;
12
+
13
+ export interface CronRunNowConfig {
14
+ /** True only when SHIPWRIGHT_CRON_RUN_NOW_ENABLED === "enabled". */
15
+ enabled: boolean;
16
+ }
17
+
18
+ export function parseCronRunNowConfig(env: CronRunNowEnv): CronRunNowConfig {
19
+ return { enabled: env.SHIPWRIGHT_CRON_RUN_NOW_ENABLED === "enabled" };
20
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * admin/src/cron-run-now-config.unit.test.ts
3
+ * Pure parser tests for the cron run-now flag (COS-42.1).
4
+ */
5
+
6
+ import { describe, expect, it } from "bun:test";
7
+ import { parseCronRunNowConfig } from "./cron-run-now-config.ts";
8
+
9
+ describe("parseCronRunNowConfig", () => {
10
+ it('is on only for the literal "enabled"', () => {
11
+ expect(
12
+ parseCronRunNowConfig({ SHIPWRIGHT_CRON_RUN_NOW_ENABLED: "enabled" })
13
+ .enabled,
14
+ ).toBe(true);
15
+ });
16
+
17
+ it('"true", "" and absent are off', () => {
18
+ for (const value of ["true", "", "Enabled", " enabled", undefined]) {
19
+ expect(
20
+ parseCronRunNowConfig({ SHIPWRIGHT_CRON_RUN_NOW_ENABLED: value })
21
+ .enabled,
22
+ ).toBe(false);
23
+ }
24
+ expect(parseCronRunNowConfig({}).enabled).toBe(false);
25
+ });
26
+ });